Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -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.

Expand Down
18 changes: 16 additions & 2 deletions docs/handbook/endpoints.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
33 changes: 20 additions & 13 deletions docs/handbook/functions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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
Expand Down
3 changes: 2 additions & 1 deletion docs/schema/map-place.sql
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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)
);
66 changes: 66 additions & 0 deletions e2e/map.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
});
1 change: 1 addition & 0 deletions src/__tests__/map/btcmap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const place: StoredMapPlace = {
lon: 8.54,
category: 'groceries',
paymentMethods: 'lightning',
techProvider: 'DFX.swiss',
createdAt: '2026-09-26T00:00:00.000Z',
};

Expand Down
108 changes: 107 additions & 1 deletion src/__tests__/map/place.test.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down Expand Up @@ -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',
};
Expand All @@ -93,6 +198,7 @@ describe('toPublicMapPlace', () => {
lat: 47.37,
lon: 8.54,
category: 'groceries',
techProvider: 'DFX.swiss',
});
});
});
Loading
Loading