diff --git a/server/README.md b/server/README.md index f6e4580..37ce09c 100644 --- a/server/README.md +++ b/server/README.md @@ -110,7 +110,14 @@ Open `http://127.0.0.1:3001/` and run: query { health databaseStatus - venues(offset: 0, limit: 10) { + venueTypes + venues( + search: "Midtbyen" + types: [PUB, BAR] + sort: RATING + offset: 0 + limit: 10 + ) { totalCount items { id @@ -122,6 +129,12 @@ query { } ``` +`search` is case-insensitive and matches venue names, areas and types, including +Norwegian display labels (for example, `nattklubb` matches `NIGHTCLUB`). Multiple +`types` are combined with OR. `totalCount` is the size of the complete filtered +result, while `items` contains only the requested page. Omitting these optional +arguments preserves the original name-sorted catalog query. + `health` returns `ok`; `databaseStatus` returns `connected`. The model document contains an example requesting all 16 fields. On the VM, the compiled server is run by the persistent `t07-project2-api.service`. Apache proxies the public diff --git a/server/src/graphql/schema.ts b/server/src/graphql/schema.ts index 025fcf8..93e28d4 100644 --- a/server/src/graphql/schema.ts +++ b/server/src/graphql/schema.ts @@ -43,6 +43,17 @@ export const typeDefs = ` totalCount: Int! } + enum VenueType { + BAR + PUB + NIGHTCLUB + } + + enum VenueSort { + NAME + RATING + } + type Query { """Simple API check that does not require a database query.""" health: String! @@ -50,7 +61,14 @@ export const typeDefs = ` """Checks whether the backend can reach its PostgreSQL database.""" databaseStatus: String! - venues(offset: Int = 0, limit: Int = 10): VenuePage! + venues( + search: String + types: [VenueType!] + sort: VenueSort = NAME + offset: Int = 0 + limit: Int = 10 + ): VenuePage! + venueTypes: [VenueType!]! venue(id: ID!): Venue } ` @@ -60,10 +78,39 @@ const venueColumns = `id, name, type, city, area, address, latitude, longitude, opening_hours AS "openingHours", tags, image_url AS "imageUrl", website_url AS "websiteUrl"` type VenuePageArguments = { + search?: string | null + types?: VenueType[] | null + sort?: VenueSort offset?: number limit?: number } +type VenueType = 'BAR' | 'PUB' | 'NIGHTCLUB' +type VenueSort = 'NAME' | 'RATING' + +const venueFilters = ` + WHERE ( + $1::text IS NULL + OR name ILIKE $1 ESCAPE '\\' + OR area ILIKE $1 ESCAPE '\\' + OR type ILIKE $1 ESCAPE '\\' + OR (CASE type + WHEN 'BAR' THEN 'Bar' + WHEN 'PUB' THEN 'Pub' + WHEN 'NIGHTCLUB' THEN 'Nattklubb' + END) ILIKE $1 ESCAPE '\\' + ) + AND ( + cardinality($2::text[]) = 0 + OR type = ANY($2::text[]) + )` + +function toSearchPattern(search?: string | null) { + const value = search?.trim() + if (!value) return null + return `%${value.replace(/[\\%_]/g, '\\$&')}%` +} + function validatePagination({ offset = 0, limit = 10 }: VenuePageArguments) { if (offset < 0) { throw new GraphQLError('offset must be zero or greater.', { @@ -89,21 +136,37 @@ export const createResolvers = (database: SqlExecutor) => ({ }, venues: async (_parent: unknown, args: VenuePageArguments) => { const { offset, limit } = validatePagination(args) + const search = toSearchPattern(args.search) + const types = args.types ?? [] + const orderBy = + args.sort === 'RATING' + ? 'rating DESC NULLS LAST, LOWER(name), id' + : 'LOWER(name), id' const countResult = await database.query( - 'SELECT COUNT(*)::int AS "totalCount" FROM venues', + `SELECT COUNT(*)::int AS "totalCount" + FROM venues + ${venueFilters}`, + [search, types], ) const { rows } = await database.query( `SELECT ${venueColumns} FROM venues - ORDER BY name, id - LIMIT $1 OFFSET $2`, - [limit, offset], + ${venueFilters} + ORDER BY ${orderBy} + LIMIT $3 OFFSET $4`, + [search, types, limit, offset], ) return { items: rows, totalCount: Number(countResult.rows[0]?.totalCount ?? 0), } }, + venueTypes: async () => { + const { rows } = await database.query( + 'SELECT DISTINCT type FROM venues ORDER BY type', + ) + return rows.map(({ type }) => type) + }, venue: async (_parent: unknown, args: { id: string }) => { if ( !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( diff --git a/server/tests/graphqlCatalog.test.ts b/server/tests/graphqlCatalog.test.ts new file mode 100644 index 0000000..2991105 --- /dev/null +++ b/server/tests/graphqlCatalog.test.ts @@ -0,0 +1,216 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { ApolloServer } from '@apollo/server' +import { PGlite } from '@electric-sql/pglite' +import { MigrationBuilder } from 'node-pg-migrate' +import { up as createVenueTables } from '../migrations/1791904375317_create-venues.js' +import { up as completeModel } from '../migrations/1791907200000_complete-venue-model.js' +import { importVenues, type ImportDatabase } from '../src/data/importVenues.js' +import { createResolvers, typeDefs } from '../src/graphql/schema.js' +import { venueFixture } from './fixtures.js' + +async function migrate(db: PGlite, migration: typeof createVenueTables) { + const builder = new MigrationBuilder( + {} as ConstructorParameters[0], + undefined, + false, + console, + ) + await migration(builder) + await db.exec(builder.getSql()) +} + +function adapter(db: PGlite): ImportDatabase { + return { + connect: async () => ({ + query: (sql, values) => db.query>(sql, values), + release() {}, + }), + } +} + +async function setupCatalog() { + const db = new PGlite() + await migrate(db, createVenueTables) + await migrate(db, completeModel) + await importVenues(adapter(db), [ + venueFixture({ + sourceId: 'openstreetmap:n:1', + name: 'Alpha Taproom', + type: 'PUB', + area: 'Midtbyen', + }), + venueFixture({ + sourceId: 'openstreetmap:n:2', + name: 'beta Bar', + type: 'BAR', + area: 'Solsiden', + }), + venueFixture({ + sourceId: 'openstreetmap:n:3', + name: 'Club Gamma', + type: 'NIGHTCLUB', + area: 'Bakklandet', + }), + venueFixture({ + sourceId: 'openstreetmap:n:4', + name: 'Alpine Pub', + type: 'PUB', + area: 'Ila', + }), + ]) + await db.exec(`UPDATE venues + SET rating = CASE name + WHEN 'Alpha Taproom' THEN 4.2 + WHEN 'Club Gamma' THEN 4.8 + WHEN 'Alpine Pub' THEN 4.8 + ELSE NULL + END`) + + const server = new ApolloServer({ + typeDefs, + resolvers: createResolvers({ + query: (sql, values) => db.query>(sql, values), + }), + }) + return { db, server } +} + +const catalogQuery = ` + query Catalog( + $search: String + $types: [VenueType!] + $sort: VenueSort + $offset: Int + $limit: Int + ) { + venues( + search: $search + types: $types + sort: $sort + offset: $offset + limit: $limit + ) { + totalCount + items { name rating type } + } + venueTypes + } +` + +type CatalogData = { + venues: { + totalCount: number + items: { name: string; rating: number | null; type: string }[] + } + venueTypes: string[] +} + +async function executeCatalog( + server: ApolloServer, + variables: Record, +) { + const result = await server.executeOperation({ + query: catalogQuery, + variables, + }) + assert.equal(result.body.kind, 'single') + if (result.body.kind !== 'single') throw new Error('Expected single response') + assert.equal(result.body.singleResult.errors, undefined) + return result.body.singleResult.data as CatalogData +} + +test('GraphQL searches case-insensitively by name, area and type', async () => { + const { db, server } = await setupCatalog() + try { + for (const [search, expected] of [ + [' alpha ', ['Alpha Taproom']], + ['SOLSIDEN', ['beta Bar']], + ['pub', ['Alpha Taproom', 'Alpine Pub']], + ['%', []], + ] as const) { + const data = await executeCatalog(server, { search }) + assert.equal(data.venues.totalCount, expected.length) + assert.deepEqual( + data.venues.items.map(({ name }) => name), + expected, + ) + } + + const blank = await executeCatalog(server, { search: ' ' }) + assert.equal(blank.venues.totalCount, 4) + } finally { + await server.stop() + await db.close() + } +}) + +test('GraphQL searches Norwegian type labels while preserving type filters and pagination', async () => { + const { db, server } = await setupCatalog() + try { + for (const search of ['nattklubb', ' NATTKLUBB ', 'natt', 'nightclub']) { + const data = await executeCatalog(server, { search }) + assert.equal(data.venues.totalCount, 1) + assert.deepEqual( + data.venues.items.map(({ name }) => name), + ['Club Gamma'], + ) + } + + const filtered = await executeCatalog(server, { + search: 'nattklubb', + types: ['PUB'], + }) + assert.equal(filtered.venues.totalCount, 0) + assert.deepEqual(filtered.venues.items, []) + + const nextPage = await executeCatalog(server, { + search: 'nattklubb', + types: ['NIGHTCLUB'], + offset: 1, + limit: 1, + }) + assert.equal(nextPage.venues.totalCount, 1) + assert.deepEqual(nextPage.venues.items, []) + } finally { + await server.stop() + await db.close() + } +}) + +test('GraphQL combines venue types with OR and counts the complete filtered result', async () => { + const { db, server } = await setupCatalog() + try { + const data = await executeCatalog(server, { + types: ['PUB', 'NIGHTCLUB'], + offset: 1, + limit: 1, + }) + assert.equal(data.venues.totalCount, 3) + assert.deepEqual( + data.venues.items.map(({ name }) => name), + ['Alpine Pub'], + ) + + const noFilter = await executeCatalog(server, { types: [] }) + assert.equal(noFilter.venues.totalCount, 4) + assert.deepEqual(data.venueTypes, ['BAR', 'NIGHTCLUB', 'PUB']) + } finally { + await server.stop() + await db.close() + } +}) + +test('GraphQL sorts ratings highest first, nulls last and uses name as a stable tie-breaker', async () => { + const { db, server } = await setupCatalog() + try { + const data = await executeCatalog(server, { sort: 'RATING' }) + assert.deepEqual( + data.venues.items.map(({ name }) => name), + ['Alpine Pub', 'Club Gamma', 'Alpha Taproom', 'beta Bar'], + ) + } finally { + await server.stop() + await db.close() + } +})