diff --git a/.config/mise.toml b/.config/mise.toml deleted file mode 100644 index 22e3b381..00000000 --- a/.config/mise.toml +++ /dev/null @@ -1,67 +0,0 @@ -[tools] -node = "20.17.0" - -[tasks.build] -run = "rm -rf ./dist ./.tsbuildinfo && ./node_modules/.bin/tsc" -sources = ["source/**/*.ts", "types/**/*.ts", "tsconfig.json"] -outputs = ["dist/"] - -[tasks."build:watch"] -run = "./node_modules/.bin/tsc --watch" - -[tasks.p] -run = "mise run pretty -- --no-write --check" -alias = "pretty:check" - -[tasks.pretty] -run = "./node_modules/.bin/prettier --write source scripts" - -[tasks.lint] -run = "./node_modules/.bin/eslint --cache --max-warnings=0 ." - -[tasks.start] -run = "node dist/source/ccc-server/index.js" -depends = ["build"] - -[tasks."start:watch"] -run = "node --watch dist/source/ccc-server/index.js" -env = { NODE_ENV = "development" } -depends = ["build"] - -[tasks."start:dev"] -run = {task = "start"} -env = { NODE_ENV = "development" } - -[tasks."start:prod"] -run = {task = "start"} -env = { NODE_ENV = "production" } - -[tasks."stolaf-college"] -run = {task = "start:dev"} -env = { INSTITUTION = "stolaf-college" } - -[tasks."carleton-college"] -run = {task = "start:dev"} -env = { INSTITUTION = "carleton-college" } - -[tasks."test:stolaf-college"] -run = "./scripts/smoke-test.sh stolaf-college" -depends = ["build"] - -[tasks."test:carleton-college"] -run = "./scripts/smoke-test.sh carleton-college" -depends = ["build"] - -[tasks.test] -run = "./node_modules/.bin/ava" -depends = ["build"] - -[tasks.watch] -depends = "build" # build once, before starting -run = {tasks = ["build:watch", "start:watch"]} - -[tasks.typecheck] -run = "./node_modules/.bin/tsc --noEmit" - -[tasks.check] -run = {tasks = ["p", "lint", "test", "typecheck"]} diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md deleted file mode 100644 index d674039a..00000000 --- a/.github/copilot-instructions.md +++ /dev/null @@ -1,67 +0,0 @@ -# Copilot Instructions for ccc-server - -## Project Overview -- **Purpose:** Node.js backend server acting as a caching proxy for college-specific APIs and data sources (St. Olaf, Carleton). -- **Architecture:** - - Modular structure under `source/` for each institution (`ccci-stolaf-college`, `ccci-carleton-college`). - - Each institution exposes a `/v1` API router with endpoints for food menus, calendars, contacts, news, orgs, etc. - - Main entry: `source/ccc-server/server.ts` selects institution via `INSTITUTION` env var and wires up middleware/routes. - - Shared utilities in `source/ccc-lib/` and `source/calendar/`. -- **Data Flow:** - - Requests enter via Koa server, routed by institution, handled by modular endpoint files. - - Some endpoints proxy or transform external data (e.g., Google Calendar, BonApp menus). - -## Developer Workflows - -- **Preferred task runner:** This repo uses `mise` task definitions in `.config/mise.toml`. Use `mise run ` where possible; CI relies on these tasks. -- **Install:** `npm install` (CI uses `npm ci` — you can run `mise run npm ci` in environments that support it) -- **Development:** - - Watch/reload: `npm run watch` (see `scripts/watch.js`) or `mise run watch` - - Institution-specific dev: `mise run stolaf-college` or `mise run carleton-college` (these set `INSTITUTION` and depend on `build`) -- **Build:** `mise run build` (same as `npm run build`) -- **Production:** `mise run start:prod` (or `npm run start:prod`) -- **Testing / TDD workflow:** - - This repository practices TDD for "agentic" development. Add tests first, run them, implement until green. - - Unit tests: `mise run test` (runs AVA after building). Test files follow existing patterns: `*.test.ts` next to source (see `menu.test.ts`, `index.test.ts`, `ical.test.ts`). - - Smoke tests: `mise run test:stolaf-college` / `mise run test:carleton-college` — these run `scripts/smoke-test.sh`, start the server in SMOKE_TEST mode and validate `/ping` plus most `/v1/routes` (some routes are intentionally skipped; see the script for details). -- **Lint/Format:** - - Lint: `mise run lint` (or `npm run lint`) - - Format: `mise run pretty` (or `npm run pretty`) - -## Key Conventions & Patterns -- **TypeScript:** Strict, ES modules, custom types in `source/types/` and context in `source/ccc-server/context.ts`. -- **Routing:** - - All API endpoints registered via Koa Router in each institution's `v1/index.ts`. - - `/v1/routes` endpoint lists all available routes for introspection/testing. -- **Environment Variables:** - - `INSTITUTION` required for server startup (`stolaf-college` or `carleton-college`). - - `SMOKE_TEST` disables port binding for test runs. - - `NODE_PORT` sets server port (default 3000). -- **Middleware:** - - Standard Koa stack: logging, compression, etag, cache-control, body parsing, Sentry error handling. -- **Error Handling:** - - Sentry integrated via `@sentry/node`. -- **Testing Skips:** - - See `scripts/smoke-test.sh` for routes skipped due to auth, broken endpoints, or slow responses. - -## Integration Points -- **External APIs:** - - Google Calendar, BonApp, RSS, WP-JSON, etc. (see respective endpoint files) -- **Docker:** - - Dockerfile and `docker-compose.yml` provided for containerized deployment. --- **CI:** - - GitHub Actions in `.github/workflows/node.js.yml` run the `mise`-based checks and smoke tests for both institutions. The canonical task definitions live in `.config/mise.toml`. - -## Examples -- To add a new endpoint for both colleges, update each institution's `v1/index.ts` and implement the handler in a new or existing file. -- To debug a failing smoke test, check `scripts/smoke-test.sh` for skip logic and endpoint validation details. - ---- - -For questions or unclear conventions, ask for clarification or review the referenced files for examples. - -## TDD & agent guidance -- Tests are the contract: write a failing AVA test (unit or integration), implement code in `source/`, run `mise run test` until green. -- Prefer small, focused tests next to implementation files; mirror existing tests (examples: `source/menus-bonapp/menu.test.ts`, `source/calendar/ical.test.ts`). -- Use `/v1/routes` and `scripts/smoke-test.sh` to validate integration endpoints during PRs. Smoke tests are used in CI to catch regressions. -- When adding endpoints, update the relevant `v1/index.ts` for each institution and add both unit tests and a smoke/integration test when the change touches external APIs or routing. \ No newline at end of file diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index 1563bbc9..f1aedf0c 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -10,33 +10,101 @@ on: branches: ['master'] jobs: - # This job runs all the primary checks: linting, formatting, type checking, and unit tests. - # It is the equivalent of the old `build`, `test`, `lint`, and `format` jobs combined. - checks: + build: runs-on: ubuntu-latest + strategy: + matrix: + # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ + node-version: [20.17] steps: - uses: actions/checkout@v4 - - name: Setup mise - uses: jdx/mise-action@v2 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: 'npm' - run: npm ci - - run: mise run check + - run: npm run build + + test: + runs-on: ubuntu-latest + strategy: + matrix: + # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ + node-version: [20.17] + steps: + - uses: actions/checkout@v4 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: 'npm' + - run: npm ci + - run: npm run build + - run: npm test smoke-test-stolaf: runs-on: ubuntu-latest - needs: checks + strategy: + matrix: + # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ + node-version: [20.17] steps: - uses: actions/checkout@v4 - - name: Setup mise - uses: jdx/mise-action@v2 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: 'npm' - run: npm ci - - run: mise run test:stolaf-college + - run: npm run build + - run: npm run test:stolaf-college smoke-test-carleton: runs-on: ubuntu-latest - needs: checks + strategy: + matrix: + # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ + node-version: [20.17] + steps: + - uses: actions/checkout@v4 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: 'npm' + - run: npm ci + - run: npm run build + - run: npm run test:carleton-college + + lint: + runs-on: ubuntu-latest + strategy: + matrix: + # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ + node-version: [20.17] + steps: + - uses: actions/checkout@v4 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: 'npm' + - run: npm ci + - run: npm run lint + + format: + runs-on: ubuntu-latest + strategy: + matrix: + # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ + node-version: [20.17] steps: - uses: actions/checkout@v4 - - name: Setup mise - uses: jdx/mise-action@v2 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: 'npm' - run: npm ci - - run: mise run test:carleton-college \ No newline at end of file + - run: npm run p diff --git a/README.md b/README.md index 35b07f88..2baf9b1a 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Install ```sh git clone https://github.com/frog-pond/ccc-server.git cd ccc-server -npm ci +npm install ``` ## Running the Server @@ -23,21 +23,21 @@ npm ci Watch mode (recommended): auto-recompile & restart on changes ```sh -mise run watch +npm run watch ``` Institution-specific servers ```sh -mise run stolaf-college -mise run carleton-college +npm run stolaf-college +npm run carleton-college ``` ### Production ```sh -mise run build -mise run start:prod +npm run build +npm run start:prod ``` ## Testing @@ -45,16 +45,12 @@ mise run start:prod All tests ```sh -mise run test +npm run test ``` Smoke tests ```sh -mise run test:stolaf-college -mise run test:carleton-college +npm run test:stolaf-college +npm run test:carleton-college ``` - -TDD workflow - -This repository practices TDD for agentic development: write a failing AVA test next to the implementation (`*.test.ts`), run `mise run test`, implement until green, then run smoke tests for integration checks. diff --git a/SECURITY.md b/SECURITY.md deleted file mode 100644 index 78ffc6df..00000000 --- a/SECURITY.md +++ /dev/null @@ -1,21 +0,0 @@ -# Security Policy - -We take security issues in our project seriously and appreciate the efforts of the community in improving the security of our project. - -## Supported versions - -Any security issue found actively deployed in `HEAD` of the main branch is eligible to receive a fix. - -| Reference | Supported | -| --------- | ------------------ | -| `HEAD` | :white_check_mark: | -| < `HEAD` | :x: | - -## Reporting a vulnerability - -To report a security issue, please email [allaboutolaf@frogpond.tech](mailto:allaboutolaf@frogpond.tech) with a description of the issue, the steps you took to create the issue, affected versions, and, if known, mitigations for the issue. -Our team will respond within 3 working days of your email. -If the issue is confirmed as a vulnerability, we will open a Security Advisory. -This project follows a 90 day disclosure timeline. - -Thank you for your contribution to the security of this project. diff --git a/package-lock.json b/package-lock.json index bcbfac91..eb06a211 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,8 +10,12 @@ "@sentry/node": "8.4.0", "@sentry/profiling-node": "8.4.0", "@sentry/utils": "8.4.0", + "dataloader": "^2.2.3", "dotenv": "^16.4.5", "get-urls": "12.1.0", + "graphql": "^16.11.0", + "graphql-http": "^1.22.4", + "graphql-relay": "^0.10.2", "html-entities": "2.5.2", "ical.js": "^2.0.1", "is-absolute-url": "4.0.1", @@ -42,6 +46,7 @@ "@eslint/js": "^9.3.0", "@types/eslint__js": "^8.42.3", "@types/eslint-config-prettier": "^6.11.3", + "@types/graphql": "^14.2.3", "@types/jsdom": "^21.1.6", "@types/koa": "^2.15.0", "@types/koa-bodyparser": "^4.3.12", @@ -57,6 +62,7 @@ "ava": "^6.1.3", "eslint": "^8.0.0", "eslint-config-prettier": "^9.1.0", + "get-port": "^7.1.0", "globals": "^15.6.0", "prettier": "^3.2.5", "typed-query-selector": "^2.11.2", @@ -1082,6 +1088,13 @@ "@types/send": "*" } }, + "node_modules/@types/graphql": { + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/@types/graphql/-/graphql-14.2.3.tgz", + "integrity": "sha512-UoCovaxbJIxagCvVfalfK7YaNhmxj3BQFRQ2RHQKLiu+9wNXhJnlbspsLHt/YQM99IaLUUFJNzCwzc6W0ypMeQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/http-assert": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@types/http-assert/-/http-assert-1.5.5.tgz", @@ -1217,6 +1230,7 @@ "resolved": "https://registry.npmjs.org/@types/koa-router/-/koa-router-7.4.8.tgz", "integrity": "sha512-SkWlv4F9f+l3WqYNQHnWjYnyTxYthqt8W9az2RTdQW7Ay8bc00iRZcrb8MC75iEfPqnGcg2csEl8tTG1NQPD4A==", "dev": true, + "license": "MIT", "dependencies": { "@types/koa": "*" } @@ -2421,6 +2435,12 @@ "node": ">=18" } }, + "node_modules/dataloader": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/dataloader/-/dataloader-2.2.3.tgz", + "integrity": "sha512-y2krtASINtPFS1rSDjacrFgn1dcUuoREVabwlOGOe4SdxenREqwjwjElAdwvbGM7kgZz9a3KVicWR7vcz8rnzA==", + "license": "MIT" + }, "node_modules/date-time": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/date-time/-/date-time-3.1.0.tgz", @@ -3249,6 +3269,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-port": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/get-port/-/get-port-7.1.0.tgz", + "integrity": "sha512-QB9NKEeDg3xxVwCCwJQ9+xycaz6pBB6iQ76wiWMl1927n0Kir6alPiP+yuiICLLU4jpMe08dXfpebuQppFA2zw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-stream": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", @@ -3368,6 +3401,42 @@ "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", "dev": true }, + "node_modules/graphql": { + "version": "16.11.0", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.11.0.tgz", + "integrity": "sha512-mS1lbMsxgQj6hge1XZ6p7GPhbrtFwUFYi3wRzXAC/FmYnyXMTvvI3td3rjmQ2u8ewXueaSvRPWaEcgVVOT9Jnw==", + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, + "node_modules/graphql-http": { + "version": "1.22.4", + "resolved": "https://registry.npmjs.org/graphql-http/-/graphql-http-1.22.4.tgz", + "integrity": "sha512-OC3ucK988teMf+Ak/O+ZJ0N2ukcgrEurypp8ePyJFWq83VzwRAmHxxr+XxrMpxO/FIwI4a7m/Fzv3tWGJv0wPA==", + "license": "MIT", + "workspaces": [ + "implementations/**/*" + ], + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "graphql": ">=0.11 <=16" + } + }, + "node_modules/graphql-relay": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/graphql-relay/-/graphql-relay-0.10.2.tgz", + "integrity": "sha512-abybva1hmlNt7Y9pMpAzHuFnM2Mme/a2Usd8S4X27fNteLGRAECMYfhmsrpZFvGn3BhmBZugMXYW/Mesv3P1Kw==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.15.0 || >= 15.9.0" + }, + "peerDependencies": { + "graphql": "^16.2.0" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", diff --git a/package.json b/package.json index 16527aee..7626389a 100644 --- a/package.json +++ b/package.json @@ -42,8 +42,12 @@ "@sentry/node": "8.4.0", "@sentry/profiling-node": "8.4.0", "@sentry/utils": "8.4.0", + "dataloader": "^2.2.3", "dotenv": "^16.4.5", "get-urls": "12.1.0", + "graphql": "^16.11.0", + "graphql-http": "^1.22.4", + "graphql-relay": "^0.10.2", "html-entities": "2.5.2", "ical.js": "^2.0.1", "is-absolute-url": "4.0.1", @@ -74,6 +78,7 @@ "@eslint/js": "^9.3.0", "@types/eslint__js": "^8.42.3", "@types/eslint-config-prettier": "^6.11.3", + "@types/graphql": "^14.2.3", "@types/jsdom": "^21.1.6", "@types/koa": "^2.15.0", "@types/koa-bodyparser": "^4.3.12", @@ -89,6 +94,7 @@ "ava": "^6.1.3", "eslint": "^8.0.0", "eslint-config-prettier": "^9.1.0", + "get-port": "^7.1.0", "globals": "^15.6.0", "prettier": "^3.2.5", "typed-query-selector": "^2.11.2", diff --git a/scripts/watch.js b/scripts/watch.js new file mode 100644 index 00000000..c1770443 --- /dev/null +++ b/scripts/watch.js @@ -0,0 +1,13 @@ +import {spawn} from 'child_process' + +const buildWatch = spawn('npm', ['run', 'build:watch'], {stdio: 'inherit'}) +const startWatch = spawn('npm', ['run', 'start:watch'], {stdio: 'inherit'}) + +const shutdown = () => { + buildWatch.kill() + startWatch.kill() + process.exit() +} + +process.on('SIGINT', shutdown) +process.on('SIGTERM', shutdown) diff --git a/source/calendar/ical.test.ts b/source/calendar/ical.test.ts index 8becd68e..7cc5fa63 100644 --- a/source/calendar/ical.test.ts +++ b/source/calendar/ical.test.ts @@ -185,51 +185,3 @@ END:VCALENDAR` t.is(result.description, 'Test description') t.is(result.location, 'Test Location') }) - -test('ical function should filter events beyond maxEndDate', (t) => { - const now = moment('2024-01-01') - const maxEndDate = moment('2026-01-01') - - // Create an iCal with events in different time periods - const sampleIcal = `BEGIN:VCALENDAR -VERSION:2.0 -PRODID:-//Example//EN -BEGIN:VEVENT -UID:event1@example.com -SUMMARY:Event within range -DTSTART:20250601T100000Z -DTEND:20250601T110000Z -END:VEVENT -BEGIN:VEVENT -UID:event2@example.com -SUMMARY:Event beyond maxEndDate -DTSTART:20990101T100000Z -DTEND:20990101T110000Z -END:VEVENT -BEGIN:VEVENT -UID:event3@example.com -SUMMARY:Event just at limit -DTSTART:20260101T100000Z -DTEND:20260101T110000Z -END:VEVENT -END:VCALENDAR` - - // We test the filtering logic directly - const comp = InternetCalendar.Component.fromString(sampleIcal) - let events = comp - .getAllSubcomponents('vevent') - .map((vevent) => new InternetCalendar.Event(vevent)) - - // Apply filters as the ical function does - events = events.filter((event) => moment(event.endDate.toString()).isAfter(now, 'day')) - events = events.filter((event) => - moment(event.endDate.toString()).isSameOrBefore(maxEndDate, 'day'), - ) - - t.is(events.length, 2, 'Should have filtered out the event beyond 2026') - - const eventSummaries = events.map((e) => e.summary) - t.true(eventSummaries.includes('Event within range')) - t.true(eventSummaries.includes('Event just at limit')) - t.false(eventSummaries.includes('Event beyond maxEndDate')) -}) diff --git a/source/calendar/ical.ts b/source/calendar/ical.ts index 71714f43..971f12d1 100644 --- a/source/calendar/ical.ts +++ b/source/calendar/ical.ts @@ -31,11 +31,7 @@ function convertEvent(event: InternetCalendar.Event, now = moment()) { }) } -export async function ical( - url: string | URL, - {onlyFuture = true, maxEndDate}: {onlyFuture?: boolean; maxEndDate?: moment.Moment} = {}, - now = moment(), -) { +export async function ical(url: string | URL, {onlyFuture = true} = {}, now = moment()) { let body = await get(url, {headers: {accept: 'text/calendar'}}).text() let comp = InternetCalendar.Component.fromString(body) @@ -47,12 +43,6 @@ export async function ical( events = events.filter((event) => moment(event.endDate.toString()).isAfter(now, 'day')) } - if (maxEndDate) { - events = events.filter((event) => - moment(event.endDate.toString()).isSameOrBefore(maxEndDate, 'day'), - ) - } - return sortBy( events.map((e) => convertEvent(e, now)), (e) => e.startTime, diff --git a/source/ccc-server/graphql/index.ts b/source/ccc-server/graphql/index.ts new file mode 100644 index 00000000..62881500 --- /dev/null +++ b/source/ccc-server/graphql/index.ts @@ -0,0 +1,55 @@ +import {createHandler} from 'graphql-http/lib/use/koa' +import {GraphQLObjectType, GraphQLSchema, GraphQLString} from 'graphql' +import { + type ConnectionArguments, + connectionArgs, + connectionDefinitions, + connectionFromArray, +} from 'graphql-relay' +import {registerType, nodeField} from './utils/node-interface.js' +import {contactTypeInfo, ContactType, contactLoader} from './types/contact.js' +import {dictionaryTypeInfo, DictionaryDefinitionType, dictionaryLoader} from './types/dictionary.js' + +registerType('Contact', contactTypeInfo) +registerType('DictionaryDefinition', dictionaryTypeInfo) + +const {connectionType: ContactConnection} = connectionDefinitions({ + name: 'Contact', + nodeType: ContactType, +}) + +const {connectionType: DictionaryDefinitionConnection} = connectionDefinitions({ + name: 'DictionaryDefinition', + nodeType: DictionaryDefinitionType, +}) + +const schema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Query', + fields: { + node: nodeField, + hello: { + type: GraphQLString, + resolve: () => 'world', + }, + contacts: { + type: ContactConnection, + args: connectionArgs, + resolve: async (_, args: ConnectionArguments) => { + const contacts = await contactLoader.load('contact-info.json') + return connectionFromArray(contacts, args) + }, + }, + dictionary: { + type: DictionaryDefinitionConnection, + args: connectionArgs, + resolve: async (_, args: ConnectionArguments) => { + const definitions = await dictionaryLoader.load('dictionary.json') + return connectionFromArray(definitions, args) + }, + }, + }, + }), +}) + +export const graphql = createHandler({schema}) diff --git a/source/ccc-server/graphql/types/contact.ts b/source/ccc-server/graphql/types/contact.ts new file mode 100644 index 00000000..a7749273 --- /dev/null +++ b/source/ccc-server/graphql/types/contact.ts @@ -0,0 +1,67 @@ +import {GraphQLID, GraphQLNonNull, GraphQLObjectType, GraphQLString} from 'graphql' +import {toGlobalId} from 'graphql-relay' +import {z} from 'zod' +import {get} from '../../../ccc-lib/http.js' +import {GH_PAGES} from '../../../ccci-stolaf-college/v1/gh-pages.js' +import DataLoader from 'dataloader' +import {nodeInterface, type NodeTypeInfo} from '../utils/node-interface.js' +import {URLScalar} from '../utils/url-scalar.js' + +const ContactSchema = z.object({ + title: z.string(), + phoneNumber: z.string(), + buttonText: z.string(), + category: z.string(), + image: z.string().optional(), + synopsis: z.string(), + text: z.string(), +}) + +type Contact = z.infer + +interface ContactResponse { + data: Contact[] +} + +export const contactLoader = new DataLoader(async (keys) => { + const contacts = await Promise.all(keys.map((key) => get(GH_PAGES(key)).json())) + return contacts.map((contact) => contact.data) +}) + +export const ContactType = new GraphQLObjectType({ + name: 'Contact', + fields: { + id: { + type: new GraphQLNonNull(GraphQLID), + resolve: (contact) => toGlobalId('Contact', contact.title), + }, + title: {type: new GraphQLNonNull(GraphQLString)}, + phoneNumber: {type: new GraphQLNonNull(GraphQLString)}, + buttonText: {type: new GraphQLNonNull(GraphQLString)}, + category: {type: new GraphQLNonNull(GraphQLString)}, + image: { + type: URLScalar, + resolve: (contact) => { + if (!contact.image) { + return null + } + try { + return new URL(contact.image) + } catch { + return null + } + }, + }, + synopsis: {type: new GraphQLNonNull(GraphQLString)}, + text: {type: new GraphQLNonNull(GraphQLString)}, + }, + interfaces: [nodeInterface], +}) + +export const contactTypeInfo: NodeTypeInfo = { + schema: ContactSchema, + fetcher: async (id: string) => { + const contacts = await contactLoader.load('contact-info.json') + return contacts.find((c) => c.title === id) ?? null + }, +} diff --git a/source/ccc-server/graphql/types/dictionary.ts b/source/ccc-server/graphql/types/dictionary.ts new file mode 100644 index 00000000..c9c3b4a0 --- /dev/null +++ b/source/ccc-server/graphql/types/dictionary.ts @@ -0,0 +1,46 @@ +import {GraphQLID, GraphQLNonNull, GraphQLObjectType, GraphQLString} from 'graphql' +import {toGlobalId} from 'graphql-relay' +import {z} from 'zod' +import {get} from '../../../ccc-lib/http.js' +import {GH_PAGES} from '../../../ccci-stolaf-college/v1/gh-pages.js' +import DataLoader from 'dataloader' +import {nodeInterface, type NodeTypeInfo} from '../utils/node-interface.js' + +const DictionaryDefinitionSchema = z.object({ + word: z.string(), + definition: z.string(), +}) + +type DictionaryDefinition = z.infer + +interface DictionaryDefinitionResponse { + data: DictionaryDefinition[] +} + +export const dictionaryLoader = new DataLoader(async (keys) => { + const dictionary = await Promise.all( + keys.map((key) => get(GH_PAGES(key)).json()), + ) + return dictionary.map((entry) => entry.data) +}) + +export const DictionaryDefinitionType = new GraphQLObjectType({ + name: 'DictionaryDefinition', + fields: { + id: { + type: new GraphQLNonNull(GraphQLID), + resolve: (def) => toGlobalId('DictionaryDefinition', def.word), + }, + word: {type: new GraphQLNonNull(GraphQLString)}, + definition: {type: new GraphQLNonNull(GraphQLString)}, + }, + interfaces: [nodeInterface], +}) + +export const dictionaryTypeInfo: NodeTypeInfo = { + schema: DictionaryDefinitionSchema, + fetcher: async (id: string) => { + const definitions = await dictionaryLoader.load('dictionary.json') + return definitions.find((d) => d.word === id) ?? null + }, +} diff --git a/source/ccc-server/graphql/utils/node-interface.ts b/source/ccc-server/graphql/utils/node-interface.ts new file mode 100644 index 00000000..b45f67e6 --- /dev/null +++ b/source/ccc-server/graphql/utils/node-interface.ts @@ -0,0 +1,43 @@ +import {fromGlobalId, nodeDefinitions} from 'graphql-relay' +import {z, type ZodObject, type ZodRawShape, type ZodTypeAny} from 'zod' + +export type KnownGraphQLTypeNames = 'Contact' | 'DictionaryDefinition' + +export interface NodeTypeInfo { + schema: T + fetcher: (id: string) => Promise | null> +} + +const typeInfoRegistry: { + [key in KnownGraphQLTypeNames]?: NodeTypeInfo> +} = {} + +export function registerType>( + name: KnownGraphQLTypeNames, + info: NodeTypeInfo, +) { + typeInfoRegistry[name] = info +} + +function getObject(globalId: string): Promise { + const {type, id} = fromGlobalId(globalId) + const info = typeInfoRegistry[type as KnownGraphQLTypeNames] + if (info) { + return info.fetcher(id) + } + return Promise.resolve(null) +} + +function resolveNodeType(obj: object): KnownGraphQLTypeNames | undefined { + for (const name in typeInfoRegistry) { + if (Object.prototype.hasOwnProperty.call(typeInfoRegistry, name)) { + const info = typeInfoRegistry[name as KnownGraphQLTypeNames] + if (info?.schema.safeParse(obj).success) { + return name as KnownGraphQLTypeNames + } + } + } + return undefined +} + +export const {nodeInterface, nodeField} = nodeDefinitions(getObject, resolveNodeType) diff --git a/source/ccc-server/graphql/utils/url-scalar.ts b/source/ccc-server/graphql/utils/url-scalar.ts new file mode 100644 index 00000000..9b72f16e --- /dev/null +++ b/source/ccc-server/graphql/utils/url-scalar.ts @@ -0,0 +1,35 @@ +import {GraphQLScalarType, Kind} from 'graphql' + +export const URLScalar = new GraphQLScalarType({ + name: 'URL', + description: 'A URL, represented as a string.', + + serialize(value: unknown): string { + if (value instanceof URL) { + return value.toString() + } + throw new Error('URLScalar can only serialize URL objects') + }, + + parseValue(value: unknown): URL { + if (typeof value !== 'string') { + throw new Error('URLScalar can only parse string values') + } + try { + return new URL(value) + } catch (error) { + throw new Error('Invalid URL format') + } + }, + + parseLiteral(ast): URL { + if (ast.kind !== Kind.STRING) { + throw new Error('URLScalar can only parse string literals') + } + try { + return new URL(ast.value) + } catch (error) { + throw new Error('Invalid URL format') + } + }, +}) diff --git a/source/ccc-server/server.ts b/source/ccc-server/server.ts index b8858680..1a92247f 100644 --- a/source/ccc-server/server.ts +++ b/source/ccc-server/server.ts @@ -51,6 +51,12 @@ async function main() { ctx.body = 'pong' }) + // + // set up the graphql endpoint + // + const {graphql} = await import('./graphql/index.js') + router.all('/graphql', graphql) + // // attach middleware // diff --git a/source/ccc-server/tests/graphql.test.ts b/source/ccc-server/tests/graphql.test.ts new file mode 100644 index 00000000..d97ea9bc --- /dev/null +++ b/source/ccc-server/tests/graphql.test.ts @@ -0,0 +1,157 @@ +import test from 'ava' +import {spawn} from 'child_process' +import type {ChildProcess} from 'child_process' +import ky from 'ky' +import getPort from 'get-port' +import {toGlobalId} from 'graphql-relay' + +let server: ChildProcess +let port: number + +test.before(async () => { + port = await getPort() + await new Promise((resolve, reject) => { + server = spawn('npm', ['run', 'start:dev'], { + env: { + ...process.env, + INSTITUTION: 'stolaf-college', + NODE_PORT: String(port), + }, + detached: true, + }) + + server.stdout?.on('data', (data: Buffer) => { + if (data.toString().includes(`listening on port ${String(port)}`)) { + resolve() + } + }) + + server.stderr?.on('data', (data: Buffer) => { + console.error(`server stderr: ${data.toString()}`) + }) + + server.on('exit', (code) => { + if (code !== 0) { + reject(new Error(`Server exited with code ${String(code)}`)) + } + }) + }) +}) + +test.after.always(() => { + if (server.pid) { + // Kill the process group to ensure child processes are also killed. + process.kill(-server.pid) + } +}) + +test('graphql endpoint returns a successful response for a basic query', async (t) => { + const query = '{ hello }' + const response = await ky + .post(`http://localhost:${String(port)}/graphql`, { + json: {query}, + }) + .json<{data: {hello: string}}>() + + t.is(response.data.hello, 'world') +}) + +test('graphql endpoint returns a list of contacts in a connection', async (t) => { + const query = ` + query GetContacts { + contacts { + edges { + node { + id + title + } + } + } + } + ` + const response = await ky + .post(`http://localhost:${String(port)}/graphql`, { + json: {query}, + }) + .json<{data: {contacts: {edges: {node: {id: string; title: string}}[]}}}>() + + const contacts = response.data.contacts.edges + t.true(Array.isArray(contacts)) + t.truthy(contacts.length) + if (contacts[0]) { + t.is(contacts[0].node.title, 'St. Olaf Public Safety') + t.truthy(contacts[0].node.id) + } +}) + +test('graphql endpoint can fetch a single contact by its global ID', async (t) => { + const contactTitle = 'St. Olaf Public Safety' + const globalId = toGlobalId('Contact', contactTitle) + + const query = ` + query GetNode($id: ID!) { + node(id: $id) { + ... on Contact { + title + } + } + } + ` + const response = await ky + .post(`http://localhost:${String(port)}/graphql`, { + json: {query, variables: {id: globalId}}, + }) + .json<{data: {node: {title: string} | null}}>() + + t.is(response.data.node?.title, contactTitle) +}) + +test('graphql endpoint returns a list of dictionary definitions in a connection', async (t) => { + const query = ` + query GetDictionary { + dictionary { + edges { + node { + id + word + } + } + } + } + ` + const response = await ky + .post(`http://localhost:${String(port)}/graphql`, { + json: {query}, + }) + .json<{data: {dictionary: {edges: {node: {id: string; word: string}}[]}}}>() + + const definitions = response.data.dictionary.edges + t.true(Array.isArray(definitions)) + t.truthy(definitions.length) + if (definitions[0]) { + t.is(definitions[0].node.word, 'AAC') + t.truthy(definitions[0].node.id) + } +}) + +test('graphql endpoint can fetch a single dictionary definition by its global ID', async (t) => { + const word = 'AAC' + const globalId = toGlobalId('DictionaryDefinition', word) + + const query = ` + query GetNode($id: ID!) { + node(id: $id) { + ... on DictionaryDefinition { + word + } + } + } + ` + const response = await ky + .post(`http://localhost:${String(port)}/graphql`, { + json: {query, variables: {id: globalId}}, + }) + .json<{data: {node: {word: string} | null}}>() + + t.is(response.data.node?.word, word) +}) diff --git a/source/ccci-carleton-college/v1/calendar.ts b/source/ccci-carleton-college/v1/calendar.ts index 5f9e7bb8..d3560117 100644 --- a/source/ccci-carleton-college/v1/calendar.ts +++ b/source/ccci-carleton-college/v1/calendar.ts @@ -2,7 +2,6 @@ import {googleCalendar} from '../../calendar/google.js' import {ical} from '../../calendar/ical.js' import {ONE_MINUTE} from '../../ccc-lib/constants.js' import mem from 'memoize' -import moment from 'moment' import type {Context} from '../../ccc-server/context.js' export const getGoogleCalendar = mem(googleCalendar, {maxAge: ONE_MINUTE}) @@ -28,16 +27,14 @@ export async function carleton(ctx: Context) { ctx.cacheControl(ONE_MINUTE) let url = 'https://www.carleton.edu/calendar/?loadFeed=calendar&stamp=1714843628' - let maxEndDate = moment().add(1, 'month') - ctx.body = await getInternetCalendar(url, {maxEndDate}) + ctx.body = await getInternetCalendar(url) } export async function cave(ctx: Context) { ctx.cacheControl(ONE_MINUTE) let url = 'https://www.carleton.edu/student/orgs/cave/calendar/?loadFeed=calendar' - let maxEndDate = moment().add(1, 'month') - ctx.body = await getInternetCalendar(url, {maxEndDate}) + ctx.body = await getInternetCalendar(url) } export async function stolaf(ctx: Context) { @@ -72,8 +69,7 @@ export async function convos(ctx: Context) { ctx.cacheControl(ONE_MINUTE) let url = 'https://www.carleton.edu/convocations/calendar/?loadFeed=calendar&stamp=1714843936' - let maxEndDate = moment().add(1, 'month') - ctx.body = await getInternetCalendar(url, {maxEndDate}) + ctx.body = await getInternetCalendar(url) } export async function sumo(ctx: Context) { @@ -81,6 +77,5 @@ export async function sumo(ctx: Context) { let url = 'https://www.carleton.edu/student/orgs/sumo/schedule/?loadFeed=calendar&stamp=1714840383' - let maxEndDate = moment().add(1, 'month') - ctx.body = await getInternetCalendar(url, {maxEndDate}) + ctx.body = await getInternetCalendar(url) }