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
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,6 @@ DATABASE_URL=postgresql://tapfinder_app:REPLACE_WITH_PASSWORD@localhost:5432/tap
PORT=4000
# Keep the API local until a reverse proxy (for example, Nginx) is configured.
HOST=127.0.0.1

# Only needed for the Geoapify data preview/import, not for starting GraphQL.
GEOAPIFY_API_KEY=REPLACE_WITH_GEOAPIFY_KEY
35 changes: 35 additions & 0 deletions .github/workflows/backend-quality.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
name: Backend quality gate

on:
pull_request:
branches:
- main

permissions:
contents: read

jobs:
backend-checks:
name: Backend checks
runs-on: [self-hosted, linux, x64, t07-project-2]
timeout-minutes: 15
defaults:
run:
working-directory: server
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: npm
cache-dependency-path: server/package-lock.json
- name: Install dependencies
run: npm ci
- name: Check types including scripts and tests
run: npm run typecheck
- name: Run data, migration and GraphQL tests
run: npm run test:data
- name: Build backend
run: npm run build
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ node_modules/
dist/
build/
coverage/
server/imports/

# Local environment and secrets
.env
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ Se [server/README.md](server/README.md) for npm-oppsett, VM-konfigurasjon,
lokal testing, databasebruker og migrasjoner. PostgreSQL og den planlagte
backendtjenesten kjører direkte på VM-en; Docker brukes ikke.

See [Venue V1](docs/venue-model.md) for the shared data contract and
[Geoapify data import](docs/geoapify-data-import.md) for retrieval, validation
and database import. Backend checks run on pull requests alongside frontend checks.

## Frontend-deploy

Etter at en Pull Request er godkjent og merget til `main`, kjører GitHub
Expand Down
179 changes: 179 additions & 0 deletions docs/geoapify-data-import.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
# Geoapify data import

The flow is **Geoapify → local export → PostgreSQL on the team VM → our GraphQL
API → frontend**. Browsing the app should query our database. Retrieval and
refreshing are separate operations, rather than calls to Geoapify on every visit.

[Venue V1](venue-model.md) is the shared field/identity/opening-hours contract.
[Backend setup](../server/README.md) explains credentials, SSH and migrations.
The frontend still uses mock places; its GraphQL integration is separate work.

## 1. Configure retrieval

From the repository root:

```powershell
npm --prefix server ci
Copy-Item .env.example server/.env
```

