diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 7095250..9547831 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -31,11 +31,8 @@ jobs: - name: Linting run: npm run lint - - name: Run server tests - run: npm run test:server - - name: Install Playwright dependencies run: npx playwright install --with-deps chromium - - name: Run Playwright end-to-end tests - run: npm run test:frontend + - name: Run tests + run: npm run test diff --git a/README.md b/README.md index a0cda29..ab16fd9 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,14 @@ This is a template project for CS4530, Software Engineering at Northeastern. ## Vite+Express Full-stack React Application -This project has two parts: - -1. A minimal Express transcript API for a very simple transcript server -2. A Vite frontend with code that calls that server (this lives in the - `./frontend` directory) +This project has three parts, which together form an +[npm workspaces](https://docs.npmjs.com/cli/v8/using-npm/workspaces) project. + +1. A minimal Express transcript API for a very simple transcript server (in + the `./server` directory) +2. A Vite frontend with code that calls that server (in the `./frontend` + directory) +3. Shared Zod validation and type definitions (in the `./shared` directory) The way this project runs in "production mode" versus "development mode" is very different. diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..7a25cef --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,32 @@ +{ + "name": "@cs4530-workspaces/frontend", + "version": "1.0.0", + "description": "CS4530 Template", + "type": "module", + "scripts": { + "check": "tsc", + "lint": "eslint .", + "prettier": "prettier --ignore-path ../.gitignore --check .", + "prettier:fix": "prettier --ignore-path ../.gitignore --write .", + "test": "playwright test", + "dev": "vite", + "build": "tsc -b && vite build", + "playwright": "playwright test --ui" + }, + "author": "Rob Simmons", + "dependencies": { + "@cs4530-workspaces/shared": "^1.0.0", + "express": "^5.2.1", + "react": "^19.2.3", + "react-dom": "^19.2.3", + "zod": "^4.3.6" + }, + "devDependencies": { + "@playwright/test": "^1.58.1", + "@types/react": "^19.2.13", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "vite": "^8.0.0", + "vitest": "^4.0.18" + } +} diff --git a/playwright.config.mjs b/frontend/playwright.config.mjs similarity index 90% rename from playwright.config.mjs rename to frontend/playwright.config.mjs index fc1d6bf..3b0e608 100644 --- a/playwright.config.mjs +++ b/frontend/playwright.config.mjs @@ -3,7 +3,7 @@ import { defineConfig, devices } from "@playwright/test"; /* global process */ // TODO: is there a better way to avoid making ESLint angry? export default defineConfig({ // Where the tests live, relative to this file - testDir: "./frontend/tests/e2e", + testDir: "./tests/e2e", // Fail the build on CI if you accidentally left test.only in the source code. forbidOnly: !!process.env.CI, @@ -31,13 +31,15 @@ export default defineConfig({ webServer: [ { name: "Frontend", - command: "npm run dev:frontend", + cwd: "..", + command: "npm run dev -w=frontend", reuseExistingServer: !process.env.CI, url: "http://localhost:5173", }, { name: "Server", - command: "npm run dev:server", + cwd: "..", + command: "npm run dev -w=server", reuseExistingServer: !process.env.CI, url: "http://localhost:3000", }, diff --git a/frontend/src/service.ts b/frontend/src/service.ts index 822a736..c935b37 100644 --- a/frontend/src/service.ts +++ b/frontend/src/service.ts @@ -1,4 +1,13 @@ import { z } from "zod"; +import { + zAddGradeResponse, + zAddStudentResponse, + zError, + zGetTranscriptResponse, + type AddGradeResponse, + type AddStudentResponse, + type GetTranscriptResponse, +} from "@cs4530-workspaces/shared"; export class ServiceError extends Error { constructor(message: string) { @@ -6,9 +15,6 @@ export class ServiceError extends Error { } } -const zError = z.object({ error: z.string() }); - -const zAddStudentResponse = z.object({ studentID: z.int() }); /** * Validate inputs and call the `addStudent` api * @@ -20,7 +26,7 @@ const zAddStudentResponse = z.object({ studentID: z.int() }); export async function addStudent( password: string, studentName: string, -): Promise> { +): Promise { if (studentName === "") throw new ServiceError("Student name must be non-empty"); const response = await fetch("/api/addStudent", { @@ -36,7 +42,6 @@ export async function addStudent( return data; } -const zAddGradeResponse = z.object({ success: z.literal(true) }); /** * Validate inputs and call the `addGrade` api * @@ -52,7 +57,7 @@ export async function addGrade( studentIDStr: string, courseName: string, courseGradeStr: string, -): Promise> { +): Promise { const studentID = parseInt(studentIDStr); if (isNaN(studentID) || `${studentID}` !== studentIDStr || studentID < 0) { throw new ServiceError("Student ID is invalid"); @@ -87,17 +92,6 @@ export async function addGrade( return data; } -const zGetTranscriptResponse = z.union([ - z.object({ success: z.literal(false) }), - z.object({ - success: z.literal(true), - transcript: z.object({ - student: z.object({ studentID: z.int(), studentName: z.string() }), - grades: z.array(z.object({ course: z.string(), grade: z.number() })), - }), - }), -]); - /** * Validate inputs and call the `getTranscript` API * @@ -109,7 +103,7 @@ const zGetTranscriptResponse = z.union([ export async function getTranscript( password: string, studentIDStr: string, -): Promise> { +): Promise { const studentID = parseInt(studentIDStr); if (isNaN(studentID) || `${studentID}` !== studentIDStr || studentID < 0) { throw new ServiceError("Student ID is invalid"); diff --git a/frontend/vite.config.mjs b/frontend/vite.config.mjs index 8e407dc..bd95efd 100644 --- a/frontend/vite.config.mjs +++ b/frontend/vite.config.mjs @@ -1,9 +1,15 @@ import { defineConfig } from "vitest/config"; import react from "@vitejs/plugin-react"; +// Must match the port the server creates +const DEV_BACKEND_PORT = 3000; + +// https://vite.dev/config/ export default defineConfig({ plugins: [react()], server: { - proxy: { "/api": `http://localhost:3000` }, + proxy: { + "/api": `http://localhost:${DEV_BACKEND_PORT}`, + }, }, }); diff --git a/package-lock.json b/package-lock.json index 122afcb..662d107 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,13 +1,26 @@ { - "name": "cs4530-fullstack", + "name": "cs4530-workspaces", "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "cs4530-fullstack", + "name": "cs4530-workspaces", + "version": "1.0.0", + "workspaces": [ + "frontend", + "server", + "shared" + ], + "devDependencies": { + "concurrently": "^9.2.1" + } + }, + "frontend": { + "name": "@cs4530-workspaces/frontend", "version": "1.0.0", "dependencies": { + "@cs4530-workspaces/shared": "^1.0.0", "express": "^5.2.1", "react": "^19.2.3", "react-dom": "^19.2.3", @@ -15,28 +28,11 @@ }, "devDependencies": { "@playwright/test": "^1.58.1", - "@types/express": "^5.0.6", - "@types/react": "^19.2.9", + "@types/react": "^19.2.13", "@types/react-dom": "^19.2.3", - "@types/supertest": "^7.2.0", "@vitejs/plugin-react": "^6.0.1", - "@vitest/coverage-v8": "^4.0.18", - "concurrently": "^9.2.1", - "eslint": "^9.39.2", - "eslint-config-prettier": "^10.1.8", - "eslint-import-resolver-typescript": "^4.4.4", - "eslint-plugin-import": "^2.32.0", - "eslint-plugin-prettier": "^5.5.4", - "eslint-plugin-react-hooks": "^7.0.1", - "eslint-plugin-react-refresh": "^0.5.0", - "npm-run-all2": "^8.0.4", - "playwright": "^1.58.1", - "prettier": "^3.7.4", - "supertest": "^7.2.2", - "typescript": "^6.0.2", - "typescript-eslint": "^8.57.3", "vite": "^8.0.0", - "vitest": "^4.0.17" + "vitest": "^4.0.18" } }, "node_modules/@babel/code-frame": { @@ -297,6 +293,18 @@ "node": ">=18" } }, + "node_modules/@cs4530-workspaces/frontend": { + "resolved": "frontend", + "link": true + }, + "node_modules/@cs4530-workspaces/server": { + "resolved": "server", + "link": true + }, + "node_modules/@cs4530-workspaces/shared": { + "resolved": "shared", + "link": true + }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", @@ -1792,7 +1800,6 @@ "integrity": "sha512-38C0/Ddb7HcRG0Z4/DUem8x57d2p9jYgp18mkaYswEOQBGsI1CG4f/hjm0ZCeaJfWhSZ4k7jgs29V1Zom7Ki9A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.1.5", @@ -4609,16 +4616,6 @@ "dev": true, "license": "MIT" }, - "node_modules/json-parse-even-better-errors": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-4.0.0.tgz", - "integrity": "sha512-lR4MXjGNgkJc7tkQ97kb2nuEMnNCyU//XYVH0MKTGcXEiSudQ5MKGKen3C5QubYy0vmq+JGitUg92uuywGEwIA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -5033,15 +5030,6 @@ "node": ">= 0.8" } }, - "node_modules/memorystream": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/memorystream/-/memorystream-0.3.1.tgz", - "integrity": "sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==", - "dev": true, - "engines": { - "node": ">= 0.10.0" - } - }, "node_modules/merge-descriptors": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", @@ -5208,82 +5196,6 @@ "dev": true, "license": "MIT" }, - "node_modules/npm-normalize-package-bin": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-4.0.0.tgz", - "integrity": "sha512-TZKxPvItzai9kN9H/TkmCtx/ZN/hvr3vUycjlfmH0ootY9yFBzNOpiXAdIn1Iteqsvk4lQn6B5PTrt+n6h8k/w==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm-run-all2": { - "version": "8.0.4", - "resolved": "https://registry.npmjs.org/npm-run-all2/-/npm-run-all2-8.0.4.tgz", - "integrity": "sha512-wdbB5My48XKp2ZfJUlhnLVihzeuA1hgBnqB2J9ahV77wLS+/YAJAlN8I+X3DIFIPZ3m5L7nplmlbhNiFDmXRDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "cross-spawn": "^7.0.6", - "memorystream": "^0.3.1", - "picomatch": "^4.0.2", - "pidtree": "^0.6.0", - "read-package-json-fast": "^4.0.0", - "shell-quote": "^1.7.3", - "which": "^5.0.0" - }, - "bin": { - "npm-run-all": "bin/npm-run-all/index.js", - "npm-run-all2": "bin/npm-run-all/index.js", - "run-p": "bin/run-p/index.js", - "run-s": "bin/run-s/index.js" - }, - "engines": { - "node": "^20.5.0 || >=22.0.0", - "npm": ">= 10" - } - }, - "node_modules/npm-run-all2/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/npm-run-all2/node_modules/isexe": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", - "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, - "node_modules/npm-run-all2/node_modules/which": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", - "integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "isexe": "^3.1.1" - }, - "bin": { - "node-which": "bin/which.js" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -5575,6 +5487,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -5582,19 +5495,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/pidtree": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.6.0.tgz", - "integrity": "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==", - "dev": true, - "license": "MIT", - "bin": { - "pidtree": "bin/pidtree.js" - }, - "engines": { - "node": ">=0.10" - } - }, "node_modules/playwright": { "version": "1.59.1", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", @@ -5790,20 +5690,6 @@ "react": "^19.2.5" } }, - "node_modules/read-package-json-fast": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/read-package-json-fast/-/read-package-json-fast-4.0.0.tgz", - "integrity": "sha512-qpt8EwugBWDw2cgE2W+/3oxC+KTez2uSVR8JU9Q36TXPAGCaozfQUs59v4j4GFpWTaw0i6hAZSvOmu1J0uOEUg==", - "dev": true, - "license": "ISC", - "dependencies": { - "json-parse-even-better-errors": "^4.0.0", - "npm-normalize-package-bin": "^4.0.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -6983,7 +6869,6 @@ "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/expect": "4.1.5", "@vitest/mocker": "4.1.5", @@ -7305,6 +7190,42 @@ "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } + }, + "server": { + "name": "@cs4530-workspaces/server", + "version": "1.0.0", + "dependencies": { + "express": "^5.2.1", + "zod": "^4.3.6" + }, + "devDependencies": { + "@cs4530-workspaces/shared": "^1.0.0", + "@types/express": "^5.0.6", + "@types/supertest": "^7.2.0", + "@vitest/coverage-v8": "^4.0.18", + "supertest": "^7.2.2", + "vitest": "^4.0.18" + } + }, + "shared": { + "name": "@cs4530-workspaces/shared", + "version": "1.0.0", + "dependencies": { + "zod": "^4.3.6" + }, + "devDependencies": { + "eslint": "^9.39.2", + "eslint-config-prettier": "^10.1.8", + "eslint-import-resolver-typescript": "^4.4.4", + "eslint-plugin-import": "^2.32.0", + "eslint-plugin-prettier": "^5.5.4", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.5.0", + "prettier": "^3.7.4", + "typescript": "^6.0.2", + "typescript-eslint": "^8.50.0", + "vitest": "^4.0.17" + } } } } diff --git a/package.json b/package.json index 7b1c761..c134957 100644 --- a/package.json +++ b/package.json @@ -1,56 +1,25 @@ { - "name": "cs4530-fullstack", + "name": "cs4530-workspaces", "version": "1.0.0", "description": "CS4530 Template", - "type": "module", + "author": "Rob Simmons", + "workspaces": [ + "frontend", + "server", + "shared" + ], "scripts": { - "check": "npm-run-all check:*", - "check:frontend": "tsc -p frontend/tsconfig.json", - "check:server": "tsc", + "check": "npm run check --workspaces", "lint": "eslint .", - "lint:fix": "eslint . --fix", "prettier": "prettier --check .", "prettier:fix": "prettier --write .", - "test": "npm-run-all test:*", - "test:frontend": "playwright test", - "test:server": "vitest run --coverage", - "dev": "concurrently -n frontend,server -c blue,green \"npm run dev:frontend\" \"npm run dev:server\"", - "dev:frontend": "vite frontend", - "dev:server": "node --watch ./src/server.ts", - "build": "tsc -p frontend/tsconfig.json --noEmit && vite build frontend", - "start": "MODE=production node ./src/server.ts", - "playwright": "playwright test --ui" + "test": "npm run test --workspaces", + "dev": "concurrently -n frontend,server -c blue,green \"npm run dev -w=frontend\" \"npm run dev -w=server\"", + "build": "npm run build -w=frontend", + "start": "npm start -w=server", + "playwright": "npm run playwright -w=frontend" }, - "author": "Rob Simmons", "devDependencies": { - "@playwright/test": "^1.58.1", - "@types/express": "^5.0.6", - "@types/react": "^19.2.9", - "@types/react-dom": "^19.2.3", - "@types/supertest": "^7.2.0", - "@vitejs/plugin-react": "^6.0.1", - "@vitest/coverage-v8": "^4.0.18", - "concurrently": "^9.2.1", - "eslint": "^9.39.2", - "eslint-config-prettier": "^10.1.8", - "eslint-import-resolver-typescript": "^4.4.4", - "eslint-plugin-import": "^2.32.0", - "eslint-plugin-prettier": "^5.5.4", - "eslint-plugin-react-hooks": "^7.0.1", - "eslint-plugin-react-refresh": "^0.5.0", - "npm-run-all2": "^8.0.4", - "playwright": "^1.58.1", - "prettier": "^3.7.4", - "supertest": "^7.2.2", - "typescript": "^6.0.2", - "typescript-eslint": "^8.57.3", - "vite": "^8.0.0", - "vitest": "^4.0.17" - }, - "dependencies": { - "express": "^5.2.1", - "react": "^19.2.3", - "react-dom": "^19.2.3", - "zod": "^4.3.6" + "concurrently": "^9.2.1" } } diff --git a/server/package.json b/server/package.json new file mode 100644 index 0000000..b101452 --- /dev/null +++ b/server/package.json @@ -0,0 +1,26 @@ +{ + "name": "@cs4530-workspaces/server", + "version": "1.0.0", + "type": "module", + "scripts": { + "check": "tsc", + "lint": "eslint .", + "prettier": "prettier --ignore-path ../.gitignore --check . ", + "prettier:fix": "prettier --ignore-path ../.gitignore --write .", + "test": "vitest run --coverage", + "dev": "node --watch ./src/server.ts", + "start": "MODE=production node ./src/server.ts" + }, + "devDependencies": { + "@cs4530-workspaces/shared": "^1.0.0", + "@types/express": "^5.0.6", + "@types/supertest": "^7.2.0", + "@vitest/coverage-v8": "^4.0.18", + "supertest": "^7.2.2", + "vitest": "^4.0.18" + }, + "dependencies": { + "express": "^5.2.1", + "zod": "^4.3.6" + } +} diff --git a/src/api.spec.ts b/server/src/api.spec.ts similarity index 100% rename from src/api.spec.ts rename to server/src/api.spec.ts diff --git a/src/app.ts b/server/src/app.ts similarity index 97% rename from src/app.ts rename to server/src/app.ts index 4c610ad..5057f91 100644 --- a/src/app.ts +++ b/server/src/app.ts @@ -2,7 +2,7 @@ import express from "express"; import { z } from "zod"; import { checkPassword } from "./auth.service.ts"; import { TranscriptDB } from "./transcript.service.ts"; -import type { Transcript } from "./types.ts"; +import type { Transcript } from "@cs4530-workspaces/shared"; export const app = express(); app.use(express.json()); diff --git a/src/auth.service.ts b/server/src/auth.service.ts similarity index 100% rename from src/auth.service.ts rename to server/src/auth.service.ts diff --git a/src/server.ts b/server/src/server.ts similarity index 88% rename from src/server.ts rename to server/src/server.ts index 6de0f7b..b4f6fde 100644 --- a/src/server.ts +++ b/server/src/server.ts @@ -17,9 +17,9 @@ import express from "express"; // matter what you do. if (process.env.MODE === "production") { // In production mode, we want to serve the frontend code from Express - app.use(express.static(path.join(import.meta.dirname, "../frontend/dist"))); + app.use(express.static(path.join(import.meta.dirname, "../../frontend/dist"))); app.get(/(.*)/, (req, res) => - res.sendFile(path.join(import.meta.dirname, "../frontend/dist/index.html")), + res.sendFile(path.join(import.meta.dirname, "../../frontend/dist/index.html")), ); } else { app.get("/", (req, res) => { diff --git a/src/transcript.service.spec.ts b/server/src/transcript.service.spec.ts similarity index 100% rename from src/transcript.service.spec.ts rename to server/src/transcript.service.spec.ts diff --git a/src/transcript.service.ts b/server/src/transcript.service.ts similarity index 95% rename from src/transcript.service.ts rename to server/src/transcript.service.ts index 55ec386..da0d5d2 100644 --- a/src/transcript.service.ts +++ b/server/src/transcript.service.ts @@ -1,4 +1,9 @@ -import { type StudentID, type Student, type Course, type Transcript } from "./types.ts"; +import { + type StudentID, + type Student, + type Course, + type Transcript, +} from "@cs4530-workspaces/shared"; export class TranscriptDB { /** diff --git a/tsconfig.json b/server/tsconfig.json similarity index 83% rename from tsconfig.json rename to server/tsconfig.json index c788434..2b9db53 100644 --- a/tsconfig.json +++ b/server/tsconfig.json @@ -15,7 +15,5 @@ /* Linting */ "noFallthroughCasesInSwitch": true, "noImplicitReturns": true - }, - "include": ["./**/*.ts"], - "exclude": ["./node_modules", "./client", "./frontend"] + } } diff --git a/shared/package.json b/shared/package.json new file mode 100644 index 0000000..bc7f0ec --- /dev/null +++ b/shared/package.json @@ -0,0 +1,29 @@ +{ + "name": "@cs4530-workspaces/shared", + "version": "1.0.0", + "type": "module", + "main": "shared.ts", + "scripts": { + "check": "tsc", + "lint": "eslint .", + "prettier": "prettier --ignore-path ../.gitignore --check .", + "prettier:fix": "prettier --ignore-path ../.gitignore --write .", + "test": "vitest run" + }, + "devDependencies": { + "eslint": "^9.39.2", + "eslint-config-prettier": "^10.1.8", + "eslint-import-resolver-typescript": "^4.4.4", + "eslint-plugin-import": "^2.32.0", + "eslint-plugin-prettier": "^5.5.4", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.5.0", + "prettier": "^3.7.4", + "typescript": "^6.0.2", + "typescript-eslint": "^8.50.0", + "vitest": "^4.0.17" + }, + "dependencies": { + "zod": "^4.3.6" + } +} diff --git a/shared/shared.spec.ts b/shared/shared.spec.ts new file mode 100644 index 0000000..50afa7e --- /dev/null +++ b/shared/shared.spec.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { zTranscript } from "./shared.ts"; + +describe("the Transcript validator", () => { + it("forbids omission of the grades element", () => { + const response = zTranscript.safeParse({ + student: { studentID: 4, studentName: "Naomi" }, + }); + expect(response.success).toBe(false); + }); + + it("forbids omission of the student element", () => { + const response = zTranscript.safeParse({ + grades: [], + }); + expect(response.success).toBe(false); + }); + + it("accepts transcripts with no grades", () => { + const response = zTranscript.safeParse({ + student: { studentID: 4, studentName: "Naomi" }, + grades: [], + }); + expect(response.success).toBe(true); + }); + + it("accepts transcripts with duplicate grades", () => { + const response = zTranscript.safeParse({ + student: { studentID: 4, studentName: "Naomi" }, + grades: [ + { course: "Math", grade: 4 }, + { course: "Math", grade: 3 }, + ], + }); + expect(response.success).toBe(true); + }); +}); diff --git a/shared/shared.ts b/shared/shared.ts new file mode 100644 index 0000000..a717c57 --- /dev/null +++ b/shared/shared.ts @@ -0,0 +1,41 @@ +import { z } from "zod"; + +/** Validator for error responses from the API*/ +export const zError = z.object({ error: z.string() }); + +/** Validator for non-error POST `/api/addStudent` responses */ +export const zAddStudentResponse = z.object({ studentID: z.int() }); +/** Response type for POST /api/addStudent */ +export type AddStudentResponse = z.infer; + +/** Validator for POST `/api/addGrade` responses */ +export const zAddGradeResponse = z.object({ success: z.literal(true) }); +/** Response type for POST `/api/addGrade` */ +export type AddGradeResponse = z.infer; + +/** Validator for student records */ +export const zStudent = z.object({ studentID: z.int(), studentName: z.string() }); +export type Student = z.infer; + +/** Validator for individual course grade records */ +export const zCourseGrade = z.object({ course: z.string(), grade: z.number() }); +export type CourseGrade = z.infer; + +/** Validator for transcripts */ +export const zTranscript = z.object({ student: zStudent, grades: z.array(zCourseGrade) }); +/** Type of student transcripts */ +export type Transcript = z.infer; + +/** Validator for POST `/api/getTranscript` responses */ +export const zGetTranscriptResponse = z.union([ + z.object({ success: z.literal(false) }), + z.object({ success: z.literal(true), transcript: zTranscript }), +]); +/** Response type for POST `/api/zGetTranscript` */ +export type GetTranscriptResponse = z.infer; + +// Aliases for simple types + +export type Course = string; +export type StudentID = number; +export type StudentName = string; diff --git a/shared/tsconfig.json b/shared/tsconfig.json new file mode 100644 index 0000000..2b9db53 --- /dev/null +++ b/shared/tsconfig.json @@ -0,0 +1,19 @@ +/* Visit https://www.typescriptlang.org/tsconfig/ to learn about this file */ +{ + "compilerOptions": { + /* Basic configuration */ + "lib": ["ES2024"], + "module": "node18", + "skipLibCheck": true, + "noEmit": true, + + /* Support the use of type stripping in Node */ + "allowImportingTsExtensions": true, + "erasableSyntaxOnly": true, + "verbatimModuleSyntax": true, + + /* Linting */ + "noFallthroughCasesInSwitch": true, + "noImplicitReturns": true + } +} diff --git a/src/types.ts b/src/types.ts deleted file mode 100644 index b2b9f26..0000000 --- a/src/types.ts +++ /dev/null @@ -1,8 +0,0 @@ -// types.ts - types for the transcript service - -export type Course = string; -export type StudentID = number; -export type StudentName = string; -export type Student = { studentID: StudentID; studentName: StudentName }; -export type CourseGrade = { course: Course; grade: number }; -export type Transcript = { student: Student; grades: CourseGrade[] }; diff --git a/vitest.config.mjs b/vitest.config.mjs deleted file mode 100644 index 0aaff1a..0000000 --- a/vitest.config.mjs +++ /dev/null @@ -1,7 +0,0 @@ -import { defineConfig } from "vitest/config"; - -export default defineConfig({ - test: { - exclude: ["**/node_modules/**", "**/.git/**", "./{client,frontend}/**"], - }, -});