Skip to content
Open
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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ 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.

[Saved routes V1](docs/saved-routes.md) documents the initial shared-demo route
storage and API contract. Its migration/backend deployment and frontend
integration must be coordinated separately from the catalogue work.

## Frontend deployment

After a pull request is approved and merged into `main`, GitHub Actions runs
Expand Down
107 changes: 107 additions & 0 deletions docs/saved-routes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
# Saved routes V1

This is the backend contract for the first database-backed route feature.
The initial scope is creating and listing routes for the existing shared demo
account, `ola-normann`. Favourites, route editing/deletion and authentication
are later work. All visitors currently share this demo account and its routes;
this feature does **not** provide private routes per individual visitor.

## Persistence

Migration `1792663200000_create-saved-routes.js` adds two tables without changing
existing Venue data:

| Table | Fields | Purpose |
| ------------------- | --------------------------------------------------- | -------------------------------------------------------------------- |
| `saved_routes` | `id` (UUID), `user_id` (text), `name`, `created_at` | Stable route identity and ownership. |
| `saved_route_stops` | `route_id`, `venue_id`, `position` | Ordered references to existing Venue UUIDs. Positions start at zero. |

Names are trimmed and contain 1–30 printable characters. A route contains
2–100 distinct venues. The minimum and name limit match the existing frontend;
the upper stop limit bounds the work performed by a single mutation. Every
venue must exist. Creation validates all stops and commits the route and its
stops in one transaction, rolling everything back on failure.

The database enforces nonempty owners/names, unique positions and venues per
route, nonnegative positions and Venue references. The minimum/maximum number
of stops is enforced by the creation service. Deleting a route cascades to its
stops; deleting a referenced venue is restricted to preserve saved routes.

The owner index supports newest-first pages ordered by `created_at DESC, id
DESC`. `user_id` is an opaque server-selected identity; there is no account table
or authentication dependency in V1. Once real accounts are introduced, derive
the owner from the verified user/session and plan any identity migration. Do
not accept a caller-supplied owner as proof of identity.

## GraphQL

The route SDL and resolvers live in `server/src/graphql/savedRoutes.ts`, separate
from the Venue schema. `server/src/index.ts` combines both modules. This keeps
the catalogue work independent of the route implementation.

```graphql
query MyRoutes($offset: Int! = 0, $limit: Int! = 10) {
myRoutes(offset: $offset, limit: $limit) {
totalCount
items {
id
name
stopIds
createdAt
}
}
}

mutation CreateRoute($input: CreateRouteInput!) {
createRoute(input: $input) {
id
name
stopIds
createdAt
}
}
```

`CreateRouteInput` contains `name: String!` and `stopIds: [ID!]!`. Obtain actual
Venue IDs from `venues` and pass them in visit order. Both operations use the
server-owned demo identity; the input has no `userId` field. `createdAt` is an
ISO 8601 UTC timestamp. Route IDs remain stable across reads and API restarts.

`myRoutes` defaults to offset 0 and limit 10, with a maximum limit of 100.
`totalCount` counts only the current owner's routes, independently of the page.
Unknown owners have an empty result. Invalid route data, missing venues and
invalid page bounds return GraphQL `BAD_USER_INPUT` errors. Database failures
are errors, not successful local-only saves.

## Frontend integration (Kasper)

The existing `SavedRoute` fields `id`, `name`, `stopIds` are retained. Replace
the `localStorage` implementation in `client/src/api/routes.ts` with these
operations. Extract the `items` from `myRoutes` and support additional pages
when needed. Use `createRoute`'s returned ID and stop order rather than locally
generating an ID. Keep loading and error states; the frontend must report a
failed server save. Old example routes refer to mock slugs and should not be
sent as Venue UUIDs.

No frontend behaviour is changed by this backend PR. Coordinate this contract
with Kasper before integrating the UI. The API module can be reviewed/merged
independently of the catalogue work and the shared map component.

## Checks and deployment

The normal backend typecheck, `test:data` and build include this feature. Tests
use an isolated PGlite database and cover migration reversal, validation,
ownership filtering, ordering, pagination, referential constraints, rollback
and the combined GraphQL schema. Recreating the API against the same database
verifies that routes are not kept in resolver memory. These tests do not prove
individual-user privacy or verify the live VM deployment.

After review/merge, Aleks coordinates applying the new migration to the
existing VM database and deploying the matching backend release. Keep a backup
before changing the live schema. Do not reimport the Venue data to enable routes.
Then verify `myRoutes`, create a demo route using existing Venue IDs, and read it
again after restarting the API. The current frontend keeps using local storage
until its separate integration is deployed.

Implementation, tests and this document were prepared with Codex. No live
database writes or VM migration are part of the local implementation checks.
2 changes: 2 additions & 0 deletions server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ and database run directly on the team VM, without Docker.

- [Venue V1 contract](../docs/venue-model.md): fields, nulls, stable IDs and opening hours.
- [Geoapify import workflow](../docs/geoapify-data-import.md): source retrieval, seed export and import.
- [Saved routes V1](../docs/saved-routes.md): shared demo routes, GraphQL contract,
database migration, tests and the separate frontend integration.

