v0.6.3: hide the paid city lots from users without a ParkSmarter account
All checks were successful
build-apk / build (push) Successful in 9m54s

The green lots are the map's only paid category ("City lots - Paid hourly or
permit"); paying for them goes through ParkSmarter, so they are no use to
someone browsing without an account. Every other category is free street
parking with a posted time limit and needs nothing.

Gated by category rather than by an id list. Two lots were named (off Oak St
and off N 3rd Ave) and then the beach ones, which together is every green lot
on the map — and an id list would silently break the next time the map is
regenerated from a new PDF, since ids are positional.

- Paid lots are filtered out of the overlay when signed out, so they are
  neither drawn nor tappable.
- "Park here" still detects them, so standing in one explains that it needs an
  account instead of reporting no parking nearby.
- The area screen guards too, in case one is reached with a stale nav param.
- Server carries an optional per-area requiresAccount override for a lot that
  turns out to take payment another way. Null means "use the category
  default", so an unset value can't be confused with an explicit false.

The new column needs a real migration: CREATE TABLE IF NOT EXISTS does not add
a column to a table that already exists, so an already-deployed server would
have kept the old schema. Covered by a test that builds the pre-migration
table and then opens it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-13 06:02:23 +00:00
parent 6af3dad762
commit 4217e88338
8 changed files with 159 additions and 18 deletions

View file

@ -140,6 +140,58 @@ test('polygons are accepted; bad kinds and geometries are rejected', async () =>
await app.close();
});
test('requiresAccount is null unless explicitly overridden', async () => {
const app = await make();
await app.inject({
method: 'PUT',
url: '/api/areas',
headers: auth,
payload: {
areas: [
line('sp-001'), // no override -> client decides by category
{ ...line('sp-002', 'green_lot'), requiresAccount: false }, // a lot that needs no account
{ ...line('sp-003'), requiresAccount: true },
],
},
});
const areas = (await app.inject({ method: 'GET', url: '/api/areas' })).json().areas;
const by = Object.fromEntries(areas.map((a: any) => [a.id, a.requiresAccount]));
assert.equal(by['sp-001'], null, 'no override should stay null, not become false');
assert.equal(by['sp-002'], false);
assert.equal(by['sp-003'], true);
await app.close();
});
test('a database created before requires_account existed still works', async () => {
// Simulate an older deployment: build the table without the column, then let
// the migration add it. CREATE TABLE IF NOT EXISTS alone would not.
const Database = (await import('better-sqlite3')).default;
const file = `/tmp/bbp-migrate-${process.pid}.db`;
const raw = new Database(file);
raw.exec(`CREATE TABLE parking_areas (
id TEXT PRIMARY KEY, kind TEXT NOT NULL, name TEXT NOT NULL, label TEXT NOT NULL,
legend TEXT NOT NULL, hours REAL NOT NULL, color TEXT NOT NULL, shape TEXT NOT NULL,
geometry TEXT NOT NULL, updated_at INTEGER NOT NULL)`);
raw.close();
const app = await make({ dbPath: file });
const r = await app.inject({
method: 'PUT',
url: '/api/areas',
headers: auth,
payload: { areas: [{ ...line('sp-009'), requiresAccount: true }] },
});
assert.equal(r.statusCode, 200);
const areas = (await app.inject({ method: 'GET', url: '/api/areas' })).json().areas;
assert.equal(areas[0].requiresAccount, true);
await app.close();
(await import('node:fs')).rmSync(file, { force: true });
});
test('overlay defaults to identity and round-trips', async () => {
const app = await make();