Only copy the example if the file does not exist. Add your key from
[Geoapify MyProjects](https://myprojects.geoapify.com/) as `GEOAPIFY_API_KEY` in
`server/.env`. Never commit it. Database access is not needed for steps 1–4.

## 2. Inspect a sample, then collect query pages

```powershell
npm --prefix server run data:preview -- --trondheim --limit 20
npm --prefix server run data:preview -- --trondheim --all-pages --limit 50
```

Trondheim mode resolves a Norwegian city result once, validates its identity,
then uses its place ID as the boundary filter. Categories are `catering.bar`,
`catering.pub` and `adult.nightclub`. Review the selected boundary and coverage;
finishing pagination cannot guarantee every real venue is mapped.

The sample makes at most 22 requests with details enabled. Collection keeps
categories and area unchanged and fetches details for unique source identities.
It stops on a short/empty page. The default cap is 10 pages; `--max-pages` accepts
1–20 with `--all-pages`. A capped export is marked partial and exits with code 2.
Failures do not produce a successful partial file. Repeated OSM identities are
skipped, using type plus ID; Geoapify IDs are a fallback during preview only.

All exports are retained as unique `server/imports/geoapify-*.json` files. This
directory is ignored by Git. Files contain retrieval time, search parameters,
attribution, original place/details and available fields, without the API key.
The console table shows coverage rather than every source field.

Without `--trondheim`, preview uses a 5 km circle around 10.3951/63.4305.
`--offset`, `--no-details` and `--help` support inspection. Samples, capped files,
nonzero-offset files and details-free exports cannot become the completed seed.

## 3. Inspect intermediate Venue drafts (optional)

```powershell
npm --prefix server run data:prepare
npm --prefix server run data:prepare -- --opening-hours
```

Preparation selects the latest completed Trondheim collection. The first
command keeps opening hours pending; the second uses the pinned `opening_hours`
parser. Each writes a separate draft file without changing the source export.
These nested drafts are intermediate files, not seed input.

Required strings, coordinates, OSM identities and URLs are validated. Unknown
price, rating, description and image values remain null. Initial review count
is 0 for our app, not an external review count. Recognized source categories
become factual tags; generic categories do not invent tags.

## 4. Export and validate the database input

```powershell
npm --prefix server run data:export
```

This reads the latest completed source export, converts clear weekly opening
hours and produces a flat JSON array in `server/imports/venue-seed-*.json`.
It makes no API calls or database writes. The console prints the exact path and
a command for a dry run. You can also select the newest generated file:

```powershell
$venueSeedFile = Get-ChildItem server/imports/venue-seed-*.json | Sort-Object LastWriteTime -Descending | Select-Object -First 1
npm --prefix server run seed:venues -- --file $venueSeedFile.FullName --dry-run
```

Each row contains the Venue fields except the database-assigned `id`, plus
`sourceId`, `sourceFetchedAt` and `rawData`. Source types map to `BAR`, `PUB` and
`NIGHTCLUB`. `sourceId` is `openstreetmap:<n|w|r>:<osm_id>`, independent of a
changing Geoapify place ID. `rawData` retains attribution, source identity,
original place/details, query area, original hours, parser status and warnings.

Missing/uncertain/unsupported opening hours remain `[]` and can be imported as
unknown. They do not become closed days. Clear week-stable schedules use
calendar days in Europe/Oslo; overnight hours are split at midnight and repeated
weekdays are allowed. See the model document before implementing opening status.
Manually reviewing flagged expressions is a later data-quality task; retain the
original expression and use verified evidence before changing the schedule.

Validation rejects out-of-range coordinates, invalid clock times, overlaps,
contradictory closed/open entries, unsafe URL schemes/credentials, duplicate
source IDs and invalid price levels. Imported ratings must be null and counts
must be 0 or omitted; provider imports cannot supply app review aggregates.

A collection on 2026-10-13 yielded 51 venues: 19 bars, 26 pubs and 6 nightclubs.
All had required location fields and stable OSM identity; 34 had websites.
Opening hours normalized for 24, were missing for 19 and required review for 8.
These are observed counts for that collection, not guaranteed future counts.

## 5. Connect, migrate and import

Get database access from the database owner privately. Keep an SSH tunnel open
for local access and configure `server/.env` for `tapfinder_dev`, as described
in the backend README. Confirm access without writes:

```powershell
npm --prefix server run db:check
```

After the reviewed migrations are available, apply them, then import the exact
validated seed file:

```powershell
npm --prefix server run migrate:up
npm --prefix server run seed:venues -- --file $venueSeedFile.FullName
```

Imports reject other database names and placeholder credentials. Validation
happens before connecting. Each batch is a transaction: an error rolls back
both Venue and source changes. Run one import at a time. Repeating an import
updates existing source mappings, preserving internal IDs, app ratings and
review counts. Unknown imported price/description/image values preserve existing
curated values. Source retrieval time is stored separately from database update
time. Missing venues are never deleted just because they are absent from a file.

The default `server/data/venues.json` is still an empty list. Manual files with
the original flat fields remain supported; new optional fields can be omitted.
Manual open intervals must use the same calendar-day convention, including
midnight splits. File paths passed to `--file` are relative to `server/` or
absolute. Keep real exports out of Git; retain the original files for audit.

Use the model document's GraphQL query to verify all fields after import. Check
counts and rerun the same file to confirm zero new rows and preserved IDs. VM
access, migrations and import must still be verified on the actual environment.

## Tests and remaining integration

```powershell
npm --prefix server run typecheck
npm --prefix server run test:data
npm --prefix server run build
```

Retrieval tests use mocked API responses. PGlite runs migrations, constraints,
transaction rollback, repeat import and GraphQL tests against PostgreSQL in
memory. No API key, VM, Docker or external test database is needed. Real-data
coverage and real VM connectivity are separate operational checks.

Remaining application work includes frontend GraphQL integration with source
attribution, backend search/filter/sort/pagination, app review persistence and
scheduled refreshing after the manual workflow is verified.

Retain **Powered by Geoapify | © OpenStreetMap contributors** with imported
records and show the required attribution when publishing the data. Current
provider terms and the API plan govern distribution. Raw source metadata is
internal and is not exposed by the Venue API.

The implementation, tests and documentation were prepared with Codex. Source
retrieval was checked against Geoapify; database behavior was checked locally
without writing to the VM database.

Primary documentation:

- [Places API](https://apidocs.geoapify.com/docs/places/index.html)
- [Place Details API](https://apidocs.geoapify.com/docs/place-details/)
- [Source identity and optional fields](https://apidocs.geoapify.com/how-to/place-discovery/place-details-by-id-or-coordinates)
- [City boundary searches](https://apidocs.geoapify.com/how-to/place-discovery/places-in-city-district-postcode/)
- [Pagination](https://apidocs.geoapify.com/how-to/place-discovery/paginate-place-results/)
- [Storage and attribution](https://www.geoapify.com/places-api/)
- [Opening-hours parser](https://github.com/opening-hours/opening_hours.js)
- [PGlite test database](https://pglite.dev/docs/)
122 changes: 122 additions & 0 deletions docs/venue-model.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
# Venue V1 contract

This document defines the public model shared by the PostgreSQL backend and
GraphQL consumers. The schema is implemented in `server/src/graphql/schema.ts`.
The original table migration is retained; `complete-venue-model` adds the five
remaining fields to existing databases without replacing records or IDs.

| GraphQL field | PostgreSQL column | Meaning |
| -------------------------------- | ----------------- | ----------------------------------------------------- |
| `id: ID!` | `id` | Opaque, persistent UUID generated by our database. |
| `name: String!` | `name` | Verified venue name. |
| `type: String!` | `type` | `BAR`, `PUB` or `NIGHTCLUB`. |
| `city: String!` | `city` | Source city; currently Trondheim. |
| `area: String!` | `area` | Source suburb or district. |
| `address: String!` | `address` | Street address. |
| `latitude: Float!` | `latitude` | -90 to 90. |
| `longitude: Float!` | `longitude` | -180 to 180. |
| `priceLevel: Int` | `price_level` | 1–3 (`$`, `$$`, `$$$`); null when unknown. |
| `rating: Float` | `rating` | App review aggregate, 0–5; null before reviews exist. |
| `reviewCount: Int!` | `review_count` | Number of app reviews; initially 0. |
| `description: String` | `description` | Verified description, otherwise null. |
| `openingHours: [OpeningHours!]!` | `opening_hours` | Calendar-day intervals; `[]` means unknown. |
| `tags: [String!]!` | `tags` | Factual, normalized tags; `[]` when none are known. |
| `imageUrl: String` | `image_url` | HTTP(S) image address, otherwise null. |
| `websiteUrl: String` | `website_url` | HTTP(S) venue website, otherwise null. |

Geoapify does not supply the app's review aggregate. Imports do not update
`rating` or `review_count`. A new record receives their database defaults. The
review-writing feature and aggregate calculation are separate future work.
Unknown imported price, description and image values do not erase existing
curated values. Clearing those values explicitly will belong to an editing API.

## Opening hours

```graphql
enum Weekday {
MONDAY
TUESDAY
WEDNESDAY
THURSDAY
FRIDAY
SATURDAY
SUNDAY
}
type OpeningHours {
day: Weekday!
opens: String
closes: String
closed: Boolean!
}
```

Times describe **calendar days in Europe/Oslo**, using `HH:mm`. Opening times
range from `00:00` to `23:59`; closing times may also be `24:00`, the end of the
named day. Open intervals require `opens < closes`. For example, Monday
20:00–02:30 is stored as Monday 20:00–24:00 and Tuesday 00:00–02:30.

Multiple intervals on the same weekday are allowed, with no overlaps. A closed
entry has `closed: true` and both times null; it cannot coexist with an open
entry on that day. An empty list means unknown, not closed. A partial manually
prepared list leaves omitted days unknown. A normalized weekly source schedule
contains all seven days, including explicit closed-day entries.

The importer does not flatten seasonal rules, holiday exceptions, uncertain
closing times or parser warnings into a misleading recurring week. Such records
use `openingHours: []`; original text and review reasons remain in source data.
Frontend opening-status logic must support repeated weekdays and `24:00`.

## Source identity and refreshes

`venue_sources` has a unique `(provider, external_id)` mapping to `venues.id`.
The Geoapify exporter uses `provider = geoapify` and
`external_id = openstreetmap:<n|w|r>:<osm_id>`. The object type is part of the
identity: node 123 and way 123 are different places. Current Geoapify place IDs
are retained in `raw_data`, since they may change for the same OSM object.

`raw_data` retains attribution, query area, original place/detail records,
opening-hours expression, normalization status and review reason. `fetched_at`
stores source retrieval time; `venues.updated_at` stores database update time.
Legacy manually prepared seeds without a retrieval timestamp use import time.
An import without raw data preserves any previously stored raw data.

Run one import at a time. Each file is validated before connecting to PostgreSQL
and written in one transaction. Reimporting updates source fields through the
existing mapping and preserves internal IDs and app reviews. Records absent
from an import are not deleted. Missing required fields must be verified or the
record excluded; non-null fields must not be filled with invented values.

## API

The initial queries are `health`, `databaseStatus`, `venues` and `venue(id: ID!)`.
List results are ordered by name and ID. Unknown or malformed IDs return null.
Search, filtering, sorting arguments and pagination are still separate work;
the current list query should not be presented as a completed scalable API.

```graphql
query {
venues {
id
name
type
city
area
address
latitude
longitude
priceLevel
rating
reviewCount
description
openingHours {
day
opens
closes
closed
}
tags
imageUrl
websiteUrl
}
}
```
Loading