diff --git a/.github/scripts/wait-for-image-published.sh b/.github/scripts/wait-for-image-published.sh deleted file mode 100755 index 880a432f9..000000000 --- a/.github/scripts/wait-for-image-published.sh +++ /dev/null @@ -1,95 +0,0 @@ -#!/usr/bin/env bash -# Wait until the infrastructure repository_dispatch run for this image+tag+sha -# completes successfully. A cancelled run is not a product failure: two image -# publishes can overlap and GitHub then cancels one host job. Re-dispatch once -# and keep polling for a successful run of the same image:tag:sha. -# -# The product Deploy job stays in progress so a develop→main PR shows the -# live deploy result on the same commit, not only the image push. -# -# Required env: GH_TOKEN, DISPATCH_REPO, GITHUB_SHA, IMAGE, TAG, DISPATCHED_AT -# Optional: WAIT_TIMEOUT_SEC (default 1200), WAIT_POLL_SEC (default 10) -set -euo pipefail - -repo="${DISPATCH_REPO:?DISPATCH_REPO is required}" -sha="${GITHUB_SHA:?GITHUB_SHA is required}" -image="${IMAGE:?IMAGE is required}" -tag="${TAG:?TAG is required}" -dispatched_at="${DISPATCHED_AT:?DISPATCHED_AT is required}" -timeout_sec="${WAIT_TIMEOUT_SEC:-1200}" -poll_sec="${WAIT_POLL_SEC:-10}" - -if [ "${#sha}" -ne 40 ]; then - echo "::error::GITHUB_SHA must be a 40-character commit SHA" - exit 1 -fi - -needle="image-published ${image}:${tag} ${sha}" -echo "Waiting for infrastructure run titled: ${needle}" - -deadline=$((SECONDS + timeout_sec)) -run_id="" -redispatched=0 -# Space-separated databaseIds of cancelled runs we already handled. -skipped_ids="" - -find_run() { - json="$(gh run list --repo "$repo" --event repository_dispatch --limit 30 \ - --json databaseId,displayTitle,status,conclusion,createdAt)" - printf '%s\n' "$json" | jq -r --arg n "$needle" --arg t "$dispatched_at" --arg s "$skipped_ids" \ - '[.[] | (.databaseId | tostring) as $id - | select(.displayTitle == $n and .createdAt >= $t - and ($s == "" or (($s | split(" ") | index($id)) == null)))] - | sort_by(.createdAt) | reverse | .[0].databaseId // empty' -} - -poll_run() { - gh run view "$run_id" --repo "$repo" --json status,conclusion -} - -redispatch() { - echo "Re-dispatching image-published ${image}:${tag} ${sha}" - dispatched_at="$(date -u -d '5 seconds ago' +%Y-%m-%dT%H:%M:%SZ)" - gh api "repos/${repo}/dispatches" \ - -f event_type=image-published \ - -f "client_payload[image]=${image}" \ - -f "client_payload[tag]=${tag}" \ - -f "client_payload[sha]=${sha}" - redispatched=1 - run_id="" -} - -while [ "$SECONDS" -lt "$deadline" ]; do - if [ -z "$run_id" ]; then - run_id="$(find_run)" - if [ -z "$run_id" ]; then - echo "No matching infrastructure run yet" - sleep "$poll_sec" - continue - fi - echo "Pinned infrastructure run ${run_id}" - fi - view="$(poll_run)" - status="$(printf '%s\n' "$view" | jq -r '.status')" - conclusion="$(printf '%s\n' "$view" | jq -r '.conclusion // ""')" - if [ "$status" = "completed" ]; then - if [ "$conclusion" = "success" ]; then - echo "Infrastructure deploy succeeded" - exit 0 - fi - echo "Infrastructure run ${run_id} ended (${conclusion})" - if [ "$conclusion" = "cancelled" ] && [ "$redispatched" -eq 0 ]; then - skipped_ids="${skipped_ids} ${run_id}" - redispatch - sleep "$poll_sec" - continue - fi - echo "::error::Infrastructure deploy did not succeed (conclusion=${conclusion})" - exit 1 - fi - echo "Infrastructure run ${run_id} is ${status}" - sleep "$poll_sec" -done - -echo "::error::Timed out waiting for infrastructure deploy of this image" -exit 1 diff --git a/.github/workflows/deploy-dev.yaml b/.github/workflows/deploy-dev.yaml deleted file mode 100644 index 7aecb7eda..000000000 --- a/.github/workflows/deploy-dev.yaml +++ /dev/null @@ -1,72 +0,0 @@ -name: Deploy DEV - -on: - push: - branches: [develop] - workflow_dispatch: - -concurrency: - group: deploy-dev - cancel-in-progress: false - -env: - DOCKER_TAGS: opencryptopay/api:beta - -permissions: - contents: read - -jobs: - build-and-deploy: - name: Build and deploy to DEV - runs-on: ubuntu-24.04-arm - timeout-minutes: 45 - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Log in to Docker Hub - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_PASSWORD }} - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Build and push Docker image - uses: docker/build-push-action@v6 - with: - context: . - push: true - tags: ${{ env.DOCKER_TAGS }} - platforms: linux/arm64 - cache-from: type=registry,ref=opencryptopay/api:buildcache - cache-to: type=registry,ref=opencryptopay/api:buildcache,mode=max - - - name: Notify infrastructure to pull :beta - env: - GH_TOKEN: ${{ secrets.DISPATCH_TOKEN }} - DISPATCH_REPO: ${{ secrets.DISPATCH_REPO }} - run: | - set -euo pipefail - if [ -z "${GH_TOKEN}" ] || [ -z "${DISPATCH_REPO}" ]; then - echo "::error::DISPATCH_TOKEN or DISPATCH_REPO unset — cannot deploy this image" - exit 1 - fi - # Five-second slack so a run created just before this clock is still - # eligible; the wait script still matches image+tag+sha. - dispatched_at="$(date -u -d '5 seconds ago' +%Y-%m-%dT%H:%M:%SZ)" - echo "DISPATCHED_AT=${dispatched_at}" >> "$GITHUB_ENV" - gh api "repos/${DISPATCH_REPO}/dispatches" \ - -f event_type=image-published \ - -f "client_payload[image]=opencryptopay/api" \ - -f "client_payload[tag]=beta" \ - -f "client_payload[sha]=${GITHUB_SHA}" - - - name: Wait for infrastructure deploy - env: - GH_TOKEN: ${{ secrets.DISPATCH_TOKEN }} - DISPATCH_REPO: ${{ secrets.DISPATCH_REPO }} - IMAGE: opencryptopay/api - TAG: beta - run: bash .github/scripts/wait-for-image-published.sh diff --git a/.github/workflows/deploy-prd.yaml b/.github/workflows/deploy-prd.yaml deleted file mode 100644 index 0b10f5931..000000000 --- a/.github/workflows/deploy-prd.yaml +++ /dev/null @@ -1,72 +0,0 @@ -name: Deploy PRD - -on: - push: - branches: [main] - workflow_dispatch: - -concurrency: - group: deploy-prd - cancel-in-progress: false - -env: - DOCKER_TAGS: opencryptopay/api:latest - -permissions: - contents: read - -jobs: - build-and-deploy: - name: Build and deploy to PRD - runs-on: ubuntu-24.04-arm - timeout-minutes: 45 - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Log in to Docker Hub - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_PASSWORD }} - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Build and push Docker image - uses: docker/build-push-action@v6 - with: - context: . - push: true - tags: ${{ env.DOCKER_TAGS }} - platforms: linux/arm64 - cache-from: type=registry,ref=opencryptopay/api:buildcache - cache-to: type=registry,ref=opencryptopay/api:buildcache,mode=max - - - name: Notify infrastructure to pull :latest - env: - GH_TOKEN: ${{ secrets.DISPATCH_TOKEN }} - DISPATCH_REPO: ${{ secrets.DISPATCH_REPO }} - run: | - set -euo pipefail - if [ -z "${GH_TOKEN}" ] || [ -z "${DISPATCH_REPO}" ]; then - echo "::error::DISPATCH_TOKEN or DISPATCH_REPO unset — cannot deploy this image" - exit 1 - fi - # Five-second slack so a run created just before this clock is still - # eligible; the wait script still matches image+tag+sha. - dispatched_at="$(date -u -d '5 seconds ago' +%Y-%m-%dT%H:%M:%SZ)" - echo "DISPATCHED_AT=${dispatched_at}" >> "$GITHUB_ENV" - gh api "repos/${DISPATCH_REPO}/dispatches" \ - -f event_type=image-published \ - -f "client_payload[image]=opencryptopay/api" \ - -f "client_payload[tag]=latest" \ - -f "client_payload[sha]=${GITHUB_SHA}" - - - name: Wait for infrastructure deploy - env: - GH_TOKEN: ${{ secrets.DISPATCH_TOKEN }} - DISPATCH_REPO: ${{ secrets.DISPATCH_REPO }} - IMAGE: opencryptopay/api - TAG: latest - run: bash .github/scripts/wait-for-image-published.sh diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0a76bd639..2bb40348f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,15 +4,15 @@ The map API keeps the same gates as the 21.gifts API. ## Branches -| Branch | Purpose | Image | -| --------- | -------------------------------------------------------- | -------------------------- | -| `develop` | Integration branch. Open pull requests here. | `opencryptopay/api:beta` | -| `main` | Production. Moves only through the release pull request. | `opencryptopay/api:latest` | +| Branch | Purpose | +| --------- | -------------------------------------------------------- | +| `develop` | Integration branch. Open pull requests here. | +| `main` | Production. Moves only through the release pull request. | - Land work on `develop` through a feature branch and a pull request. Do not push `develop` or `main` directly. -- A push to `develop` publishes `:beta` for the dev environment and runs CI on that commit. The release pull request shows that same CI run. -- A push to `main` publishes `:latest` for production. -- `auto-release-pr.yaml` opens `Release: develop -> main` when `develop` is ahead of `main`. A normal production publish waits for that pull request. The dev and production workflows also have a manual run, and that run publishes `:beta` or `:latest` for whichever commit you start it on. +- 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. +- `auto-release-pr.yaml` opens `Release: develop -> main` when `develop` is ahead of `main`. - Never force-push, and never amend a published commit. ## Commit messages @@ -38,19 +38,4 @@ Do not lower a coverage threshold to land a change. A genuinely unreachable bran | Workflow | Trigger | Action | | ---------------------- | ----------------------------------- | ----------------------------------------------------------------------- | | `ci.yaml` | Pull request, and push to `develop` | Typecheck, lint, handbook, e2e-check, test (100% coverage), build, e2e | -| `deploy-dev.yaml` | Push to `develop` | Build and push `opencryptopay/api:beta`, then wait for the deploy | -| `deploy-prd.yaml` | Push to `main` | Build and push `opencryptopay/api:latest`, then wait for the deploy | | `auto-release-pr.yaml` | Push to `develop` | Open the release pull request (`develop` → `main`) when there is a diff | - -Images target `linux/arm64`. - -Deploy workflows need these Actions secrets: - -| Secret | Purpose | -| ----------------- | ----------------------------------------------------- | -| `DOCKER_USERNAME` | Registry username for the image push | -| `DOCKER_PASSWORD` | Registry token for the image push | -| `DISPATCH_TOKEN` | Token that sends `image-published` and reads that run | -| `DISPATCH_REPO` | `owner/repo` that receives `image-published` | - -If `DISPATCH_TOKEN` or `DISPATCH_REPO` is missing, deploy fails after the image push. The job then waits for the infrastructure run titled `image-published opencryptopay/api: ` and fails when that run does not succeed. diff --git a/README.md b/README.md index 8966a9eb3..83e82bd8f 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # OpenCryptoPay API -Map API. `GET /map/places` is the public list of places. `POST /map/places` adds a place once. +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. The same `origin` and `externalId` returns the first pin and does not submit it again. When `BTCMAP_ACCESS_TOKEN` is set, a new pin is posted once to `https://api.btcmap.org/v4/place-submissions`. A missing token stores the pin and skips that call. @@ -20,7 +20,7 @@ The process listens on port `3000` unless `PORT` is set. The database file is `d ## Branches -Pull requests target `develop`. A push there publishes `opencryptopay/api:beta`. `main` publishes `opencryptopay/api:latest` and moves only through the release pull request from `develop`. +Pull requests target `develop`. `main` moves only through the release pull request from `develop`. This repository does not publish a container image. ## Checks diff --git a/docs/handbook/endpoints.md b/docs/handbook/endpoints.md index 89e5ebeb0..86e21944f 100644 --- a/docs/handbook/endpoints.md +++ b/docs/handbook/endpoints.md @@ -9,14 +9,28 @@ ## Endpoint: GET /map/places -- **Purpose:** Public map list, newest first. Each item is `id`, `origin`, `name`, `lat`, `lon`, and `category`. The caller id and payment methods are not included. +- **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. - **Used by:** The OpenCryptoPay map. - **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 and does not call BTC Map again. A new row is posted once when `BTCMAP_ACCESS_TOKEN` is set. +- **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`. - **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. A BTC Map failure still returns 201 with `btcmap: "failed"`. - **Used by:** Trusted callers that add a shop the first time it appears. - **Auth:** `Authorization: Bearer` must equal `OCP_PLACE_INGEST_TOKEN`. Status 201 when new, 200 when the pair already exists. + +## Endpoint: PUT /map/places + +- **Purpose:** Update one map pin, or create it when the origin and external id are new. BTC Map is called only when this request inserts the row. An update returns `btcmap: "skipped"` and does not call BTC Map. +- **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 correct a shop or set its tech provider. +- **Auth:** `Authorization: Bearer` must equal `OCP_PLACE_INGEST_TOKEN`. Status 201 when new, 200 when the pair already exists. + +## Endpoint: DELETE /map/places + +- **Purpose:** Remove one map pin by origin and external id. A missing pair returns `{ deleted: false }` with status 200. This call does not contact BTC Map. +- **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`. diff --git a/docs/handbook/functions.md b/docs/handbook/functions.md index 8d7d9c6e4..024b36313 100644 --- a/docs/handbook/functions.md +++ b/docs/handbook/functions.md @@ -2,29 +2,36 @@ ## 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. -- **Inputs:** Unknown JSON. Required fields are `origin`, `externalId`, `name`, `lat`, `lon`, and `category`. +- **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. - **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`. +- **Used by:** `mapRoutes` on `POST /map/places` and `PUT /map/places`. + +## 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. +- **Inputs:** Unknown JSON. Required fields are `origin` and `externalId`, with the same rules as `normalizeMapPlace`. +- **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: toPublicMapPlace -- **Purpose:** Strip the caller id and payment methods before a pin is shown on the public map. +- **Purpose:** Strip the caller id and payment methods before a pin is shown on the public map. The tech provider stays on the public row. - **Inputs:** A stored map pin. -- **Returns / side effects:** `id`, `origin`, `name`, `lat`, `lon`, and `category`. No I/O. +- **Returns / side effects:** `id`, `origin`, `name`, `lat`, `lon`, `category`, and `techProvider`. No I/O. - **Used by:** `mapRoutes` on `GET /map/places`. ## Function: MemoryMapPlaceStore -- **Purpose:** In-memory map store with the same create-once rule as SQLite. A repeat of origin plus external id returns the first row unchanged. -- **Inputs:** `insertIfNew` takes a validated pin. `list` takes a positive limit. -- **Returns / side effects:** `insertIfNew` reports whether it created the row. `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. +- **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. - **Used by:** Unit tests. The process uses `SqliteMapPlaceStore`. ## Function: SqliteMapPlaceStore -- **Purpose:** SQLite driver for map pins. The schema is `MAP_PLACE_SCHEMA_SQL`. A conflicting insert does not overwrite the first row. -- **Inputs:** Constructor takes a SQLite filename or `:memory:`. +- **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. - **Used by:** The process entrypoint. The HTTP end-to-end run exercises it. @@ -40,13 +47,13 @@ - **Purpose:** POST one new pin to BTC Map. A missing or blank token skips the call. A network or HTTP failure returns `failed` and does not throw. The token is not logged. - **Inputs:** Stored pin, environment (`BTCMAP_ACCESS_TOKEN`, optional `BTCMAP_SUBMIT_URL`), and fetch. The default URL is `https://api.btcmap.org/v4/place-submissions`. - **Returns / side effects:** `sent`, `failed`, or `skipped`. Timeout is 5000 ms. -- **Used by:** `mapRoutes` after a new insert. +- **Used by:** `mapRoutes` after a new insert from POST or PUT. ## Function: mapRoutes -- **Purpose:** `GET /map/places` is public. `POST /map/places` requires the ingest bearer and creates a pin at most once. +- **Purpose:** `GET /map/places` is public. `POST /map/places` requires the ingest bearer and creates a pin at most once. `PUT /map/places` upserts a pin. `DELETE /map/places` removes a pin by origin and external id. - **Inputs:** Store, optional ingest token, environment, and fetch. -- **Returns / side effects:** GET returns `{ places }`. POST returns 201 `{ created: true, id, btcmap }` or 200 `{ created: false, id, btcmap: "skipped" }`. +- **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 }`. - **Used by:** `createApp`, mounted at `/map`. ## Function: healthRoutes diff --git a/docs/schema/map-place.sql b/docs/schema/map-place.sql index 2495cd38e..976ec2a25 100644 --- a/docs/schema/map-place.sql +++ b/docs/schema/map-place.sql @@ -1,4 +1,4 @@ --- Map pins. A repeated origin and external id keeps the first row. +-- Map pins keyed by origin and external id. CREATE TABLE IF NOT EXISTS map_place ( id TEXT PRIMARY KEY, origin TEXT NOT NULL, @@ -9,5 +9,6 @@ CREATE TABLE IF NOT EXISTS map_place ( category TEXT NOT NULL, payment_methods TEXT, created_at TEXT NOT NULL, + tech_provider TEXT NOT NULL DEFAULT 'DFX.swiss', UNIQUE (origin, external_id) ); diff --git a/e2e/map.spec.ts b/e2e/map.spec.ts index 0e5a35daf..290737abe 100644 --- a/e2e/map.spec.ts +++ b/e2e/map.spec.ts @@ -81,3 +81,69 @@ test('Function: pushMapPlace — default boot does not require a map token', asy const res = await request.get('/healthz'); expect(res.status()).toBe(200); }); + +test('Function: normalizeMapPlaceKey — PUT updates a pin and DELETE removes it', async ({ + request, +}) => { + const pin = { + origin: 'dfx', + externalId: 'e2e-provider', + name: 'Omitted Label Cafe', + lat: 47.37, + lon: 8.54, + category: 'cafe', + }; + const created = await request.post('/map/places', { + headers: { Authorization: 'Bearer e2e-ingest' }, + data: pin, + }); + expect(created.status()).toBe(201); + const afterCreate = (await (await request.get('/map/places')).json()) as { + places: Array<{ name: string; techProvider: string }>; + }; + expect( + afterCreate.places.some( + (row) => row.name === 'Omitted Label Cafe' && row.techProvider === 'DFX.swiss', + ), + ).toBe(true); + + const renamed = await request.put('/map/places', { + headers: { Authorization: 'Bearer e2e-ingest' }, + data: { ...pin, name: 'Updated Label Cafe', techProvider: '21.gifts' }, + }); + expect(renamed.status()).toBe(200); + + const keepProvider = await request.put('/map/places', { + headers: { Authorization: 'Bearer e2e-ingest' }, + data: { ...pin, name: 'Updated Label Cafe' }, + }); + expect(keepProvider.status()).toBe(200); + + const afterPut = (await (await request.get('/map/places')).json()) as { + places: Array<{ name: string; techProvider: string }>; + }; + expect( + afterPut.places.some( + (row) => row.name === 'Updated Label Cafe' && row.techProvider === '21.gifts', + ), + ).toBe(true); + + const deleted = await request.delete('/map/places', { + headers: { Authorization: 'Bearer e2e-ingest' }, + data: { origin: 'dfx', externalId: 'e2e-provider' }, + }); + expect(deleted.status()).toBe(200); + expect(await deleted.json()).toEqual({ deleted: true }); + + const afterDelete = (await (await request.get('/map/places')).json()) as { + places: Array<{ name: string }>; + }; + expect(afterDelete.places.some((row) => row.name === 'Updated Label Cafe')).toBe(false); + + const again = await request.delete('/map/places', { + headers: { Authorization: 'Bearer e2e-ingest' }, + data: { origin: 'dfx', externalId: 'e2e-provider' }, + }); + expect(again.status()).toBe(200); + expect(await again.json()).toEqual({ deleted: false }); +}); diff --git a/src/__tests__/map/btcmap.test.ts b/src/__tests__/map/btcmap.test.ts index 0690acbe4..f0edb8725 100644 --- a/src/__tests__/map/btcmap.test.ts +++ b/src/__tests__/map/btcmap.test.ts @@ -11,6 +11,7 @@ const place: StoredMapPlace = { lon: 8.54, category: 'groceries', paymentMethods: 'lightning', + techProvider: 'DFX.swiss', createdAt: '2026-09-26T00:00:00.000Z', }; diff --git a/src/__tests__/map/place.test.ts b/src/__tests__/map/place.test.ts index 41b08e7ab..689f2af23 100644 --- a/src/__tests__/map/place.test.ts +++ b/src/__tests__/map/place.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { normalizeMapPlace, toPublicMapPlace } from '@/lib/map/place'; +import { normalizeMapPlace, normalizeMapPlaceKey, toPublicMapPlace } from '@/lib/map/place'; import type { StoredMapPlace } from '@/lib/map/place'; const valid = { @@ -77,12 +77,117 @@ describe('normalizeMapPlace', () => { expect(normalizeMapPlace({ ...valid, category: 'Cafe' }).ok).toBe(false); expect(normalizeMapPlace({ ...valid, category: '' }).ok).toBe(false); }); + + it('omits an absent, null, or trim-empty tech provider', () => { + const absent = normalizeMapPlace(valid); + expect(absent.ok && absent.value.techProvider).toBeUndefined(); + const missing = normalizeMapPlace({ ...valid }); + expect(missing.ok && !('techProvider' in missing.value)).toBe(true); + const nulled = normalizeMapPlace({ ...valid, techProvider: null }); + expect(nulled.ok && nulled.value.techProvider).toBeUndefined(); + const blank = normalizeMapPlace({ ...valid, techProvider: ' ' }); + expect(blank.ok && blank.value.techProvider).toBeUndefined(); + }); + + it('stores a trimmed tech provider and accepts 21.gifts', () => { + const trimmed = normalizeMapPlace({ ...valid, techProvider: ' DFX.swiss ' }); + expect(trimmed).toEqual({ + ok: true, + value: { ...valid, techProvider: 'DFX.swiss' }, + }); + const gifts = normalizeMapPlace({ ...valid, techProvider: '21.gifts' }); + expect(gifts.ok && gifts.value.techProvider).toBe('21.gifts'); + const max = normalizeMapPlace({ ...valid, techProvider: `A${'a'.repeat(39)}` }); + expect(max.ok && max.value.techProvider).toBe(`A${'a'.repeat(39)}`); + }); + + it('rejects a non-string or invalid tech provider', () => { + expect(normalizeMapPlace({ ...valid, techProvider: 1 })).toEqual({ + ok: false, + error: 'Place tech provider is invalid', + }); + expect(normalizeMapPlace({ ...valid, techProvider: true })).toEqual({ + ok: false, + error: 'Place tech provider is invalid', + }); + expect(normalizeMapPlace({ ...valid, techProvider: 'bad provider' })).toEqual({ + ok: false, + error: 'Place tech provider is invalid', + }); + expect(normalizeMapPlace({ ...valid, techProvider: '-leading' })).toEqual({ + ok: false, + error: 'Place tech provider is invalid', + }); + expect(normalizeMapPlace({ ...valid, techProvider: `A${'a'.repeat(40)}` })).toEqual({ + ok: false, + error: 'Place tech provider is invalid', + }); + }); +}); + +describe('normalizeMapPlaceKey', () => { + it('rejects a non-object and an array', () => { + expect(normalizeMapPlaceKey(null)).toEqual({ + ok: false, + error: 'Place origin is invalid', + }); + expect(normalizeMapPlaceKey([])).toEqual({ + ok: false, + error: 'Place origin is invalid', + }); + expect(normalizeMapPlaceKey('x')).toEqual({ + ok: false, + error: 'Place origin is invalid', + }); + }); + + it('rejects a bad origin and external id with the create error strings', () => { + expect(normalizeMapPlaceKey({ origin: 1, externalId: 'store-1' })).toEqual({ + ok: false, + error: 'Place origin is invalid', + }); + expect(normalizeMapPlaceKey({ origin: 'DFX', externalId: 'store-1' })).toEqual({ + ok: false, + error: 'Place origin is invalid', + }); + expect(normalizeMapPlaceKey({ origin: 'dfx', externalId: 1 })).toEqual({ + ok: false, + error: 'Place external id is required', + }); + expect(normalizeMapPlaceKey({ origin: 'dfx', externalId: '' })).toEqual({ + ok: false, + error: 'Place external id is required', + }); + expect(normalizeMapPlaceKey({ origin: 'dfx', externalId: 'a'.repeat(81) })).toEqual({ + ok: false, + error: 'Place external id is required', + }); + expect(normalizeMapPlaceKey({ origin: 'dfx', externalId: 'bad\nid' })).toEqual({ + ok: false, + error: 'Place external id is required', + }); + }); + + it('returns the trimmed key and ignores extra fields', () => { + expect( + normalizeMapPlaceKey({ + origin: ' dfx ', + externalId: ' store-1 ', + name: 'ignored', + lat: 1, + }), + ).toEqual({ + ok: true, + value: { origin: 'dfx', externalId: 'store-1' }, + }); + }); }); describe('toPublicMapPlace', () => { it('omits the caller id and payment methods', () => { const stored: StoredMapPlace = { ...valid, + techProvider: 'DFX.swiss', id: 'id-1', createdAt: '2026-09-26T00:00:00.000Z', }; @@ -93,6 +198,7 @@ describe('toPublicMapPlace', () => { lat: 47.37, lon: 8.54, category: 'groceries', + techProvider: 'DFX.swiss', }); }); }); diff --git a/src/__tests__/map/routes.test.ts b/src/__tests__/map/routes.test.ts index 9d9fe2e52..f0b74c881 100644 --- a/src/__tests__/map/routes.test.ts +++ b/src/__tests__/map/routes.test.ts @@ -12,6 +12,11 @@ const body = { paymentMethods: 'lightning', }; +const jsonHeaders = { + Authorization: 'Bearer secret', + 'Content-Type': 'application/json', +}; + function app(opts: { token?: string; env?: Record; @@ -55,6 +60,29 @@ describe('map routes', () => { body: JSON.stringify(body), }); expect(blank.status).toBe(503); + const putMissing = await app({}).request('/map/places', { + method: 'PUT', + body: JSON.stringify(body), + }); + expect(putMissing.status).toBe(503); + expect(await putMissing.json()).toEqual({ error: 'Place ingest is not configured' }); + const putBlank = await app({ token: ' ' }).request('/map/places', { + method: 'PUT', + headers: { Authorization: 'Bearer secret' }, + body: JSON.stringify(body), + }); + expect(putBlank.status).toBe(503); + const deleteMissing = await app({}).request('/map/places', { + method: 'DELETE', + body: JSON.stringify({ origin: 'dfx', externalId: 'store-1' }), + }); + expect(deleteMissing.status).toBe(503); + const deleteBlank = await app({ token: ' ' }).request('/map/places', { + method: 'DELETE', + headers: { Authorization: 'Bearer secret' }, + body: JSON.stringify({ origin: 'dfx', externalId: 'store-1' }), + }); + expect(deleteBlank.status).toBe(503); }); it('rejects a missing, short, or wrong bearer and a bad body', async () => { @@ -78,10 +106,52 @@ describe('map routes', () => { expect(wrong.status).toBe(401); const bad = await api.request('/map/places', { method: 'POST', - headers: { Authorization: 'Bearer secret', 'Content-Type': 'application/json' }, + headers: jsonHeaders, body: '{', }); expect(bad.status).toBe(400); + const putNoHeader = await api.request('/map/places', { + method: 'PUT', + body: JSON.stringify(body), + }); + expect(putNoHeader.status).toBe(401); + expect(await putNoHeader.json()).toEqual({ error: 'Unauthorized' }); + const putWrong = await api.request('/map/places', { + method: 'PUT', + headers: { Authorization: 'Bearer secreX' }, + body: JSON.stringify(body), + }); + expect(putWrong.status).toBe(401); + const putBad = await api.request('/map/places', { + method: 'PUT', + headers: jsonHeaders, + body: '{', + }); + expect(putBad.status).toBe(400); + const putInvalidTech = await api.request('/map/places', { + method: 'PUT', + headers: jsonHeaders, + body: JSON.stringify({ ...body, techProvider: 'bad provider' }), + }); + expect(putInvalidTech.status).toBe(400); + expect(await putInvalidTech.json()).toEqual({ error: 'Place tech provider is invalid' }); + const deleteNoHeader = await api.request('/map/places', { + method: 'DELETE', + body: JSON.stringify({ origin: 'dfx', externalId: 'store-1' }), + }); + expect(deleteNoHeader.status).toBe(401); + const deleteWrong = await api.request('/map/places', { + method: 'DELETE', + headers: { Authorization: 'Bearer secreX' }, + body: JSON.stringify({ origin: 'dfx', externalId: 'store-1' }), + }); + expect(deleteWrong.status).toBe(401); + const deleteBad = await api.request('/map/places', { + method: 'DELETE', + headers: jsonHeaders, + body: '{', + }); + expect(deleteBad.status).toBe(400); }); it('stores a pin once and pushes only the first time', async () => { @@ -96,7 +166,7 @@ describe('map routes', () => { }); const created = await api.request('/map/places', { method: 'POST', - headers: { Authorization: 'Bearer secret', 'Content-Type': 'application/json' }, + headers: jsonHeaders, body: JSON.stringify(body), }); expect(created.status).toBe(201); @@ -104,8 +174,8 @@ describe('map routes', () => { expect(createdBody.btcmap).toBe('sent'); const again = await api.request('/map/places', { method: 'POST', - headers: { Authorization: 'Bearer secret', 'Content-Type': 'application/json' }, - body: JSON.stringify({ ...body, name: 'Other' }), + headers: jsonHeaders, + body: JSON.stringify({ ...body, name: 'Other', techProvider: '21.gifts' }), }); expect(again.status).toBe(200); expect(await again.json()).toEqual({ @@ -124,6 +194,89 @@ describe('map routes', () => { lat: 47.37, lon: 8.54, category: 'groceries', + techProvider: 'DFX.swiss', + }); + }); + + it('upserts on PUT and pushes BTC Map only when created', 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 created = await api.request('/map/places', { + method: 'PUT', + headers: jsonHeaders, + body: JSON.stringify({ ...body, techProvider: '21.gifts' }), + }); + expect(created.status).toBe(201); + const createdBody = (await created.json()) as { created: boolean; id: string; btcmap: string }; + expect(createdBody).toEqual({ created: true, id: createdBody.id, btcmap: 'sent' }); + expect(calls).toHaveLength(1); + const updated = await api.request('/map/places', { + method: 'PUT', + headers: jsonHeaders, + body: JSON.stringify({ ...body, name: 'Other' }), + }); + expect(updated.status).toBe(200); + expect(await updated.json()).toEqual({ + created: false, + id: createdBody.id, + btcmap: 'skipped', + }); + expect(calls).toHaveLength(1); + const list = (await (await api.request('/map/places')).json()) as { + places: Array>; + }; + expect(list.places[0]).toEqual({ + id: createdBody.id, + origin: 'dfx', + name: 'Other', + lat: 47.37, + lon: 8.54, + category: 'groceries', + techProvider: '21.gifts', }); }); + + it('deletes a pin without calling BTC Map and is idempotent', 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 created = await api.request('/map/places', { + method: 'POST', + headers: jsonHeaders, + body: JSON.stringify(body), + }); + expect(created.status).toBe(201); + expect(calls).toHaveLength(1); + const deleted = await api.request('/map/places', { + method: 'DELETE', + headers: jsonHeaders, + body: JSON.stringify({ origin: 'dfx', externalId: 'store-1' }), + }); + expect(deleted.status).toBe(200); + expect(await deleted.json()).toEqual({ deleted: true }); + expect(calls).toHaveLength(1); + const listed = (await (await api.request('/map/places')).json()) as { places: unknown[] }; + expect(listed.places).toEqual([]); + const missing = await api.request('/map/places', { + method: 'DELETE', + headers: jsonHeaders, + body: JSON.stringify({ origin: 'dfx', externalId: 'store-1', name: 'ignored' }), + }); + expect(missing.status).toBe(200); + expect(await missing.json()).toEqual({ deleted: false }); + expect(calls).toHaveLength(1); + }); }); diff --git a/src/__tests__/map/store.test.ts b/src/__tests__/map/store.test.ts index c41196b33..be536e220 100644 --- a/src/__tests__/map/store.test.ts +++ b/src/__tests__/map/store.test.ts @@ -29,9 +29,11 @@ describe('MAP_PLACE_SCHEMA_SQL', () => { describe('MemoryMapPlaceStore', () => { it('inserts once and lists newest first without changing the first row', () => { - const store = new MemoryMapPlaceStore(); + let tick = 0; + const store = new MemoryMapPlaceStore(() => new Date(Date.UTC(2026, 0, 1, 0, 0, tick++))); const first = store.insertIfNew(input); expect(first.created).toBe(true); + expect(first.place.techProvider).toBe('DFX.swiss'); const second = store.insertIfNew({ ...input, name: 'Other', lat: 1 }); expect(second.created).toBe(false); expect(second.place.name).toBe('SPAR'); @@ -58,4 +60,70 @@ describe('MemoryMapPlaceStore', () => { const expected = [a.place.id, b.place.id].sort((left, right) => right.localeCompare(left)); expect(fixed.list(10).map((row) => row.id)).toEqual(expected); }); + + it('defaults an omitted tech provider and stores an explicit value on insert', () => { + const store = new MemoryMapPlaceStore(() => new Date('2026-09-26T00:00:00.000Z')); + const omitted = store.insertIfNew(input); + expect(omitted.place.techProvider).toBe('DFX.swiss'); + const labeled = store.insertIfNew({ + ...input, + externalId: 'store-labeled', + techProvider: '21.gifts', + }); + expect(labeled.created).toBe(true); + expect(labeled.place.techProvider).toBe('21.gifts'); + }); + + it('does not change techProvider on a repeated insert', () => { + const store = new MemoryMapPlaceStore(() => new Date('2026-09-26T00:00:00.000Z')); + store.insertIfNew({ ...input, techProvider: '21.gifts' }); + const again = store.insertIfNew({ ...input, name: 'Other', techProvider: 'Other.lab' }); + expect(again.created).toBe(false); + expect(again.place.name).toBe('SPAR'); + expect(again.place.techProvider).toBe('21.gifts'); + }); + + it('upserts a new pair and updates fields on an existing pair', () => { + const store = new MemoryMapPlaceStore(() => new Date('2026-09-26T00:00:00.000Z')); + const created = store.upsert(input); + expect(created.created).toBe(true); + expect(created.place.techProvider).toBe('DFX.swiss'); + const updated = store.upsert({ + ...input, + name: 'Other', + lat: 1, + lon: 2, + category: 'cafe', + paymentMethods: 'onchain', + }); + expect(updated.created).toBe(false); + expect(updated.place.id).toBe(created.place.id); + expect(updated.place.createdAt).toBe(created.place.createdAt); + expect(updated.place.name).toBe('Other'); + expect(updated.place.lat).toBe(1); + expect(updated.place.lon).toBe(2); + expect(updated.place.category).toBe('cafe'); + expect(updated.place.paymentMethods).toBe('onchain'); + expect(updated.place.techProvider).toBe('DFX.swiss'); + }); + + it('keeps techProvider on upsert when omitted and changes it when sent', () => { + const store = new MemoryMapPlaceStore(() => new Date('2026-09-26T00:00:00.000Z')); + store.upsert({ ...input, techProvider: '21.gifts' }); + const kept = store.upsert({ ...input, name: 'Kept' }); + expect(kept.place.name).toBe('Kept'); + expect(kept.place.techProvider).toBe('21.gifts'); + const changed = store.upsert({ ...input, name: 'Changed', techProvider: 'DFX.swiss' }); + expect(changed.place.name).toBe('Changed'); + expect(changed.place.techProvider).toBe('DFX.swiss'); + }); + + 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); + expect(store.deleteByKey('dfx', 'store-1')).toBe(true); + expect(store.list(10)).toHaveLength(0); + expect(store.deleteByKey('dfx', 'store-1')).toBe(false); + }); }); diff --git a/src/lib/map/place.ts b/src/lib/map/place.ts index f05d15def..bedb7d14f 100644 --- a/src/lib/map/place.ts +++ b/src/lib/map/place.ts @@ -1,5 +1,5 @@ /** - * One pin on the OpenCryptoPay map. A caller submits it once. + * One pin on the OpenCryptoPay map. * Coordinates are rounded to six decimal places. */ @@ -11,11 +11,13 @@ export type MapPlaceInput = { lon: number; category: string; paymentMethods: string | null; + techProvider?: string; }; export type StoredMapPlace = MapPlaceInput & { id: string; createdAt: string; + techProvider: string; }; /** Fields the public map may show. The caller's own id stays off this list. */ @@ -26,6 +28,7 @@ export type PublicMapPlace = { lat: number; lon: number; category: string; + techProvider: string; }; const COORD_ERROR = 'Place must be a latitude and longitude'; @@ -33,8 +36,10 @@ const ORIGIN_ERROR = 'Place origin is invalid'; 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 PAYMENT_METHODS = /^(onchain|lightning|nfc)(,(onchain|lightning|nfc))*$/; +const TECH_PROVIDER = /^[A-Za-z0-9][A-Za-z0-9.-]{0,39}$/; function roundCoord(n: number): number { const rounded = Math.round(n * 1e6) / 1e6; @@ -51,6 +56,45 @@ function hasNoControls(value: string): boolean { return true; } +function normalizeOrigin(raw: unknown): { ok: true; value: string } | { ok: false; error: string } { + if (typeof raw !== 'string' || !/^[a-z][a-z0-9-]{0,31}$/.test(raw.trim())) { + return { ok: false, error: ORIGIN_ERROR }; + } + return { ok: true, value: raw.trim() }; +} + +function normalizeExternalId( + raw: unknown, +): { ok: true; value: string } | { ok: false; error: string } { + if (typeof raw !== 'string') { + return { ok: false, error: EXTERNAL_ID_ERROR }; + } + const externalId = raw.trim(); + if (externalId.length < 1 || externalId.length > 80 || !hasNoControls(externalId)) { + return { ok: false, error: EXTERNAL_ID_ERROR }; + } + return { ok: true, value: externalId }; +} + +function normalizeTechProvider( + raw: unknown, +): { ok: true; value: string | undefined } | { ok: false; error: string } { + if (raw === undefined || raw === null) { + return { ok: true, value: undefined }; + } + if (typeof raw !== 'string') { + return { ok: false, error: TECH_PROVIDER_ERROR }; + } + const trimmed = raw.trim(); + if (trimmed === '') { + return { ok: true, value: undefined }; + } + if (!TECH_PROVIDER.test(trimmed)) { + return { ok: false, error: TECH_PROVIDER_ERROR }; + } + return { ok: true, value: trimmed }; +} + /** * Validate a JSON body for one map pin. * @@ -79,18 +123,14 @@ export function normalizeMapPlace( return { ok: false, error: COORD_ERROR }; } - const rawOrigin = rec['origin']; - if (typeof rawOrigin !== 'string' || !/^[a-z][a-z0-9-]{0,31}$/.test(rawOrigin.trim())) { - return { ok: false, error: ORIGIN_ERROR }; + const origin = normalizeOrigin(rec['origin']); + if (!origin.ok) { + return origin; } - const rawExternalId = rec['externalId']; - if (typeof rawExternalId !== 'string') { - return { ok: false, error: EXTERNAL_ID_ERROR }; - } - const externalId = rawExternalId.trim(); - if (externalId.length < 1 || externalId.length > 80 || !hasNoControls(externalId)) { - return { ok: false, error: EXTERNAL_ID_ERROR }; + const externalId = normalizeExternalId(rec['externalId']); + if (!externalId.ok) { + return externalId; } const rawName = rec['name']; @@ -120,18 +160,48 @@ export function normalizeMapPlace( } } - return { - ok: true, - value: { - origin: rawOrigin.trim(), - externalId, - name, - lat: roundCoord(lat), - lon: roundCoord(lon), - category, - paymentMethods, - }, + const techProvider = normalizeTechProvider(rec['techProvider']); + if (!techProvider.ok) { + return techProvider; + } + + const value: MapPlaceInput = { + origin: origin.value, + externalId: externalId.value, + name, + lat: roundCoord(lat), + lon: roundCoord(lon), + category, + paymentMethods, }; + if (techProvider.value !== undefined) { + value.techProvider = techProvider.value; + } + return { ok: true, value }; +} + +/** + * Validate origin and external id for a pin delete. + * + * @param input - Request JSON. + * @returns The key, or a fixed error message. + */ +export function normalizeMapPlaceKey( + input: unknown, +): { ok: true; value: { origin: string; externalId: string } } | { ok: false; error: string } { + if (typeof input !== 'object' || input === null || Array.isArray(input)) { + return { ok: false, error: ORIGIN_ERROR }; + } + const rec = input as Record; + const origin = normalizeOrigin(rec['origin']); + if (!origin.ok) { + return origin; + } + const externalId = normalizeExternalId(rec['externalId']); + if (!externalId.ok) { + return externalId; + } + return { ok: true, value: { origin: origin.value, externalId: externalId.value } }; } /** @@ -148,5 +218,6 @@ export function toPublicMapPlace(place: StoredMapPlace): PublicMapPlace { lat: place.lat, lon: place.lon, category: place.category, + techProvider: place.techProvider, }; } diff --git a/src/lib/map/sqlite.ts b/src/lib/map/sqlite.ts index 4a824e729..02d86841e 100644 --- a/src/lib/map/sqlite.ts +++ b/src/lib/map/sqlite.ts @@ -2,6 +2,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'; +const MAP_PLACE_COLUMNS = + 'id, origin, external_id, name, lat, lon, category, payment_methods, created_at, tech_provider'; + type MapPlaceRow = { id: string; origin: string; @@ -12,6 +15,7 @@ type MapPlaceRow = { category: string; payment_methods: string | null; created_at: string; + tech_provider: string; }; function mapRow(row: MapPlaceRow): StoredMapPlace { @@ -25,6 +29,7 @@ function mapRow(row: MapPlaceRow): StoredMapPlace { category: row.category, paymentMethods: row.payment_methods, createdAt: row.created_at, + techProvider: row.tech_provider, }; } @@ -42,6 +47,12 @@ 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'", + ); + } } /** @@ -53,11 +64,12 @@ export class SqliteMapPlaceStore implements MapPlaceStore { insertIfNew(input: MapPlaceInput): { created: boolean; place: StoredMapPlace } { const id = crypto.randomUUID(); const createdAt = new Date().toISOString(); + const techProvider = input.techProvider ?? 'DFX.swiss'; const result = this.#db .query( `INSERT INTO map_place ( - id, origin, external_id, name, lat, lon, category, payment_methods, created_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + id, origin, external_id, name, lat, lon, category, payment_methods, created_at, tech_provider + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (origin, external_id) DO NOTHING`, ) .run( @@ -70,13 +82,14 @@ export class SqliteMapPlaceStore implements MapPlaceStore { input.category, input.paymentMethods, createdAt, + techProvider, ); if (result.changes === 1) { - return { created: true, place: { ...input, id, createdAt } }; + return { created: true, place: { ...input, techProvider, id, createdAt } }; } const existing = this.#db .query( - `SELECT id, origin, external_id, name, lat, lon, category, payment_methods, created_at + `SELECT ${MAP_PLACE_COLUMNS} FROM map_place WHERE origin = ? AND external_id = ?`, ) .get(input.origin, input.externalId) as MapPlaceRow | null; @@ -86,6 +99,68 @@ export class SqliteMapPlaceStore implements MapPlaceStore { return { created: false, place: mapRow(existing) }; } + /** + * Insert when the pair is new, otherwise update the stored pin. + * + * @param input - Validated pin. + * @returns Whether this call inserted the row. + */ + upsert(input: MapPlaceInput): { created: boolean; place: StoredMapPlace } { + const existing = this.#db + .query( + `SELECT ${MAP_PLACE_COLUMNS} + FROM map_place WHERE origin = ? AND external_id = ?`, + ) + .get(input.origin, input.externalId) as MapPlaceRow | null; + if (existing === null) { + return this.insertIfNew(input); + } + const techProvider = + input.techProvider !== undefined ? input.techProvider : existing.tech_provider; + this.#db + .query( + `UPDATE map_place + SET name = ?, lat = ?, lon = ?, category = ?, payment_methods = ?, tech_provider = ? + WHERE origin = ? AND external_id = ?`, + ) + .run( + input.name, + input.lat, + input.lon, + input.category, + input.paymentMethods, + techProvider, + input.origin, + input.externalId, + ); + return { + created: false, + place: { + ...mapRow(existing), + name: input.name, + lat: input.lat, + lon: input.lon, + category: input.category, + paymentMethods: input.paymentMethods, + techProvider, + }, + }; + } + + /** + * Remove the pin for this origin and external id. + * + * @param origin - Pin origin. + * @param externalId - Caller id. + * @returns Whether a row was removed. + */ + deleteByKey(origin: string, externalId: string): boolean { + const result = this.#db + .query('DELETE FROM map_place WHERE origin = ? AND external_id = ?') + .run(origin, externalId); + return result.changes > 0; + } + /** * Newest pins first. * @@ -95,7 +170,7 @@ export class SqliteMapPlaceStore implements MapPlaceStore { list(limit: number): StoredMapPlace[] { const rows = this.#db .query( - `SELECT id, origin, external_id, name, lat, lon, category, payment_methods, created_at + `SELECT ${MAP_PLACE_COLUMNS} FROM map_place ORDER BY created_at DESC, id DESC LIMIT ?`, diff --git a/src/lib/map/store.ts b/src/lib/map/store.ts index 74a768db6..be9944486 100644 --- a/src/lib/map/store.ts +++ b/src/lib/map/store.ts @@ -1,6 +1,8 @@ import type { MapPlaceInput, StoredMapPlace } from '@/lib/map/place'; -/** SQLite schema for map pins. A repeated origin and external id keeps the first row. */ +const DEFAULT_TECH_PROVIDER = 'DFX.swiss'; + +/** SQLite schema for map pins. */ export const MAP_PLACE_SCHEMA_SQL = `CREATE TABLE IF NOT EXISTS map_place ( id TEXT PRIMARY KEY, origin TEXT NOT NULL, @@ -11,6 +13,7 @@ export const MAP_PLACE_SCHEMA_SQL = `CREATE TABLE IF NOT EXISTS map_place ( category TEXT NOT NULL, payment_methods TEXT, created_at TEXT NOT NULL, + tech_provider TEXT NOT NULL DEFAULT 'DFX.swiss', UNIQUE (origin, external_id) )`; @@ -19,12 +22,14 @@ export const MAP_PLACE_SCHEMA_SQL = `CREATE TABLE IF NOT EXISTS map_place ( */ export interface MapPlaceStore { insertIfNew(input: MapPlaceInput): { created: boolean; place: StoredMapPlace }; + upsert(input: MapPlaceInput): { created: boolean; place: StoredMapPlace }; + deleteByKey(origin: string, externalId: string): boolean; list(limit: number): StoredMapPlace[]; close(): void; } /** - * Process-local map store. Same create-once rule as SQLite. + * Process-local map store. Same insert-once, upsert, and delete rules as SQLite. */ export class MemoryMapPlaceStore implements MapPlaceStore { readonly #rows: StoredMapPlace[] = []; @@ -52,6 +57,7 @@ export class MemoryMapPlaceStore implements MapPlaceStore { } const place: StoredMapPlace = { ...input, + techProvider: input.techProvider ?? DEFAULT_TECH_PROVIDER, id: crypto.randomUUID(), createdAt: this.#now().toISOString(), }; @@ -59,6 +65,48 @@ export class MemoryMapPlaceStore implements MapPlaceStore { return { created: true, place: { ...place } }; } + /** + * Insert when the pair is new, otherwise update the stored pin. + * + * @param input - Validated pin. + * @returns Whether this call inserted the row. + */ + upsert(input: MapPlaceInput): { created: boolean; place: StoredMapPlace } { + const existing = this.#rows.find( + (row) => row.origin === input.origin && row.externalId === input.externalId, + ); + if (existing === undefined) { + return this.insertIfNew(input); + } + existing.name = input.name; + existing.lat = input.lat; + existing.lon = input.lon; + existing.category = input.category; + existing.paymentMethods = input.paymentMethods; + if (input.techProvider !== undefined) { + existing.techProvider = input.techProvider; + } + return { created: false, place: { ...existing } }; + } + + /** + * Remove the pin for this origin and external id. + * + * @param origin - Pin origin. + * @param externalId - Caller id. + * @returns Whether a row was removed. + */ + deleteByKey(origin: string, externalId: string): boolean { + const index = this.#rows.findIndex( + (row) => row.origin === origin && row.externalId === externalId, + ); + if (index < 0) { + return false; + } + this.#rows.splice(index, 1); + return true; + } + /** * Newest pins first, then id descending. * diff --git a/src/routes/map.ts b/src/routes/map.ts index 1ed63076c..105fa2f2f 100644 --- a/src/routes/map.ts +++ b/src/routes/map.ts @@ -2,12 +2,12 @@ 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, toPublicMapPlace } from '@/lib/map/place'; +import { normalizeMapPlace, normalizeMapPlaceKey, toPublicMapPlace } from '@/lib/map/place'; import type { MapPlaceStore } from '@/lib/map/store'; export type MapRouteDeps = { store: MapPlaceStore; - /** Ingest bearer. Unset or blank means POST is not configured. */ + /** Ingest bearer. Unset or blank means ingest is not configured. */ ingestToken?: string; env: Record; fetchImpl: FetchLike; @@ -34,7 +34,7 @@ function checkIngest( } /** - * Public map list and create-only ingest. Mounted at `/map`. + * Public map list and ingest. Mounted at `/map`. * * @param deps - Store, ingest token, and the environment used for BTC Map. * @returns The map route group. @@ -87,5 +87,43 @@ export function mapRoutes(deps: MapRouteDeps): Hono { return c.json({ created, id: place.id, btcmap }, created ? 201 : 200); }); + app.put('/places', 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 raw: unknown = await c.req.json().catch(() => null); + const parsed = normalizeMapPlace(raw); + if (!parsed.ok) { + return c.json({ error: parsed.error }, 400); + } + const { created, place } = deps.store.upsert(parsed.value); + let btcmap: MapPushResult = 'skipped'; + if (created) { + btcmap = await pushMapPlace(place, deps.env, deps.fetchImpl); + } + return c.json({ created, id: place.id, btcmap }, created ? 201 : 200); + }); + + app.delete('/places', 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 raw: unknown = await c.req.json().catch(() => null); + const parsed = normalizeMapPlaceKey(raw); + if (!parsed.ok) { + return c.json({ error: parsed.error }, 400); + } + const deleted = deps.store.deleteByKey(parsed.value.origin, parsed.value.externalId); + return c.json({ deleted }); + }); + return app; } diff --git a/src/server.ts b/src/server.ts index b1adfa916..d1a217535 100644 --- a/src/server.ts +++ b/src/server.ts @@ -6,7 +6,7 @@ import { mapRoutes } from '@/routes/map'; export type AppDeps = { store: MapPlaceStore; - /** Ingest bearer. Unset or blank means POST is not configured. */ + /** Ingest bearer. Unset or blank means ingest is not configured. */ ingestToken?: string; env: Record; fetchImpl: FetchLike; @@ -14,7 +14,8 @@ export type AppDeps = { /** * HTTP API. `GET /healthz` and `GET /map/places` are public. - * `POST /map/places` creates a pin once. + * `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. * @returns The application.