## Install and configure

Expand Down
66 changes: 66 additions & 0 deletions server/migrations/1792663200000_create-saved-routes.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
export const up = (pgm) => {
pgm.createTable('saved_routes', {
id: {
type: 'uuid',
primaryKey: true,
default: pgm.func('gen_random_uuid()'),
},
// The demo identity is text today; a future authenticated identity can use
// the same owner column without rebuilding the route/stops model.
user_id: { type: 'text', notNull: true },
name: { type: 'varchar(30)', notNull: true },
created_at: {
type: 'timestamptz',
notNull: true,
default: pgm.func('current_timestamp'),
},
})
pgm.addConstraint('saved_routes', 'saved_routes_owner_nonempty', {
check: 'length(btrim(user_id)) > 0',
})
pgm.addConstraint('saved_routes', 'saved_routes_name_nonempty', {
check: 'length(btrim(name)) > 0 AND name = btrim(name)',
})
pgm.createIndex('saved_routes', ['user_id', 'created_at', 'id'], {
name: 'saved_routes_owner_created_idx',
})

pgm.createTable('saved_route_stops', {
route_id: {
type: 'uuid',
notNull: true,
references: 'saved_routes',
onDelete: 'CASCADE',
},
venue_id: {
type: 'uuid',
notNull: true,
references: 'venues',
onDelete: 'RESTRICT',
},
position: { type: 'integer', notNull: true },
})
pgm.addConstraint('saved_route_stops', 'saved_route_stops_pkey', {
primaryKey: ['route_id', 'position'],
})
pgm.addConstraint('saved_route_stops', 'saved_route_stops_venue_unique', {
unique: ['route_id', 'venue_id'],
})
pgm.addConstraint(
'saved_route_stops',
'saved_route_stops_position_nonnegative',
{
check: 'position >= 0',
},
)
pgm.createIndex('saved_route_stops', 'venue_id', {
name: 'saved_route_stops_venue_idx',
})
}

/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
export const down = (pgm) => {
pgm.dropTable('saved_route_stops')
pgm.dropTable('saved_routes')
}
69 changes: 69 additions & 0 deletions server/src/graphql/savedRoutes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { GraphQLError } from 'graphql'
import type { ImportDatabase, SqlExecutor } from '../data/importVenues.js'
import {
createSavedRoute,
listSavedRoutes,
RouteInputError,
type CreateRouteInput,
type RoutePageArguments,
} from '../routes/savedRoutes.js'

// Shared demo account, matching the current frontend example user. This is
// deliberately not authentication or private routes per individual visitor.
export const DEMO_USER_ID = 'ola-normann'

export const savedRouteTypeDefs = `
type SavedRoute {
id: ID!
name: String!
stopIds: [ID!]!
createdAt: String!
}
type SavedRoutePage {
items: [SavedRoute!]!
totalCount: Int!
}
input CreateRouteInput {
name: String!
stopIds: [ID!]!
}
extend type Query {
"""Routes belonging to the shared demo account, newest first."""
myRoutes(offset: Int! = 0, limit: Int! = 10): SavedRoutePage!
}
type Mutation {
"""Saves a route for the server-selected shared demo account."""
createRoute(input: CreateRouteInput!): SavedRoute!
}
`

async function routeOperation<T>(operation: () => Promise<T>) {
try {
return await operation()
} catch (error) {
if (error instanceof RouteInputError)
throw new GraphQLError(error.message, {
extensions: { code: 'BAD_USER_INPUT' },
})
throw error
}
}

export const createSavedRouteResolvers = (
database: SqlExecutor & ImportDatabase,
) => ({
Query: {
myRoutes: (_parent: unknown, args: RoutePageArguments) =>
routeOperation(() => listSavedRoutes(database, DEMO_USER_ID, args)),
},
Mutation: {
createRoute: (_parent: unknown, args: { input: CreateRouteInput }) =>
routeOperation(() =>
createSavedRoute(database, DEMO_USER_ID, args.input),
),
},
})
14 changes: 13 additions & 1 deletion server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ import { ApolloServer } from '@apollo/server'
import { startStandaloneServer } from '@apollo/server/standalone'
import { pool } from './db/pool.js'
import { createResolvers, typeDefs } from './graphql/schema.js'
import {
createSavedRouteResolvers,
savedRouteTypeDefs,
} from './graphql/savedRoutes.js'

const port = Number(process.env.PORT ?? 3001)
const host = process.env.HOST ?? '127.0.0.1'
Expand All @@ -11,7 +15,15 @@ if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error('PORT must be a valid TCP port number.')
}

const server = new ApolloServer({ typeDefs, resolvers: createResolvers(pool) })
const venueResolvers = createResolvers(pool)
const routeResolvers = createSavedRouteResolvers(pool)
const server = new ApolloServer({
typeDefs: [typeDefs, savedRouteTypeDefs],
resolvers: {
Query: { ...venueResolvers.Query, ...routeResolvers.Query },
Mutation: routeResolvers.Mutation,
},
})

try {
const { url } = await startStandaloneServer(server, {
Expand Down
Loading