Skip to content
Merged
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: 2 additions & 2 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. 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)). The user data and the connection from the frontend are the next steps.
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)). The login and the user data (gymnasts, their lists of elements and routines) work through the API. The connection from the frontend is the next step.

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 @@ -312,7 +312,7 @@ To write your own SQL queries, connect with `psql -h localhost -U cop -d cop` (o

## 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 is built (see "Login" below). The user data (gymnasts, routines) is not; those operations answer a clear error, see [The server in docs/API.md](docs/API.md#the-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 built (see "Login" below). How the server works is in [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):

Expand Down
8 changes: 7 additions & 1 deletion backend/src/dscore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
// divided at the end. Adding 0.1 + 0.2 as decimals gives 0.30000000000000004.

import { InvalidItemsError } from './errors.js'
import { apparatusOfId } from './server/items.js'
import { type Item, apparatusOfId } from './server/items.js'

/** How many elements count in the score. */
export const COUNTED_ELEMENTS = 8
Expand Down Expand Up @@ -58,3 +58,9 @@ export function calculateDScore(items: ScoredItem[]): number {
.slice(0, COUNTED_ELEMENTS)
return highest.reduce((sum, tenths) => sum + tenths, 0) / 10
}

/** An element or vault from the database as the function wants it. */
export const toScoredItem = (item: Item): ScoredItem =>
item.__typename === 'Vault'
? { id: item.id, code: item.code, value: item.dValue, isVault: true }
: { id: item.id, code: item.code, value: item.value, isVault: false }
41 changes: 40 additions & 1 deletion backend/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,18 +53,57 @@ export class InvalidItemsError extends Error {
}
}

/**
* The id does not exist, or it belongs to another coach. Both cases give the
* same answer, so that nobody can find out which ids exist. Code "NOT_FOUND".
*/
export class NotFoundError extends Error {
readonly code = 'NOT_FOUND'

constructor(what = 'The item') {
super(`${what} was not found.`)
this.name = 'NotFoundError'
}
}

/** The coach already has a routine with this name for this gymnast and apparatus. Code "NAME_TAKEN". */
export class NameTakenError extends Error {
readonly code = 'NAME_TAKEN'

constructor(message = 'A routine with this name already exists.') {
super(message)
this.name = 'NameTakenError'
}
}

/** A list is full (gymnasts, routines, items in a routine or in a gymnast's list). Code "LIMIT_REACHED". */
export class LimitReachedError extends Error {
readonly code = 'LIMIT_REACHED'

constructor(message: string) {
super(message)
this.name = 'LimitReachedError'
}
}

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

