Skip to content
Merged
40 changes: 40 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,46 @@ Other commands:

`npm ci` installs both the frontend and the backend, because the backend is an npm workspace. The database commands are described in the next section.

### Calling the API from the frontend

The frontend uses [Apollo Client](https://www.apollographql.com/docs/react) (`src/lib/apollo/`), and `src/main.tsx` wraps the app in `ApolloProvider`. Start the backend (`npm run backend`, port 3001) and the frontend (`npm run dev`). In development Vite sends `/graphql` and `/images` to the backend, so the browser sees one address and there is no CORS to set up. Set `VITE_API_URL` (for example in `.env.local`) to call another address; the default is `/graphql`. The app is built for the virtual machine with `VITE_API_URL=/project2/graphql npm run build`, because it is served under `/project2/` and Apache must send that address to the backend (see "Connect the frontend to the backend" in [docs/VM_SETUP.md](docs/VM_SETUP.md)).

**Bundle size.** `npm run build` gives one JavaScript file of 665 kB (177 kB compressed), and Vite warns about files over 500 kB. About 160 kB of it is Apollo Client. We leave it for now: the app has two pages, so route-level splitting (`React.lazy`) would give little, and raising the warning limit only hides the number. Check it again when the pages grow. The options then are `React.lazy` for the larger pages and `rollup-plugin-visualizer` to see what is big.

```tsx
import { gql } from '@apollo/client'
import { useQuery } from '@apollo/client/react'

const SEARCH = gql`
query Search($search: String, $first: Int, $after: String) {
searchCatalog(search: $search, first: $first, after: $after) {
pageInfo {
hasNextPage
endCursor
}
edges {
cursor
node {
id
description
}
}
}
}
`

const { data, fetchMore } = useQuery(SEARCH, {
variables: { search, first: 20 },
})
// "Load more": the next page is added to the list in the cache.
fetchMore({ variables: { after: data.searchCatalog.pageInfo.endCursor } })
```

- **Load more with `fetchMore`.** The cache keeps all the pages of one search in one list (the key is the search, filter and sort, not `first` or `after`). A query with `after` that is sent with `useQuery` or `client.query` is answered from the cache. `fetchMore` always asks the server, so use it for the next page.
- **`totalCount` only with the first page** (`totalCount @include(if: $firstPage)`): the server counts all matches only when the query asks for it, and the cache keeps the count when later pages do not have it.
- **The login.** `login(googleToken)`, `logout()` and `restoreSession()` are in `src/lib/apollo/client.ts`. The access token is kept in memory, the refresh token in `localStorage`. A request that answers `UNAUTHENTICATED` because the access token has expired refreshes the session once and is repeated. Refreshing is done from one place at a time (`navigator.locks`), as the server requires. How it works and why is in "Identifying the user" in [docs/API.md](docs/API.md). The Google login button itself is not built yet.
- **Errors.** `describeError(error)` (`src/lib/apollo/errors.ts`) turns an error from the API or the network into a message for the user, and says whether trying again can help.

## Database (local setup)

The backend uses PostgreSQL 18. Everyone installs it directly on their own machine, without Docker, and everyone uses version 18 so that migrations behave the same for all of us.
Expand Down
15 changes: 15 additions & 0 deletions docs/AI_USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,21 @@
its own issue. Claude made the changes, checked the query plans with
`EXPLAIN ANALYZE`, added tests for each point and updated the docs.

## Apollo Client in the frontend (`src/lib/apollo/`)

- **Tool:** Claude Code
- **What the team did:** Wrote issue #75: set up Apollo Client so the pages can use the
API, with a cache for the search, the token handling from the login issue and one
place for error messages, without changing the pages. The team decided that the
refresh token is returned in the response and kept in `localStorage`.
- **What Claude did:** Wrote the client, the cache policy for `searchCatalog`, the
authorization link (one refresh and one repeat on an expired token, one refresh at a
time between tabs), the session, the error messages, the Vite proxy and the tests.
Checked it against the running backend: the first page, "load more" and the errors.
That check showed that a plain query with `after` is answered from the cache because
`after` is not part of the key, so the pages must be loaded with `fetchMore`. This is
now in the README and `docs/API.md`.

## Synthetic test data (`backend/src/seed/synthetic.ts`)

- **Tool:** Claude Code
Expand Down
12 changes: 11 additions & 1 deletion docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,8 @@ The access token is short, because the client keeps it and a token that leaks ca
**What the client does:**

- It keeps the access token in memory. It keeps the refresh token where it survives a reload, and sends it nowhere except to `refreshSession` and `logout`.
- On page load, if `me` is `null` and the client has a refresh token, it calls `refreshSession` once. When a request answers `UNAUTHENTICATED` because the access token has expired, it calls `refreshSession` once and repeats the request. If that fails, the coach logs in with Google again.
- **It refreshes before the request, not only after an error.** If the client has a refresh token and the access token is missing (a reload, a new tab) or expires within 30 seconds, it calls `refreshSession` first. It reads `exp` from the token only to plan this; the server still decides what is valid. The reason is that an expired access token does not make the server fail the request: `me` answers `null`, so a page that asks for `me` would show the coach as logged out after 15 minutes, although the refresh token still works. Waiting for an `UNAUTHENTICATED` error cannot catch that. Checking the expiry before the request and retrying after an error is the usual way to build an Apollo auth link, and the retry stays as a fallback for the cases the check misses (a clock that is wrong, a login that was ended on the server). When a request answers `UNAUTHENTICATED`, the client refreshes once and repeats the request. If that fails, the coach logs in with Google again.
- **The login, refresh and logout requests time out after 5 seconds** (`REQUEST_TIMEOUT_MS` in `src/lib/apollo/api.ts`, with `AbortSignal.timeout`). `main.tsx` waits for the refresh before it shows the app, so a slow server would otherwise leave a white page. A timeout is not an answer from the server, so the client keeps the refresh token and the next request tries again. Showing the app first with a loading state would also work, but it needs a state in every page that reads `me`, and the timeout is one line in one place.
- **It must refresh from one place at a time.** Two tabs that use the same refresh token at the same moment look like a stolen token, and the login ends. Use one refresh at a time per browser (for example `navigator.locks`), and store the new refresh token before the next request.

**Decision for now: the tokens are returned in the response body, not in a cookie.** The refresh token is therefore readable by scripts on the page, so a cross-site scripting hole would leak it. Rotation and the reuse check limit what a stolen token is worth, but they do not remove the risk. An httpOnly, Secure, SameSite cookie for the refresh token is safer, and works when the app and the API are on the same origin (Apache proxy on the virtual machine, the Vite proxy in development). It needs `credentials` in CORS and a change in `login`, `refreshSession` and `logout`, and can be added later without changing the rest.
Expand Down Expand Up @@ -333,6 +334,15 @@ These are the choices for the implementation. The server is built with them (iss
- **Schema first.** We write `schema.graphql` by hand and the resolvers follow it. The file is easy to read for someone who reviews the project, and the schema is the contract between the frontend and the backend. The alternative, generating the schema from TypeScript code (code first), hides the contract in the code.
- **GraphQL Code Generator** makes the TypeScript types for the resolvers from the same schema file (issue #74), and will make the types for the queries in the frontend. A change in the schema then shows up as a type error in both places. Run `npm run codegen` (in `backend/`, config in `backend/codegen.ts`) after `npm ci` and after every change in `schema.graphql`. It writes `backend/src/generated/graphql.ts`, which is not committed, like the Prisma client next to it, so `tsc` and `npm run lint` need it. The resolver files end with `satisfies Resolvers<Context>`: a resolver for a field that is not in the schema, with the wrong arguments or with the wrong return type fails `tsc`. The database rows that resolvers return are mapped in `codegen.ts` (`Apparatus`, `ElementGroup`, `Coach`, `Gymnast`, `Routine`, and `Element`, `Vault`, `CatalogItem` and `CatalogConnection` from `server/items.ts`). The first run found two mismatches that are now fixed: `ElementGroup.apparatus` could be `null` although the schema says it never is, and `totalCount` could be missing when the query did not ask for it (it now has its own resolver that fails clearly).

### The client in the frontend

Apollo Client is set up in `src/lib/apollo/` (issue #75). See "Calling the API from the frontend" in the README for how to use it.

- **`cache.ts`.** `searchCatalog` is a Relay connection. The key of the field is the search, filter and sort, so all the pages of one search are one list. The merge function puts a page with `after` after the pages already there, skips a page that arrives twice (by cursor), starts over when the first page (no `after`) arrives again, and keeps `totalCount` when a later page does not have it. `possibleTypes` lets a fragment on `CatalogItem` match `Element` and `Vault`. Elements and vaults are stored once by `__typename` and `id`, also when they are in several lists. Because `after` is not in the key, a plain query with `after` is a cache hit; `fetchMore` is the way to load the next page.
- **`authLink.ts`, `session.ts`.** Adds `Authorization: Bearer <access token>`, and on `UNAUTHENTICATED` refreshes the session once and repeats the request once. The login operations (`Login`, `RefreshSession`, `Logout`) never start a refresh. Parts of the page that refresh at the same moment share one request, and tabs follow each other through `navigator.locks`, with the refresh token read inside the lock. A refresh token that the server refuses ends the login; a network error keeps it for a later try. The access token is kept in memory, and the refresh token in `localStorage` (see the decision in "Identifying the user").
- **`api.ts`.** The `login`, `refreshSession` and `logout` calls are plain `fetch` requests, not Apollo operations, because the refresh is called by the link itself.
- **`errors.ts`.** One place for the messages of the error codes.

### Search without raw SQL

**The backend never uses raw SQL.** No `$queryRaw`, `$executeRaw`, their `Unsafe` versions or `$queryRawTyped` (Prisma's TypedSQL). Every query goes through Prisma Client, which sends all values as parameters. This removes SQL injection by the way the code is built, and not by careful escaping. A lint rule (`no-restricted-properties` on those names, in `eslint.config.js`) makes the build fail if someone adds a raw query anywhere in the backend, including the tests. Indexes are declared in `schema.prisma` with `@@index`, and the migrations are generated by Prisma, with a few additions written by hand in SQL: CHECK constraints and the collation of `catalog_words.word`, which Prisma cannot express. These are migrations, not queries in the backend, so they do not break the rule.
Expand Down
47 changes: 47 additions & 0 deletions docs/VM_SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,3 +98,50 @@ DATABASE_URL="postgresql://wag_migrator:YOUR_SECURE_MIGRATOR_PASSWORD@localhost:
This command executes Prisma migrations to create all database tables and populates the database with the initial gymnastics catalog data.

_Note: The initial setup and database seeding have been successfully executed on the production VM._

---

## 5. Connect the frontend to the backend

The frontend is served by Apache under `/project2/`, and the backend listens on port 3001. The browser must reach the backend on the same address as the frontend, so Apache sends `/project2/graphql` and `/project2/images` to the backend. This also means there is no CORS to set up.

_Status: this configuration follows the Apache documentation for `mod_proxy`, but it has **not been tried on the VM yet**. Check it with the `curl` command below._

1. Enable the proxy modules and restart Apache:

```bash
sudo a2enmod proxy proxy_http
sudo systemctl restart apache2
```

2. In the `VirtualHost` that serves `/project2/`, add (the path and the address in each pair either both end in a slash or both do not):

```apache
ProxyRequests Off
ProxyPass /project2/graphql http://localhost:3001/graphql
ProxyPassReverse /project2/graphql http://localhost:3001/graphql
ProxyPass /project2/images http://localhost:3001/images
ProxyPassReverse /project2/images http://localhost:3001/images
```

`ProxyRequests Off` is the default and must stay off: with it on, Apache would work as an open proxy for anyone.

3. Build the frontend with the API address, and put `IMAGE_BASE_URL` in `backend/.env` (the drawings are then fetched through the same address):

```bash
VITE_API_URL=/project2/graphql npm run build
```

```env
IMAGE_BASE_URL=http://it2810-XX.idi.ntnu.no/project2/images
```

4. Check that the backend answers through Apache:

```bash
curl -s -X POST -H 'content-type: application/json' -d '{"query":"{ __typename }"}' http://it2810-XX.idi.ntnu.no/project2/graphql
```

The answer should be `{"data":{"__typename":"Query"}}`.

The VM serves plain http, so Sign in with Google does not work there (Google only accepts http for `localhost`), and the tokens would travel without encryption. See "Login" in the README.
Loading