Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
18 changes: 17 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,7 @@ 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:** the first-page query asks for the count, and later-page queries omit it; the cache keeps the count when those pages are loaded.
- **Authentication.** Session handling (`devLogin`, `logout`, and `restoreSession`) is in `src/lib/apollo/client.ts`. The development test login accepts any email address and is the only login flow planned; do not use real or sensitive data with it. See [Test login on the virtual machine](docs/VM_SETUP.md#6-test-login-on-the-virtual-machine) and [Login for development](docs/API.md#login-for-development).
- **Authentication.** Session handling (`devLogin`, `logout`, and `restoreSession`) is in `src/lib/apollo/client.ts`. The development test login accepts any email address and is the only login flow planned; do not use real or sensitive data with it. See [Test login on the virtual machine](docs/VM_SETUP.md#6-test-login-on-the-virtual-machine) and [Login for development](docs/API.md#login-for-development). The login page (`src/components/auth/LoginPage.tsx`) offers the demo coach and a test coach of your own. The app reads who is logged in in one place, `useAuth()` (`src/components/auth/useAuth.ts`), which the menu, the protected pages and the gymnasts page share.
- **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.

## The Elements page: search, filters and sort
Expand Down Expand Up @@ -467,6 +467,22 @@ node -e "console.log(require('node:crypto').randomBytes(48).toString('base64url'

The server does not start if the secret is shorter than 32 characters. Without it the catalog works and `me` is always `null`. The development test login is enabled with `DEV_LOGIN=true` and a `SESSION_SECRET`; the VM setup documents the additional flag required to enable it in production. It accepts any email address, so it must only be used for testing with non-sensitive data. Send `Authorization: Bearer <accessToken>` with the queries, or call `refreshSession` with the refresh token.

**Try the app with the demo coach.** The only login is a test login that needs no password (why: [docs/AUTHENTICATION.md](docs/AUTHENTICATION.md)). The demo coach has example gymnasts, and you can add and delete things as it.

1. In `backend/.env` add `SESSION_SECRET` (see above) and `DEV_LOGIN=true`. In `.env.local` in the repository root add `VITE_DEV_LOGIN=true`.
2. Fill the database and make the demo coach, then start the backend and the frontend:

```bash
npm run db:seed # the catalog
npm run db:demo # the demo coach (demo@wag-planner.example) with three gymnasts
npm run backend
npm run dev
```

3. Open the app, click **Log in**, and then **Log in as the demo coach**. "Your gymnasts" shows the example gymnasts. Run `npm run db:demo` again to start the demo coach over: it deletes what the demo coach has, and nothing else. Everyone who uses the demo coach shares its data, so run it before each review round.

Everyone who uses the demo coach shares the same data, and anyone can log in as any test coach, so do not enter real data. The page says so.

## 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 Down
1 change: 1 addition & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"prisma:migrate": "prisma migrate dev",
"prisma:deploy": "prisma migrate deploy",
"prisma:seed": "prisma db seed",
"demo": "tsx src/seed/demo.ts",
"synthetic": "tsx src/seed/synthetic.ts",
"prisma:studio": "prisma studio"
},
Expand Down
129 changes: 129 additions & 0 deletions backend/src/seed/demo.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
// Tests for the demo coach (see "The demo coach" in docs/AUTHENTICATION.md).
// They run against the local database from backend/.env, which must have the
// catalog (`npm run db:seed`), and they use an address of their own so the real
// demo coach is not touched. Without DATABASE_URL they are skipped.

import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { DEMO_GYMNASTS } from '../../../shared/demo.js'
import { createPrismaClient } from '../db.js'
import type { PrismaClient } from '../generated/prisma/client.js'
import { createApolloServer } from '../server/app.js'
import { createContext } from '../server/context.js'
import { resetDemoCoach } from './demo.js'

const hasDatabase = Boolean(process.env['DATABASE_URL'])
const EMAIL = 'test-138-demo@example.com'
const OTHER = 'test-138-other@example.com'

describe.runIf(hasDatabase)('resetDemoCoach', () => {
let prisma: PrismaClient

const clean = () =>
prisma.coach.deleteMany({ where: { email: { startsWith: 'test-138-' } } })
const gymnastsOf = async (email: string) =>
(
await prisma.gymnast.findMany({
where: { coach: { email } },
include: { elements: true },
orderBy: { name: 'asc' },
})
).map((g) => ({ name: g.name, elements: g.elements.length }))

beforeAll(async () => {
prisma = createPrismaClient()
await clean()
})
afterAll(async () => {
await clean()
await prisma.$disconnect()
})

it('makes the coach with the example gymnasts, each with elements from the catalog', async () => {
const result = await resetDemoCoach(prisma, EMAIL)

expect(result).toMatchObject({
gymnasts: DEMO_GYMNASTS.length,
elements: 9,
})
expect(await gymnastsOf(EMAIL)).toEqual(
[...DEMO_GYMNASTS].sort().map((name) => ({ name, elements: 3 })),
)
const coach = await prisma.coach.findFirstOrThrow({
where: { email: EMAIL },
})
expect(coach.googleId).toBe(`dev:${EMAIL}`)
})

it('is the coach that the test login finds, so the login page can log in as it', async () => {
const server = createApolloServer()
await server.start()
try {
const response = await server.executeOperation(
{
query: `mutation ($email: String!) { devLogin(email: $email) { coach { id email } } }`,
variables: { email: EMAIL },
},
{
contextValue: createContext(
prisma,
{
port: 3001,
imageBaseUrl: 'http://images.test/images',
corsOrigins: [],
sessionSecret: 'a-secret-that-is-at-least-32-characters-long',
devLogin: true,
},
{},
),
},
)
if (response.body.kind !== 'single')
throw new Error('expected one result')
const coach = await prisma.coach.findFirstOrThrow({
where: { email: EMAIL },
})
expect(response.body.singleResult.errors).toBeUndefined()
expect(
(
response.body.singleResult.data as {
devLogin: { coach: { id: string } }
}
).devLogin.coach.id,
).toBe(coach.id)
expect(await prisma.coach.count({ where: { email: EMAIL } })).toBe(1)
} finally {
await server.stop()
}
})

it('puts the example data back after visitors have changed it, and touches no other coach', async () => {
const coach = await prisma.coach.findFirstOrThrow({
where: { email: EMAIL },
})
await prisma.gymnast.deleteMany({
where: { coachId: coach.id, name: DEMO_GYMNASTS[0]! },
})
await prisma.gymnast.create({
data: { coachId: coach.id, name: 'Added by a visitor' },
})
const other = await prisma.coach.create({
data: {
googleId: 'dev:' + OTHER,
email: OTHER,
name: 'Other',
gymnasts: { create: [{ name: 'Not the demo' }] },
},
})

await resetDemoCoach(prisma, EMAIL)

expect((await gymnastsOf(EMAIL)).map((g) => g.name)).toEqual(
[...DEMO_GYMNASTS].sort(),
)
expect(await gymnastsOf(OTHER)).toEqual([
{ name: 'Not the demo', elements: 0 },
])
expect(await prisma.coach.count({ where: { id: other.id } })).toBe(1)
expect(await prisma.coach.count({ where: { email: EMAIL } })).toBe(1)
})
})
100 changes: 100 additions & 0 deletions backend/src/seed/demo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
// Makes the demo coach and puts its example data back (see "The demo coach" in
// docs/AUTHENTICATION.md).
//
// Run with `npm run db:demo` from the repository root, after `npm run db:seed`
// (the example elements come from the catalog). It can be run again whenever
// the demo coach should start over: everything the demo coach has is deleted
// first, so whatever visitors added or deleted is gone. Other coaches are never
// touched.
//
// The demo coach is a coach of the test login (`devLogin`, see "Login for
// development" in docs/API.md): its Google id is "dev:<e-mail address>", so
// `devLogin` finds it. It has no password; this script only makes the coach
// and its data, and the login itself still needs DEV_LOGIN=true on the server.

import { fileURLToPath } from 'node:url'
import { DEMO_EMAIL, DEMO_GYMNASTS, DEMO_NAME } from '../../../shared/demo.js'
import { createPrismaClient } from '../db.js'
import type { PrismaClient } from '../generated/prisma/client.js'

/** The elements each demo gymnast "can do", from the catalog. */
const ELEMENTS_PER_GYMNAST = 3

export type DemoResult = {
coachId: string
gymnasts: number
elements: number
}

/**
* Makes the demo coach if there is none, and gives it exactly the example
* gymnasts. Each gymnast gets a few elements in the list of what they can do:
* real elements of the catalog, the first ones that are not removed, one after
* the other. If the catalog is empty the gymnasts get no elements. `email` is
* only different in the tests, which must not touch the real demo coach.
*/
export async function resetDemoCoach(
prisma: PrismaClient,
email: string = DEMO_EMAIL,
): Promise<DemoResult> {
const googleId = `dev:${email}`
// Elements of the catalog and not the synthetic test rows (SYN-), which
// `npm run db:synthetic -- remove` deletes.
const elements = await prisma.element.findMany({
where: { removedAt: null, NOT: { id: { startsWith: 'SYN-' } } },
orderBy: { id: 'asc' },
take: DEMO_GYMNASTS.length * ELEMENTS_PER_GYMNAST,
select: { id: true },
})

return prisma.$transaction(async (tx) => {
const coach = await tx.coach.upsert({
where: { googleId },
create: { googleId, email, name: DEMO_NAME },
update: { name: DEMO_NAME },
})
// Deleting the gymnasts deletes their routines and lists (ON DELETE CASCADE).
await tx.gymnast.deleteMany({ where: { coachId: coach.id } })

let added = 0
for (const [index, name] of DEMO_GYMNASTS.entries()) {
const own = elements.slice(
index * ELEMENTS_PER_GYMNAST,
(index + 1) * ELEMENTS_PER_GYMNAST,
)
await tx.gymnast.create({
data: {
coachId: coach.id,
name,
elements: { create: own.map((e) => ({ elementId: e.id })) },
},
})
added += own.length
}
return {
coachId: coach.id,
gymnasts: DEMO_GYMNASTS.length,
elements: added,
}
})
}

if (process.argv[1] === fileURLToPath(import.meta.url)) {
const prisma = createPrismaClient()
resetDemoCoach(prisma)
.then((result) => {
console.log(
`The demo coach (${DEMO_EMAIL}) has ${result.gymnasts} gymnasts with ${result.elements} elements.`,
)
if (result.elements === 0) {
console.log(
'The catalog is empty, so the gymnasts have no elements. Run "npm run db:seed" first.',
)
}
})
.catch((error: unknown) => {
console.error(error instanceof Error ? error.message : error)
process.exitCode = 1
})
.finally(() => prisma.$disconnect())
}
6 changes: 6 additions & 0 deletions docs/AI_USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -493,6 +493,12 @@
- **What the team did:** Decided that the page says "1000+" when there are more than 1000 results, and that the server must not count more than that.
- **What Claude did:** Made the database stop counting at 1001 (`count` with `take`), kept the schema the same (a `totalCount` above 1000 means more than 1000), wrote the tests (the count stops at one more than the most, it is not capped below that, and a search is capped too) and broke the cap to check that they fail without it.

## The login, the demo coach and deleting a gymnast (`src/components/auth/`, `src/components/ProtectedRoute.tsx`, `backend/src/seed/demo.ts`, `docs/AUTHENTICATION.md`)

- **Tool:** Claude Code
- **What the team did:** Asked for one source of login state after peer feedback (the menu and the gymnasts page disagreed), and for a clear way from the login to saving and seeing your own data. Decided to keep the test login and a seeded demo coach, and not to build passwords, when it was clear that the course server has only http (so a password would be sent as plain text) and that the assignment asks for saved user data and not for a login. Asked Claude to delete the work on password accounts, and to write down how much was tried and what a real system would do. Asked that the demo coach can save and delete things.
- **What Claude did:** Read the course text, the OWASP password and authentication cheat sheets, Google's guide for the client library and the Node documentation for `crypto.argon2`. First built password accounts (Argon2id, a lock after five wrong passwords, the same answer for every failure) with tests that were checked by breaking the lock, the same-answer rule and the lock-while-locked rule one at a time; then deleted them on the team's decision. Wrote `AuthProvider` and `useAuth` so the menu, `ProtectedRoute` and the gymnasts page read the same answer, the login page, the http warning, the confirmation before deleting a gymnast, and `npm run db:demo`. Wrote the tests (including one test of the whole path in `src/App.flow.test.tsx`) and broke four behaviors one at a time to check that a test fails: waiting while the server has not answered, the same state for the page and the menu, a `from` that leaves the site, and resetting the store after a login (this one was not caught by the flow test, which replaces the login functions, so `src/lib/apollo/client.test.ts` was added for it). Wrote `docs/AUTHENTICATION.md`.

## The filters, the sort and the page titles of the Elements page (`src/components/elements/FilterMenu.tsx`, `SortMenu.tsx`, `src/lib/elementFilters.ts`, `src/components/PageTitle.tsx`, `backend/src/catalog/`)

- **Tool:** Claude Code
Expand Down
Loading