export function isClientError(error: unknown): error is ClientError {
return (
error instanceof BadUserInputError ||
error instanceof UnauthenticatedError ||
error instanceof NotImplementedError ||
error instanceof InvalidItemsError
error instanceof InvalidItemsError ||
error instanceof NotFoundError ||
error instanceof NameTakenError ||
error instanceof LimitReachedError
)
}
2 changes: 1 addition & 1 deletion backend/src/server/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ describe.runIf(hasDatabase)('the session', () => {
expect(
codeOf(await run(query, undefined, { accessToken, now: minutes(20) })),
).toBe('UNAUTHENTICATED')
// With a valid token the login check passes (the operation itself is built in another issue).
// With a valid token the login check passes.
expect(codeOf(await run(query, undefined, { accessToken }))).not.toBe(
'UNAUTHENTICATED',
)
Expand Down
3 changes: 3 additions & 0 deletions backend/src/server/items.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ export type Item = ElementItem | VaultItem
*/
export const isVaultId = (id: string): boolean => id.startsWith('VT-')

/** Ids look like "UB-2.305-a". This keeps odd text out of the queries and the messages. */
export const ITEM_ID_PATTERN = /^[A-Za-z0-9.-]{1,40}$/

/** The apparatus an item belongs to: the first part of its id ("UB" in "UB-2.305-a"). */
export const apparatusOfId = (id: string): string => id.split('-')[0] ?? id

Expand Down
71 changes: 69 additions & 2 deletions backend/src/server/loaders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,23 @@
// between requests (see "N+1 queries" in docs/API.md).

import DataLoader from 'dataloader'
import type { Apparatus, PrismaClient } from '../generated/prisma/client.js'
import type {
Apparatus,
Element,
Gymnast,
PrismaClient,
Routine,
Vault,
} from '../generated/prisma/client.js'
import { type Item, asElement, asVault, isVaultId } from './items.js'

/** The element or the vault of a list row (exactly one of them is set). */
const itemOf = (row: {
element: Element | null
vault: Vault | null
}): Item[] =>
row.element ? [asElement(row.element)] : row.vault ? [asVault(row.vault)] : []

export function createLoaders(prisma: PrismaClient) {
/** An element or vault by its id, also when it is removed. `null` when it does not exist. */
const itemById = new DataLoader<string, Item | null>(async (ids) => {
Expand Down Expand Up @@ -64,7 +78,60 @@ export function createLoaders(prisma: PrismaClient) {
},
)

return { itemById, variantsOfGroup, apparatusById }
const gymnastById = new DataLoader<string, Gymnast | null>(async (ids) => {
const rows = await prisma.gymnast.findMany({
where: { id: { in: [...ids] } },
})
const byId = new Map(rows.map((row) => [row.id, row]))
return ids.map((id) => byId.get(id) ?? null)
})

/** The routines of each gymnast: by apparatus, then by name. At most 50 each. */
const routinesOfGymnast = new DataLoader<string, Routine[]>(async (ids) => {
const rows = await prisma.routine.findMany({
where: { gymnastId: { in: [...ids] } },
orderBy: [
{ apparatus: { position: 'asc' } },
{ name: 'asc' },
{ id: 'asc' },
],
})
return ids.map((id) => rows.filter((row) => row.gymnastId === id))
})

/** The list of each gymnast, in the order the items were added. At most 100 each. */
const elementsOfGymnast = new DataLoader<string, Item[]>(async (ids) => {
const rows = await prisma.gymnastElement.findMany({
where: { gymnastId: { in: [...ids] } },
orderBy: { id: 'asc' },
include: { element: true, vault: true },
})
return ids.map((id) =>
rows.filter((row) => row.gymnastId === id).flatMap(itemOf),
)
})

/** The items of each routine, in the order of the routine. The same item can be there twice. */
const itemsOfRoutine = new DataLoader<string, Item[]>(async (ids) => {
const rows = await prisma.routineElement.findMany({
where: { routineId: { in: [...ids] } },
orderBy: [{ routineId: 'asc' }, { position: 'asc' }],
include: { element: true, vault: true },
})
return ids.map((id) =>
rows.filter((row) => row.routineId === id).flatMap(itemOf),
)
})

return {
itemById,
variantsOfGroup,
apparatusById,
gymnastById,
routinesOfGymnast,
elementsOfGymnast,
itemsOfRoutine,
}
}

export type Loaders = ReturnType<typeof createLoaders>
6 changes: 2 additions & 4 deletions backend/src/server/resolvers/calculator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,11 @@ import { calculateDScore, type ScoredItem } from '../../dscore.js'
import { BadUserInputError, InvalidItemsError } from '../../errors.js'
import type { Resolvers } from '../../generated/graphql.js'
import type { Context } from '../context.js'
import { ITEM_ID_PATTERN } from '../items.js'

/** The most ids one call may have (also in docs/API.md). */
export const MAX_CALCULATOR_IDS = 50

/** Ids look like "UB-2.305-a". This keeps odd text out of the queries and the messages. */
const ID_PATTERN = /^[A-Za-z0-9.-]{1,40}$/

export const calculatorResolvers = {
Query: {
calculateDScore: async (
Expand All @@ -23,7 +21,7 @@ export const calculatorResolvers = {
`At most ${MAX_CALCULATOR_IDS} ids can be calculated at once.`,
)
}
if (args.itemIds.some((id) => !ID_PATTERN.test(id))) {
if (args.itemIds.some((id) => !ITEM_ID_PATTERN.test(id))) {
throw new BadUserInputError('An item id is not valid.')
}
const ids = [...new Set(args.itemIds)] // the same id twice counts once
Expand Down
16 changes: 16 additions & 0 deletions backend/src/server/resolvers/index.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,26 @@
import { GraphQLScalarType } from 'graphql'
import { authResolvers } from './auth.js'
import { calculatorResolvers } from './calculator.js'
import { catalogResolvers } from './catalog.js'
import { userDataResolvers } from './userData.js'

/** DateTime: an ISO 8601 date and time in the answer, a Date in the resolvers. */
const DateTime = new GraphQLScalarType<Date, string>({
name: 'DateTime',
serialize: (value) => {
if (!(value instanceof Date)) throw new TypeError('Expected a Date.')
return value.toISOString()
},
parseValue: (value) => {
const date = new Date(String(value))
if (Number.isNaN(date.getTime())) throw new TypeError('Not a date.')
return date
},
})

/** All the resolvers, merged by type. Add a file per area and merge it here. */
export const resolvers = {
DateTime,
...catalogResolvers,
...userDataResolvers,
Query: {
Expand Down
Loading