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
57 changes: 54 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ We are building a web application that helps gymnastics coaches plan their gymna

## Status

The first delivery was a specification. There is no working application yet, but the catalog data has been extracted from the Code of Points and is in [`data/`](data/), and the database schema is in place: the Prisma schema and its migrations create all the tables of the data model in PostgreSQL. The seed script loads `data/` into the database, and the catalog search is written as a backend function. The GraphQL server, which will call it, is the next step.
The first delivery was a specification. The application is not finished: the frontend is still the Vite starter page, but the catalog data has been extracted from the Code of Points and is in [`data/`](data/), and the database schema is in place: the Prisma schema and its migrations create all the tables of the data model in PostgreSQL. The seed script loads `data/` into the database, and a GraphQL server in `backend/` answers the catalog queries and the search (see [GraphQL server](#graphql-server)). Login, the user data and the connection from the frontend are the next steps.

The code in `src/` is the Vite starter project with one example test. The tooling is set up: TypeScript, ESLint, Prettier and Vitest.

Expand Down Expand Up @@ -64,7 +64,7 @@ In the repository now:
- A backend package in `backend/` with TypeScript and Prisma, set up as an npm workspace
- PostgreSQL 18, installed directly on our own machines (not in Docker), with the Prisma schema and migrations for all the tables of the data model
- A seed script that loads the catalog from `data/` into the database
- The catalog search, with Prisma only and cursor pagination. It is not yet reachable through a GraphQL server
- The GraphQL server (Apollo Server) with the catalog queries and the catalog search, with Prisma only and cursor pagination, see [GraphQL server](#graphql-server)
- ESLint and Prettier for linting and formatting, for both the frontend and the backend
- Vitest for testing
- Tailwind CSS, shadcn/ui and Lucide icons for the interface, see [UI components](#ui-components-shadcnui-tailwind-css-and-lucide)
Expand Down Expand Up @@ -308,6 +308,57 @@ Prisma Studio uses the connection in `backend/.env`, so there is nothing more to

To write your own SQL queries, connect with `psql -h localhost -U cop -d cop` (on Windows, use _SQL Shell (psql)_) and enter the password from `backend/.env`. The tables have snake_case names in the database, such as `catalog_entries` and `element_group_id`. `\dt` lists the tables, `\d elements` shows the columns of a table, and `\q` quits.

## GraphQL server

The server is in `backend/src/server/` (Apollo Server 5 on Express 5). It answers the catalog queries and the search through GraphQL. Login and the user data (gymnasts, routines) are not built yet; those operations answer a clear error, see [The server in docs/API.md](docs/API.md#the-server).

**Start it.** The database must be migrated and seeded first (see "Database (local setup)" above):

```bash
npm run backend # restarts when a file changes
npm run start -w backend # starts once
```

It listens on port 3001. Open http://localhost:3001/graphql in a browser to try queries in Apollo Sandbox, for example:

```graphql
{
searchCatalog(search: "hecht", first: 3) {
totalCount
pageInfo {
hasNextPage
endCursor
}
edges {
node {
id
description
imageUrl
... on Element {
value
variants {
id
description
}
}
}
}
}
}
```

Other addresses: `/images/<apparatus>/<file>.webp` serves the drawings in `data/images/` (on the virtual machine Apache does this), and `/health` answers `{"status":"ok"}`.

**Settings** (all optional, in `backend/.env`, see `backend/.env.example`):

| Variable | Default | Used for |
| ---------------- | ------------------------------ | ------------------------------------------------------------------- |
| `PORT` | `3001` | The port of the server |
| `IMAGE_BASE_URL` | `http://localhost:3001/images` | The address the drawings are served from (`imageUrl` in the API) |
| `CORS_ORIGIN` | `http://localhost:5173` | The addresses that may call the API from a browser, comma separated |

The tests of the server are in `backend/src/server/` and run with `npm test`, like the search tests. They need the seeded database.

## Catalog search

The catalog search is in `backend/src/catalog/search.ts`. It finds elements and vaults by the start of their words, with filters, sorting and cursor pagination, using Prisma only. How it works and why is described in "Search without raw SQL" in [docs/API.md](docs/API.md#search-without-raw-sql).
Expand All @@ -320,7 +371,7 @@ The tests in `backend/src/catalog/` run with `npm test`. The search tests use yo

| Folder | Contents |
| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `backend/` | The backend package: TypeScript, Prisma, the seed script, the catalog search and the draft GraphQL schema |
| `backend/` | The backend package: TypeScript, Prisma, the seed script, the catalog search, the GraphQL server and the GraphQL schema |
| `data/` | The catalog data extracted from the Code of Points: JSON files and one image per element |
| `docs/` | The specification: user interface plans, data model, example data and AI usage. [docs/EXTRACTION.md](docs/EXTRACTION.md) explains how `data/` was made |
| `public/` | Static files served as they are, such as the favicon |
Expand Down
10 changes: 10 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,13 @@
#
# Format: postgresql://USER:PASSWORD@HOST:PORT/DATABASE?schema=public
DATABASE_URL="postgresql://cop:your-password@localhost:5432/cop?schema=public"

# The GraphQL server (npm run dev -w backend). All three are optional.
# The port of the server. The backend should be on 3001 on the virtual machine.
# PORT=3001
# The address the drawings are served from, without a final slash. In development
# the server serves them itself. On the virtual machine it is the Apache address,
# for example http://it2810-XX.idi.ntnu.no/project2/images
# IMAGE_BASE_URL=http://localhost:3001/images
# The addresses that may call the API from a browser, separated by commas.
# CORS_ORIGIN=http://localhost:5173
26 changes: 17 additions & 9 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,29 +4,37 @@
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"start": "tsx src/index.ts",
"build": "tsc",
"setup": "prisma migrate deploy && npm run prisma:seed",
"typecheck": "tsc --noEmit",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
"prisma:deploy": "prisma migrate deploy",
"prisma:seed": "prisma db seed",
"prisma:studio": "prisma studio",
"setup": "prisma migrate deploy && npm run prisma:seed",
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js"
"prisma:studio": "prisma studio"
},
"engines": {
"node": ">=24"
},
"devDependencies": {
"@types/cors": "^2.8.19",
"@types/express": "^5.0.6",
"@types/node": "^24.19.2",
"dotenv": "^17.4.2",
"prisma": "^7.10.1",
"tsx": "^4.23.15",
"typescript": "~6.0.2"
},
"dependencies": {
"@prisma/adapter-pg": "7.10.0",
"@prisma/client": "^7.10.1"
"@apollo/server": "^5.5.1",
"@as-integrations/express5": "^1.1.2",
"@prisma/adapter-pg": "^7.10.1",
"@prisma/client": "^7.10.1",
"cors": "^2.8.6",
"dataloader": "^2.2.3",
"dotenv": "^18.0.8",
"express": "^5.3.0",
"graphql": "^16.14.2",
"tsx": "^4.23.15"
}
}
39 changes: 39 additions & 0 deletions backend/src/errors.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
// Errors that the GraphQL layer turns into errors with `extensions.code` (see
// "Error handling" in docs/API.md). Resolvers and the search throw these; the
// server maps them in backend/src/server/formatError.ts.

/**
* Wrong input from the client. The GraphQL layer turns it into an error with
* `extensions.code` "BAD_USER_INPUT" and this message (see "Error handling"
Expand All @@ -11,3 +15,38 @@ export class BadUserInputError extends Error {
this.name = 'BadUserInputError'
}
}

/** The operation needs a logged-in coach. Code "UNAUTHENTICATED". */
export class UnauthenticatedError extends Error {
readonly code = 'UNAUTHENTICATED'

constructor(message = 'You must be logged in.') {
super(message)
this.name = 'UnauthenticatedError'
}
}

/**
* The operation is in the schema but not built yet. Code "NOT_IMPLEMENTED".
* This is temporary: it goes away when the login and the user data are done.
*/
export class NotImplementedError extends Error {
readonly code = 'NOT_IMPLEMENTED'

constructor(operation: string) {
super(`${operation} is not available yet.`)
this.name = 'NotImplementedError'
}
}

/** The errors above, which the server shows to the client with their code. */
export type ClientError =
BadUserInputError | UnauthenticatedError | NotImplementedError

export function isClientError(error: unknown): error is ClientError {
return (
error instanceof BadUserInputError ||
error instanceof UnauthenticatedError ||
error instanceof NotImplementedError
)
}
49 changes: 21 additions & 28 deletions backend/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,32 +1,25 @@
import { ApolloServer } from '@apollo/server';
import { startStandaloneServer } from '@apollo/server/standalone';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import dotenv from 'dotenv';
import { createPrismaClient } from './db.js';
import { resolvers } from './resolvers.js';
// Starts the server: `npm run dev -w backend` (restarts on changes) or
// `npm run start -w backend`. The settings are in backend/.env.

dotenv.config();
import { createPrismaClient } from './db.js' // reads backend/.env first
import { createApp } from './server/app.js'
import { readConfig } from './server/config.js'

// Initialize database connection
const prisma = createPrismaClient();
const config = readConfig()
const prisma = createPrismaClient()
const { app, server } = await createApp({ prisma, config })

// Load GraphQL Schema
const typeDefs = readFileSync(
path.join(process.cwd(), 'src/schema.graphql'),
'utf-8',
);
const listener = app.listen(config.port, () => {
console.log(`GraphQL server on http://localhost:${config.port}/graphql`)
console.log(`Drawings on ${config.imageBaseUrl}`)
})

const server = new ApolloServer({
typeDefs,
resolvers,
});

const port = parseInt(process.env.PORT || '4000', 10);

const { url } = await startStandaloneServer(server, {
listen: { port },
context: async () => ({ prisma }),
});

console.log(` Server ready at ${url}`);
async function shutDown(signal: string) {
console.log(`${signal}: shutting down`)
listener.close()
await server.stop()
await prisma.$disconnect()
process.exit(0)
}
process.on('SIGINT', () => void shutDown('SIGINT'))
process.on('SIGTERM', () => void shutDown('SIGTERM'))
24 changes: 0 additions & 24 deletions backend/src/resolvers.ts

This file was deleted.

68 changes: 68 additions & 0 deletions backend/src/server/app.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// The HTTP app: the GraphQL endpoint, the drawings and a health check. It is
// built from a Prisma client and the settings, so the tests can make one
// against any database and call it in the same way as the real server.

import { ApolloServer } from '@apollo/server'
import { expressMiddleware } from '@as-integrations/express5'
import cors from 'cors'
import express, { type Express } from 'express'
import { fileURLToPath } from 'node:url'
import type { PrismaClient } from '../generated/prisma/client.js'
import type { Config } from './config.js'
import { type Context, createContext } from './context.js'
import { MAX_QUERY_DEPTH, depthLimit } from './depthLimit.js'
import { formatError } from './formatError.js'
import { resolvers } from './resolvers/index.js'
import { loadTypeDefs } from './schema.js'

/** The drawings (data/images/<apparatus>/<file>.webp). Apache serves them on the virtual machine. */
const IMAGES_DIR = new URL('../../../data/images/', import.meta.url)

/** The GraphQL server. `executeOperation` works on it without HTTP, for tests. */
export function createApolloServer(): ApolloServer<Context> {
return new ApolloServer<Context>({
typeDefs: loadTypeDefs(),
resolvers,
validationRules: [depthLimit(MAX_QUERY_DEPTH)],
formatError,
includeStacktraceInErrorResponses: false,
})
}

export async function createApp(options: {
prisma: PrismaClient
config: Config
}): Promise<{ app: Express; server: ApolloServer<Context> }> {
const { prisma, config } = options
const server = createApolloServer()
await server.start()

const app = express()
app.disable('x-powered-by')

// For the process manager and the proxy: the server is up. It does not touch the database.
app.get('/health', (_req, res) => {
res.json({ status: 'ok' })
})

// The drawings never change under the same name, so the browser may keep them.
app.use(
'/images',
express.static(fileURLToPath(IMAGES_DIR), {
maxAge: '30d',
immutable: true,
index: false,
}),
)

app.use(
'/graphql',
cors({ origin: config.corsOrigins }),
express.json({ limit: '100kb' }),
expressMiddleware(server, {
context: async () => createContext(prisma, config),
}),
)

return { app, server }
}
44 changes: 44 additions & 0 deletions backend/src/server/config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest'
import { DEFAULT_CORS_ORIGIN, DEFAULT_PORT, readConfig } from './config.js'

describe('readConfig', () => {
it('has defaults for development', () => {
expect(readConfig({})).toEqual({
port: DEFAULT_PORT,
imageBaseUrl: `http://localhost:${DEFAULT_PORT}/images`,
corsOrigins: [DEFAULT_CORS_ORIGIN],
})
})

it('takes the image address from the port when it is not set', () => {
expect(readConfig({ PORT: '4000' }).imageBaseUrl).toBe(
'http://localhost:4000/images',
)
expect(readConfig({ PORT: '4000', IMAGE_BASE_URL: '' }).imageBaseUrl).toBe(
'http://localhost:4000/images',
)
})

it('removes the final slash of the image address', () => {
expect(
readConfig({
IMAGE_BASE_URL: 'http://it2810-00.idi.ntnu.no/project2/images///',
}).imageBaseUrl,
).toBe('http://it2810-00.idi.ntnu.no/project2/images')
})

it('reads several allowed origins, separated by commas', () => {
expect(
readConfig({ CORS_ORIGIN: 'http://a.example, http://b.example ,' })
.corsOrigins,
).toEqual(['http://a.example', 'http://b.example'])
})

it('stops with a clear message when the port is not a port', () => {
for (const port of ['abc', '0', '70000', '30.5', '-1']) {
expect(() => readConfig({ PORT: port })).toThrow(
/PORT must be a whole number from 1 to 65535/,
)
}
})
})
Loading