diff --git a/.env.example b/.env.example index 269877d..2a49f77 100644 --- a/.env.example +++ b/.env.example @@ -5,7 +5,8 @@ JWT_DEFAULT_DURATION=3600 # 1 hour JWT_REFRESH_DURATION=2592000 # 30 days # Comma-separated exact origins allowed for credentialed CORS. -CORS_ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000,http://localhost:5173,http://127.0.0.1:5173 +# Include http://localhost:3001 (and 127.0.0.1) when @studiqo/web runs on 3001 with API on PORT=3000. +CORS_ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000,http://localhost:3001,http://127.0.0.1:3001,http://localhost:5173,http://127.0.0.1:5173 # Comma-separated hostname suffixes allowed for credentialed CORS. CORS_ALLOWED_ORIGIN_SUFFIXES=.studiqo.io diff --git a/.gitignore b/.gitignore index 2c0eb98..b3823c4 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ coverage postman/ .cursor/ apps/web/.next +apps/web/.env.local diff --git a/apps/api/src/config/config.ts b/apps/api/src/config/config.ts index 51ad90e..9e04650 100644 --- a/apps/api/src/config/config.ts +++ b/apps/api/src/config/config.ts @@ -198,6 +198,8 @@ export const config: Config = { corsAllowedOrigins: csvEnvOrDefault("CORS_ALLOWED_ORIGINS", [ "http://localhost:3000", "http://127.0.0.1:3000", + "http://localhost:3001", + "http://127.0.0.1:3001", "http://localhost:5173", "http://127.0.0.1:5173", ]), diff --git a/apps/web/.env.example b/apps/web/.env.example new file mode 100644 index 0000000..1a7be82 --- /dev/null +++ b/apps/web/.env.example @@ -0,0 +1,12 @@ +# Studiqo HTTP API base URL (must include /api/v1). +# Local default matches apps/api when PORT=3000. +NEXT_PUBLIC_API_BASE_URL=http://localhost:3000/api/v1 + +# `npm run dev -w apps/web` uses port 3001 (see apps/web/package.json) so the API can use 3000. + +# Local dev: the API and Next.js both default to port 3000 — pick one: +# - Run API on 3000 and Next with: next dev -p 3001 +# Then add http://localhost:3001 (and http://127.0.0.1:3001) to the API's +# CORS_ALLOWED_ORIGINS in .env (see repo root .env.example). +# - Or use a same-origin proxy (e.g. Next rewrites) so the browser only talks +# to one origin; verify HttpOnly refresh cookies still work for your setup. diff --git a/apps/web/app/dev-health-status.tsx b/apps/web/app/dev-health-status.tsx new file mode 100644 index 0000000..5b6895f --- /dev/null +++ b/apps/web/app/dev-health-status.tsx @@ -0,0 +1,30 @@ +"use client"; + +import { useEffect, useState } from "react"; + +import { fetchHealth } from "@/lib/api/health"; + +/** Fetches `GET /health` once in development to exercise typed OpenAPI client usage. */ +export function DevHealthStatus() { + const [line, setLine] = useState("…"); + + useEffect(() => { + if (process.env.NODE_ENV !== "development") return; + void (async () => { + try { + const { message } = await fetchHealth(); + setLine(message); + } catch (e) { + setLine(e instanceof Error ? e.message : "error"); + } + })(); + }, []); + + if (process.env.NODE_ENV !== "development") { + return null; + } + + return ( +

API health (dev): {line}

+ ); +} diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index e53180e..dedba19 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -1,9 +1,13 @@ import type { ReactNode } from "react"; +import { Providers } from "./providers"; + export default function RootLayout({ children }: { children: ReactNode }) { return ( - {children} + + {children} + ); } diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index caa41d3..f556333 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -1,8 +1,18 @@ +import { DevHealthStatus } from "./dev-health-status"; + +import { formatIsoDateTime } from "@/lib/datetime"; + +const sampleApiDateTime = "2026-04-01T14:00:00.000Z"; + export default function Page() { return ( -
-

Studiqo Frontend

-

Next.js workspace scaffold complete.

+
+

Studiqo

+

Next.js workspace scaffold with Phase 0 providers and API client.

+

+ Sample formatted API date-time: {formatIsoDateTime(sampleApiDateTime)} +

+
); } diff --git a/apps/web/app/providers.tsx b/apps/web/app/providers.tsx new file mode 100644 index 0000000..81fe5cc --- /dev/null +++ b/apps/web/app/providers.tsx @@ -0,0 +1,16 @@ +"use client"; + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { useState, type ReactNode } from "react"; + +import { SessionProvider } from "@/lib/auth/session"; + +export function Providers({ children }: { children: ReactNode }) { + const [queryClient] = useState(() => new QueryClient()); + + return ( + + {children} + + ); +} diff --git a/apps/web/lib/api/health.ts b/apps/web/lib/api/health.ts new file mode 100644 index 0000000..96c7219 --- /dev/null +++ b/apps/web/lib/api/health.ts @@ -0,0 +1,11 @@ +import { createStudiqoClient } from "@studiqo/api-client/client"; +import { unwrapStudiqoResponse } from "@studiqo/api-client/errors"; + +import { getPublicApiBaseUrl } from "@/lib/env"; + +/** Typed smoke path: `GET /health` (OpenAPI `getHealth`). */ +export async function fetchHealth() { + const client = createStudiqoClient(getPublicApiBaseUrl()); + const result = await client.GET("/health"); + return unwrapStudiqoResponse(result); +} diff --git a/apps/web/lib/auth/session.tsx b/apps/web/lib/auth/session.tsx new file mode 100644 index 0000000..031048b --- /dev/null +++ b/apps/web/lib/auth/session.tsx @@ -0,0 +1,92 @@ +"use client"; + +import { createStudiqoClient } from "@studiqo/api-client/client"; +import { + unwrapStudiqoResponse, + unwrapStudiqoVoid, +} from "@studiqo/api-client/errors"; +import { + createContext, + useCallback, + useContext, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; + +import { getPublicApiBaseUrl } from "@/lib/env"; + +export type SessionContextValue = { + accessToken: string | null; + setAccessToken: (token: string | null) => void; + clearSession: () => void; + refreshAccessToken: () => Promise; + logout: () => Promise; +}; + +const SessionContext = createContext(null); + +export function SessionProvider({ children }: { children: ReactNode }) { + const [accessToken, setAccessTokenState] = useState(null); + const tokenRef = useRef(null); + + const setAccessToken = useCallback((token: string | null) => { + tokenRef.current = token; + setAccessTokenState(token); + }, []); + + const clearSession = useCallback(() => { + tokenRef.current = null; + setAccessTokenState(null); + }, []); + + const client = useMemo( + () => + createStudiqoClient(getPublicApiBaseUrl(), { + getAccessToken: () => tokenRef.current, + }), + [], + ); + + const refreshAccessToken = useCallback(async () => { + const result = await client.POST("/auth/refresh"); + const data = unwrapStudiqoResponse(result); + setAccessToken(data.token); + }, [client, setAccessToken]); + + const logout = useCallback(async () => { + const result = await client.POST("/auth/logout"); + unwrapStudiqoVoid(result); + setAccessToken(null); + }, [client, setAccessToken]); + + const value = useMemo( + () => ({ + accessToken, + setAccessToken, + clearSession, + refreshAccessToken, + logout, + }), + [ + accessToken, + setAccessToken, + clearSession, + refreshAccessToken, + logout, + ], + ); + + return ( + {children} + ); +} + +export function useSession(): SessionContextValue { + const ctx = useContext(SessionContext); + if (!ctx) { + throw new Error("useSession must be used within SessionProvider"); + } + return ctx; +} diff --git a/apps/web/lib/datetime.ts b/apps/web/lib/datetime.ts new file mode 100644 index 0000000..d056ebe --- /dev/null +++ b/apps/web/lib/datetime.ts @@ -0,0 +1,34 @@ +/** + * API date-times are ISO 8601 in UTC (e.g. `2026-04-01T14:00:00.000Z`). Display uses `timeZone` + * (default `undefined` = runtime local zone). + */ + +export function parseIsoDateTime(iso: string): Date { + const d = new Date(iso); + if (Number.isNaN(d.getTime())) { + throw new RangeError(`Invalid ISO date-time: ${iso}`); + } + return d; +} + +export function formatIsoDateTime( + iso: string, + options?: { + /** IANA zone (e.g. `Europe/London`); omit for the user's local timezone */ + timeZone?: string; + dateStyle?: Intl.DateTimeFormatOptions["dateStyle"]; + timeStyle?: Intl.DateTimeFormatOptions["timeStyle"]; + }, +): string { + const { + timeZone, + dateStyle = "medium", + timeStyle = "short", + } = options ?? {}; + const d = parseIsoDateTime(iso); + return new Intl.DateTimeFormat(undefined, { + dateStyle, + timeStyle, + timeZone, + }).format(d); +} diff --git a/apps/web/lib/env.ts b/apps/web/lib/env.ts new file mode 100644 index 0000000..43eb9af --- /dev/null +++ b/apps/web/lib/env.ts @@ -0,0 +1,6 @@ +/** Public API base URL (includes `/api/v1`). See `apps/web/.env.example`. */ +export function getPublicApiBaseUrl(): string { + const raw = + process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:3000/api/v1"; + return raw.replace(/\/$/, ""); +} diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts index c4b7818..9edff1c 100644 --- a/apps/web/next-env.d.ts +++ b/apps/web/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/dev/types/routes.d.ts"; +import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index cb651cd..5eb7294 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -1,5 +1,7 @@ import type { NextConfig } from "next"; -const nextConfig: NextConfig = {}; +const nextConfig: NextConfig = { + transpilePackages: ["@studiqo/api-client"], +}; export default nextConfig; diff --git a/apps/web/package.json b/apps/web/package.json index e19f36b..1c73549 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -3,19 +3,20 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "next dev", + "dev": "next dev -p 3002", "build": "next build", "start": "next start", "lint": "eslint ." }, "dependencies": { + "@studiqo/api-client": "*", + "@tanstack/react-query": "^5.96.2", "next": "16.2.2", "react": "19.1.0", "react-dom": "19.1.0" }, "devDependencies": { "@eslint/js": "^10.0.1", - "typescript": "^5.9.3", "@types/node": "^25.5.0", "@types/react": "^19.1.12", "@types/react-dom": "^19.1.9", @@ -23,6 +24,7 @@ "eslint-config-next": "16.2.2", "eslint-config-prettier": "^10.1.8", "globals": "^17.4.0", + "typescript": "^5.9.3", "typescript-eslint": "^8.57.2" } } diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index eb7448e..d289a86 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -24,7 +24,12 @@ ], "types": [ "node" - ] + ], + "paths": { + "@/*": [ + "./*" + ] + } }, "include": [ "next-env.d.ts", diff --git a/package-lock.json b/package-lock.json index eb8c19a..99d98b0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -114,6 +114,7 @@ "name": "@studiqo/web", "version": "0.1.0", "dependencies": { + "@tanstack/react-query": "^5.96.2", "next": "16.2.2", "react": "19.1.0", "react-dom": "19.1.0" @@ -4235,6 +4236,32 @@ "tslib": "^2.8.0" } }, + "node_modules/@tanstack/query-core": { + "version": "5.96.2", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.96.2.tgz", + "integrity": "sha512-hzI6cTVh4KNRk8UtoIBS7Lv9g6BnJPXvBKsvYH1aGWvv0347jT3BnSvztOE+kD76XGvZnRC/t6qdW1CaIfwCeA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.96.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.96.2.tgz", + "integrity": "sha512-sYyzzJT4G0g02azzJ8o55VFFV31XvFpdUpG+unxS0vSaYsJnSPKGoI6WdPwUucJL1wpgGfwfmntNX/Ub1uOViA==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.96.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", @@ -10591,6 +10618,15 @@ "wrappy": "1" } }, + "node_modules/openapi-fetch": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/openapi-fetch/-/openapi-fetch-0.17.0.tgz", + "integrity": "sha512-PsbZR1wAPcG91eEthKhN+Zn92FMHxv+/faECIwjXdxfTODGSGegYv0sc1Olz+HYPvKOuoXfp+0pA2XVt2cI0Ig==", + "license": "MIT", + "dependencies": { + "openapi-typescript-helpers": "^0.1.0" + } + }, "node_modules/openapi-sampler": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/openapi-sampler/-/openapi-sampler-1.7.2.tgz", @@ -10624,6 +10660,12 @@ "typescript": "^5.x" } }, + "node_modules/openapi-typescript-helpers": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/openapi-typescript-helpers/-/openapi-typescript-helpers-0.1.0.tgz", + "integrity": "sha512-OKTGPthhivLw/fHz6c3OPtg72vi86qaMlqbJuVJ23qOvQ+53uw1n7HdmkJFibloF7QEjDrDkzJiOJuockM/ljw==", + "license": "MIT" + }, "node_modules/openapi-typescript/node_modules/@redocly/ajv": { "version": "8.11.2", "resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz", @@ -13684,6 +13726,9 @@ "packages/api-client": { "name": "@studiqo/api-client", "version": "0.1.0", + "dependencies": { + "openapi-fetch": "^0.17.0" + }, "devDependencies": { "openapi-typescript": "^7.10.1", "typescript": "^5.9.3" diff --git a/packages/api-client/README.md b/packages/api-client/README.md index 3c02a00..421befa 100644 --- a/packages/api-client/README.md +++ b/packages/api-client/README.md @@ -4,6 +4,10 @@ Workspace package for generated API types/client derived from OpenAPI. ## Generate +After changing [`apps/api/docs/openapi/openapi.yaml`](../apps/api/docs/openapi/openapi.yaml): + ```bash npm run generate -w packages/api-client ``` + +Imports: `@studiqo/api-client/client`, `@studiqo/api-client/errors`, or `@studiqo/api-client/generated` for types. diff --git a/packages/api-client/package.json b/packages/api-client/package.json index ff0db71..dbf9ef4 100644 --- a/packages/api-client/package.json +++ b/packages/api-client/package.json @@ -4,7 +4,10 @@ "private": true, "type": "module", "exports": { - ".": "./src/index.ts" + ".": "./src/index.ts", + "./client": "./src/create-client.ts", + "./errors": "./src/errors.ts", + "./generated": "./src/generated.ts" }, "scripts": { "generate": "openapi-typescript ../../apps/api/docs/openapi/openapi.yaml -o src/generated.ts" @@ -12,5 +15,8 @@ "devDependencies": { "openapi-typescript": "^7.10.1", "typescript": "^5.9.3" + }, + "dependencies": { + "openapi-fetch": "^0.17.0" } } diff --git a/packages/api-client/src/create-client.ts b/packages/api-client/src/create-client.ts new file mode 100644 index 0000000..4d1ddb9 --- /dev/null +++ b/packages/api-client/src/create-client.ts @@ -0,0 +1,38 @@ +import createClient from "openapi-fetch"; + +import type { paths } from "./generated"; + +export type StudiqoClientOptions = { + getAccessToken?: () => string | null; +}; + +export type StudiqoClient = ReturnType; + +/** + * Typed OpenAPI client: `credentials: "include"` for refresh cookies; optional Bearer from `getAccessToken`. + */ +export function createStudiqoClient( + baseUrl: string, + options: StudiqoClientOptions = {}, +) { + const getAccessToken = options.getAccessToken ?? (() => null); + + const client = createClient({ + baseUrl, + credentials: "include", + }); + + client.use({ + onRequest({ request }) { + const token = getAccessToken(); + if (!token) { + return; + } + const headers = new Headers(request.headers); + headers.set("Authorization", `Bearer ${token}`); + return new Request(request, { headers }); + }, + }); + + return client; +} diff --git a/packages/api-client/src/errors.ts b/packages/api-client/src/errors.ts new file mode 100644 index 0000000..9e1482d --- /dev/null +++ b/packages/api-client/src/errors.ts @@ -0,0 +1,65 @@ +/** Normalized API error matching OpenAPI `Error` (`{ error: string }`) when present. */ + +export class StudiqoApiError extends Error { + override readonly name = "StudiqoApiError"; + + constructor( + readonly status: number, + message: string, + readonly body?: unknown, + ) { + super(message); + } +} + +export function isStudiqoApiError(value: unknown): value is StudiqoApiError { + return value instanceof StudiqoApiError; +} + +function messageFromErrorField(error: unknown, fallback: string): string { + if ( + error && + typeof error === "object" && + "error" in error && + typeof (error as { error: unknown }).error === "string" + ) { + return (error as { error: string }).error; + } + if (typeof error === "string" && error.length > 0) { + return error; + } + return fallback || "Request failed"; +} + +/** Throws {@link StudiqoApiError} when `response` is not OK (openapi-fetch already consumed the body into `error`). */ +export function throwIfStudiqoError(result: { + data?: unknown; + error?: unknown; + response: Response; +}): void { + if (result.response.ok) { + return; + } + const message = messageFromErrorField( + result.error, + result.response.statusText, + ); + throw new StudiqoApiError(result.response.status, message, result.error); +} + +export function unwrapStudiqoResponse(result: { + data?: T; + error?: unknown; + response: Response; +}): T { + throwIfStudiqoError(result); + return result.data as T; +} + +export function unwrapStudiqoVoid(result: { + data?: unknown; + error?: unknown; + response: Response; +}): void { + throwIfStudiqoError(result); +} diff --git a/packages/api-client/src/generated.ts b/packages/api-client/src/generated.ts new file mode 100644 index 0000000..a66afb9 --- /dev/null +++ b/packages/api-client/src/generated.ts @@ -0,0 +1,2733 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + "/health": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Health check */ + get: operations["getHealth"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/auth/register": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Register a new user + * @description Creates a user account only. Organization membership is not assigned by this endpoint. + * Parent onboarding is handled through invitation acceptance endpoints. + */ + post: operations["registerUser"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/auth/login": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Log in + * @description Returns an access token and sets an HttpOnly refresh-token cookie. + * Browser clients should use `credentials: include` for refresh/logout requests. + */ + post: operations["loginUser"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/auth/me": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Current user (from access JWT) */ + get: operations["getMe"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/auth/refresh": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Issue a new access JWT + * @description Primary flow: send the HttpOnly refresh-token cookie set by `/auth/login`. + * Compatibility flow (deprecated): send `Authorization: Bearer `. + * The refresh token rotates on success and a new refresh cookie is set. + */ + post: operations["refreshToken"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/auth/active-organization": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Switch active organization context + * @description Issues a new access token scoped to the requested organization. + * The caller must be a member of the organization unless they are superadmin. + */ + post: operations["setActiveOrganization"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/auth/logout": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Revoke refresh token + * @description Primary flow: use the HttpOnly refresh-token cookie set by `/auth/login`. + * Compatibility flow (deprecated): send `Authorization: Bearer `. + */ + post: operations["logoutUser"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/organizations": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List caller organizations + * @description Returns organizations visible to the caller. + * Superadmins receive all organizations. + */ + get: operations["listMyOrganizations"]; + put?: never; + /** Create organization */ + post: operations["createOrganization"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/organizations/{organizationId}/members": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List organization members */ + get: operations["listOrganizationMembers"]; + put?: never; + /** Add or update organization membership */ + post: operations["addOrganizationMember"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/organizations/{organizationId}/invites": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List organization invitations + * @description Organization admin only. Returns invitations ordered by most recent first. + */ + get: operations["listOrganizationInvitations"]; + put?: never; + /** + * Invite a parent by email + * @description Organization admin only. Creates a parent invitation and sends an email. + */ + post: operations["createOrganizationInvitation"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/organizations/{organizationId}/invites/{invitationId}/resend": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Resend an invitation + * @description Organization admin only. Issues a fresh invitation token, sends a new email, + * and revokes the previous invitation. + */ + post: operations["resendOrganizationInvitation"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/organizations/{organizationId}/invites/{invitationId}/revoke": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Revoke an invitation + * @description Organization admin only. Revokes a pending invitation. + */ + post: operations["revokeOrganizationInvitation"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/invites/{token}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get invitation details */ + get: operations["getInvitationDetails"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/invites/{token}/accept": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Accept invitation and create account */ + post: operations["acceptInvitation"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/users/{userId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Update user + * @description Organization admin only. At least one of `email` or `role` must be provided. `role` updates membership role in the active organization. + */ + put: operations["updateUser"]; + post?: never; + /** + * Delete user + * @description Admin only. + */ + delete: operations["deleteUser"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/subjects": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List subjects for active organization + * @description Returns global subjects and subjects scoped to the active organization. + */ + get: operations["listSubjects"]; + put?: never; + /** + * Create subject + * @description Admin only. + */ + post: operations["createSubject"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/students": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List students + * @description Admins see all students. Parents see their children. Tutors see students assigned to them. + */ + get: operations["listStudents"]; + put?: never; + /** + * Create student + * @description Organization admin only. `parentId` must reference a user with `parent` membership in the active organization. Optional `tutorId` must reference a `tutor` membership. + */ + post: operations["createStudent"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/students/{studentId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get student by id + * @description Admin, or parent/tutor with access to this student. + */ + get: operations["getStudent"]; + /** + * Update student + * @description Admin only. At least one field required. + */ + put: operations["updateStudent"]; + post?: never; + /** + * Delete student + * @description Admin only. + */ + delete: operations["deleteStudent"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/students/{studentId}/subjects": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List subjects for a student + * @description Admin, or parent/tutor with access to this student. + */ + get: operations["getStudentSubjects"]; + put?: never; + /** + * Link student to subject + * @description Admin only. Enrolls the student in a subject (grades optional). + */ + post: operations["linkStudentSubject"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/students/{studentId}/emergency-contacts": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List emergency contacts + * @description Admin, or parent/tutor with access to this student. + */ + get: operations["listEmergencyContacts"]; + put?: never; + /** + * Create emergency contact + * @description Admin only. Maximum two contacts per student. Phone must match E.164-style rules enforced by the server. + */ + post: operations["createEmergencyContact"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/students/{studentId}/emergency-contacts/{contactId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Update emergency contact + * @description Admin only. At least one field required. + */ + put: operations["updateEmergencyContact"]; + post?: never; + /** + * Delete emergency contact + * @description Admin only. + */ + delete: operations["deleteEmergencyContact"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/lessons": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List lessons in a time range + * @description Scoped by role: admins see all (optional filters); parents see their children; tutors see their lessons and assigned students. + */ + get: operations["listLessons"]; + put?: never; + /** + * Create a lesson + * @description Admin only. + */ + post: operations["createLesson"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/lessons/{lessonId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get lesson by id */ + get: operations["getLesson"]; + /** + * Update lesson + * @description Admin only. At least one field required. + */ + put: operations["updateLesson"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/lessons/{lessonId}/complete": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Complete a lesson + * @description Admin or assigned tutor only. Idempotent when already completed. + */ + post: operations["completeLesson"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/lessons/{lessonId}/cancel": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Cancel a lesson + * @description Admin or assigned tutor (same rules as viewing the lesson). Parents cannot cancel via this endpoint. + * Idempotent if already `cancelled`. Cannot cancel a `completed` lesson. + */ + post: operations["cancelLesson"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + Error: { + error: string; + }; + UserPublic: { + /** Format: uuid */ + id: string; + /** Format: email */ + email: string; + /** @description Active-organization membership role when available. */ + role?: components["schemas"]["OrganizationMembershipRole"]; + /** Format: date-time */ + createdAt: string; + isSuperadmin: boolean; + /** Format: uuid */ + activeOrganizationId?: string; + }; + RegisterRequest: { + /** Format: email */ + email: string; + /** @description Must include upper, lower, digit, and special character (see server validation). */ + password: string; + }; + LoginRequest: { + /** Format: email */ + email: string; + password: string; + }; + LoginResponse: { + /** Format: uuid */ + id: string; + /** Format: email */ + email: string; + /** @description Active-organization membership role when available. */ + role?: components["schemas"]["OrganizationMembershipRole"]; + /** Format: date-time */ + createdAt: string; + isSuperadmin: boolean; + /** Format: uuid */ + activeOrganizationId?: string; + /** @description Access JWT */ + token: string; + /** + * @deprecated + * @description Deprecated compatibility field for legacy clients using Authorization-header refresh. + */ + refreshToken: string; + }; + RefreshTokenResponse: { + /** @description New access JWT */ + token: string; + /** + * @deprecated + * @description Deprecated compatibility field for legacy clients using Authorization-header refresh. + */ + refreshToken?: string; + }; + SetActiveOrganizationRequest: { + /** Format: uuid */ + organizationId: string; + }; + /** @enum {string} */ + OrganizationMembershipRole: "org_admin" | "tutor" | "parent"; + Organization: { + /** Format: uuid */ + id: string; + name: string; + slug: string; + /** Format: date-time */ + createdAt: string; + }; + CreateOrganizationRequest: { + name: string; + slug: string; + }; + OrganizationMembership: { + /** Format: uuid */ + organizationId: string; + /** Format: uuid */ + userId: string; + role: components["schemas"]["OrganizationMembershipRole"]; + /** Format: date-time */ + createdAt: string; + }; + AddOrganizationMemberRequest: { + /** Format: uuid */ + userId: string; + role: components["schemas"]["OrganizationMembershipRole"]; + }; + CreateOrganizationInvitationRequest: { + /** Format: email */ + email: string; + }; + OrganizationInvitation: { + /** Format: uuid */ + id: string; + /** Format: uuid */ + organizationId: string; + invitedByUserId: string | null; + /** Format: email */ + email: string; + role: components["schemas"]["OrganizationMembershipRole"]; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + expiresAt: string; + acceptedAt: string | null; + revokedAt: string | null; + }; + InvitationDetails: { + /** Format: uuid */ + organizationId: string; + organizationName: string; + organizationSlug: string; + /** Format: email */ + email: string; + role: components["schemas"]["OrganizationMembershipRole"]; + /** Format: date-time */ + expiresAt: string; + }; + AcceptInvitationRequest: { + /** @description Must include upper, lower, digit, and special character (see server validation). */ + password: string; + }; + UpdateUserRequest: { + /** Format: email */ + email?: string; + role?: components["schemas"]["OrganizationMembershipRole"]; + }; + CreateSubjectRequest: { + name: string; + organizationId?: string | null; + }; + Subject: { + /** Format: uuid */ + id: string; + name: string; + }; + Student: { + /** Format: uuid */ + id: string; + /** Format: uuid */ + organizationId: string; + /** Format: uuid */ + parentId: string; + tutorId: string | null; + firstName: string; + lastName: string; + /** Format: date-time */ + dateOfBirth: string; + }; + CreateStudentRequest: { + /** Format: uuid */ + parentId: string; + /** Format: uuid */ + tutorId?: string; + firstName: string; + lastName: string; + /** Format: date-time */ + dateOfBirth: string; + }; + UpdateStudentRequest: { + /** Format: uuid */ + parentId?: string; + /** Format: uuid */ + tutorId?: string; + firstName?: string; + lastName?: string; + /** Format: date-time */ + dateOfBirth?: string; + }; + StudentSubject: { + /** Format: uuid */ + subjectId: string; + subjectName: string; + currentGrade: string | null; + predictedGrade: string | null; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + }; + StudentSubjectLink: { + /** Format: uuid */ + studentId: string; + /** Format: uuid */ + subjectId: string; + currentGrade: string | null; + predictedGrade: string | null; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + }; + LinkStudentSubjectRequest: { + /** Format: uuid */ + subjectId: string; + currentGrade?: string; + predictedGrade?: string; + }; + EmergencyContact: { + /** Format: uuid */ + id: string; + /** Format: uuid */ + studentId: string; + name: string; + phone: string; + relationship: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + }; + CreateEmergencyContactRequest: { + name: string; + phone: string; + relationship: string; + }; + UpdateEmergencyContactRequest: { + name?: string; + phone?: string; + relationship?: string; + }; + /** @enum {string} */ + LessonStatus: "scheduled" | "completed" | "cancelled" | "no_show"; + Lesson: { + /** Format: uuid */ + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + /** Format: uuid */ + organizationId: string; + /** Format: uuid */ + studentId: string; + /** Format: uuid */ + tutorId: string; + /** Format: uuid */ + subjectId: string; + /** Format: date-time */ + startsAt: string; + /** Format: date-time */ + endsAt: string; + status: components["schemas"]["LessonStatus"]; + notes: string | null; + }; + CreateLessonRequest: { + /** Format: uuid */ + studentId: string; + /** Format: uuid */ + tutorId: string; + /** Format: uuid */ + subjectId: string; + /** Format: date-time */ + startsAt: string; + /** Format: date-time */ + endsAt: string; + }; + UpdateLessonRequest: { + /** Format: uuid */ + tutorId?: string; + /** Format: uuid */ + subjectId?: string; + /** Format: date-time */ + startsAt?: string; + /** Format: date-time */ + endsAt?: string; + notes?: string | null; + }; + }; + responses: never; + parameters: { + LessonId: string; + UserId: string; + OrganizationId: string; + StudentId: string; + ContactId: string; + InvitationId: string; + }; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export interface operations { + getHealth: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description API is running */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example API is running */ + message: string; + }; + }; + }; + }; + }; + registerUser: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["RegisterRequest"]; + }; + }; + responses: { + /** @description User created */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UserPublic"]; + }; + }; + /** @description Validation or duplicate email */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + loginUser: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["LoginRequest"]; + }; + }; + responses: { + /** @description Access token issued and refresh cookie set */ + 200: { + headers: { + /** @description HttpOnly refresh token cookie */ + "Set-Cookie"?: string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["LoginResponse"]; + }; + }; + /** @description Invalid credentials */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + getMe: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Current user profile (no tokens) */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UserPublic"]; + }; + }; + /** @description Missing or invalid access token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description User not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + refreshToken: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description New access JWT */ + 200: { + headers: { + /** @description Rotated HttpOnly refresh token cookie */ + "Set-Cookie"?: string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RefreshTokenResponse"]; + }; + }; + /** @description Missing or invalid refresh credentials */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Refresh token not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + setActiveOrganization: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SetActiveOrganizationRequest"]; + }; + }; + responses: { + /** @description New access JWT for selected organization */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RefreshTokenResponse"]; + }; + }; + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Not authenticated */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Caller cannot access requested organization */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + logoutUser: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Logged out and refresh cookie cleared */ + 204: { + headers: { + /** @description Cleared refresh token cookie */ + "Set-Cookie"?: string; + [name: string]: unknown; + }; + content?: never; + }; + /** @description Missing or invalid refresh credentials */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + listMyOrganizations: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Organizations list */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Organization"][]; + }; + }; + /** @description Not authenticated */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + createOrganization: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CreateOrganizationRequest"]; + }; + }; + responses: { + /** @description Organization created */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Organization"]; + }; + }; + /** @description Validation error or duplicate slug */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Not authenticated */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + listOrganizationMembers: { + parameters: { + query?: never; + header?: never; + path: { + organizationId: components["parameters"]["OrganizationId"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Organization memberships */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["OrganizationMembership"][]; + }; + }; + /** @description Not authenticated */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Access denied */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Organization not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + addOrganizationMember: { + parameters: { + query?: never; + header?: never; + path: { + organizationId: components["parameters"]["OrganizationId"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AddOrganizationMemberRequest"]; + }; + }; + responses: { + /** @description Membership created or updated */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["OrganizationMembership"]; + }; + }; + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Not authenticated */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Access denied */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Organization or user not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + listOrganizationInvitations: { + parameters: { + query?: never; + header?: never; + path: { + organizationId: components["parameters"]["OrganizationId"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Invitation list */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["OrganizationInvitation"][]; + }; + }; + /** @description Not authenticated */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Organization admin access required */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Organization not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + createOrganizationInvitation: { + parameters: { + query?: never; + header?: never; + path: { + organizationId: components["parameters"]["OrganizationId"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CreateOrganizationInvitationRequest"]; + }; + }; + responses: { + /** @description Invitation created */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["OrganizationInvitation"]; + }; + }; + /** @description Validation error or failed invite delivery */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Not authenticated */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Organization admin access required */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Organization not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Active invitation already exists for this email */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + resendOrganizationInvitation: { + parameters: { + query?: never; + header?: never; + path: { + organizationId: components["parameters"]["OrganizationId"]; + invitationId: components["parameters"]["InvitationId"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description New invitation created and sent */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["OrganizationInvitation"]; + }; + }; + /** @description Validation error, revoked invitation, or failed invite delivery */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Not authenticated */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Organization admin access required */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Organization or invitation not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Invitation already accepted */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + revokeOrganizationInvitation: { + parameters: { + query?: never; + header?: never; + path: { + organizationId: components["parameters"]["OrganizationId"]; + invitationId: components["parameters"]["InvitationId"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Invitation revoked */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["OrganizationInvitation"]; + }; + }; + /** @description Not authenticated */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Organization admin access required */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Organization or invitation not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Cannot revoke an accepted invitation */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + getInvitationDetails: { + parameters: { + query?: never; + header?: never; + path: { + token: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Invitation details */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["InvitationDetails"]; + }; + }; + /** @description Invitation not found or expired */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + acceptInvitation: { + parameters: { + query?: never; + header?: never; + path: { + token: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AcceptInvitationRequest"]; + }; + }; + responses: { + /** @description Invitation accepted and session issued */ + 200: { + headers: { + /** @description HttpOnly refresh token cookie */ + "Set-Cookie"?: string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["LoginResponse"]; + }; + }; + /** @description Invitation not found or expired */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Account already exists for invitation email */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + updateUser: { + parameters: { + query?: never; + header?: never; + path: { + userId: components["parameters"]["UserId"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UpdateUserRequest"]; + }; + }; + responses: { + /** @description Updated user */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UserPublic"]; + }; + }; + /** @description Validation or duplicate email */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Not authenticated */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Admin only */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description User not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + deleteUser: { + parameters: { + query?: never; + header?: never; + path: { + userId: components["parameters"]["UserId"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description User deleted */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Not authenticated */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Admin only */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description User not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + listSubjects: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Subject list */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Subject"][]; + }; + }; + /** @description Not authenticated */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + createSubject: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CreateSubjectRequest"]; + }; + }; + responses: { + /** @description Subject created */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Subject"]; + }; + }; + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Not authenticated */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Admin only */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + listStudents: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Students visible to the caller */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Student"][]; + }; + }; + /** @description Not authenticated */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Forbidden for this role */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + createStudent: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CreateStudentRequest"]; + }; + }; + responses: { + /** @description Student created */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Student"]; + }; + }; + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Not authenticated */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Admin only or invalid parent/tutor membership role */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Parent or tutor not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + getStudent: { + parameters: { + query?: never; + header?: never; + path: { + studentId: components["parameters"]["StudentId"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Student */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Student"]; + }; + }; + /** @description Not authenticated */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Access denied */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Student not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + updateStudent: { + parameters: { + query?: never; + header?: never; + path: { + studentId: components["parameters"]["StudentId"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UpdateStudentRequest"]; + }; + }; + responses: { + /** @description Updated student */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Student"]; + }; + }; + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Not authenticated */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Admin only or invalid parent/tutor membership role */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Student, parent, or tutor not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + deleteStudent: { + parameters: { + query?: never; + header?: never; + path: { + studentId: components["parameters"]["StudentId"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Student deleted */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Not authenticated */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Admin only */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Student not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + getStudentSubjects: { + parameters: { + query?: never; + header?: never; + path: { + studentId: components["parameters"]["StudentId"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Enrolled subjects with grades */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["StudentSubject"][]; + }; + }; + /** @description Not authenticated */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Access denied */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Student not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + linkStudentSubject: { + parameters: { + query?: never; + header?: never; + path: { + studentId: components["parameters"]["StudentId"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["LinkStudentSubjectRequest"]; + }; + }; + responses: { + /** @description Link created */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["StudentSubjectLink"]; + }; + }; + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Not authenticated */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Admin only */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Student or subject not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Student already linked to this subject */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + listEmergencyContacts: { + parameters: { + query?: never; + header?: never; + path: { + studentId: components["parameters"]["StudentId"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Emergency contacts (max two per student) */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["EmergencyContact"][]; + }; + }; + /** @description Not authenticated */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Access denied */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Student not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + createEmergencyContact: { + parameters: { + query?: never; + header?: never; + path: { + studentId: components["parameters"]["StudentId"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CreateEmergencyContactRequest"]; + }; + }; + responses: { + /** @description Contact created */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["EmergencyContact"]; + }; + }; + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Not authenticated */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Admin only */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Student not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Maximum emergency contacts reached */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + updateEmergencyContact: { + parameters: { + query?: never; + header?: never; + path: { + studentId: components["parameters"]["StudentId"]; + contactId: components["parameters"]["ContactId"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UpdateEmergencyContactRequest"]; + }; + }; + responses: { + /** @description Updated contact */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["EmergencyContact"]; + }; + }; + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Not authenticated */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Admin only */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Student or contact not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + deleteEmergencyContact: { + parameters: { + query?: never; + header?: never; + path: { + studentId: components["parameters"]["StudentId"]; + contactId: components["parameters"]["ContactId"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Contact deleted */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Not authenticated */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Admin only */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Student or contact not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + listLessons: { + parameters: { + query: { + from: string; + to: string; + studentId?: string; + tutorId?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Lessons overlapping the range */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Lesson"][]; + }; + }; + /** @description Invalid query (e.g. `to` not after `from`) */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Not authenticated */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Forbidden for this actor or query */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + createLesson: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CreateLessonRequest"]; + }; + }; + responses: { + /** @description Lesson created */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Lesson"]; + }; + }; + /** @description Validation or business rule error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Not authenticated */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Caller is not an admin */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Related resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + getLesson: { + parameters: { + query?: never; + header?: never; + path: { + lessonId: components["parameters"]["LessonId"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Lesson */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Lesson"]; + }; + }; + /** @description Not authenticated */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Not allowed to view this lesson */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Lesson or student not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + updateLesson: { + parameters: { + query?: never; + header?: never; + path: { + lessonId: components["parameters"]["LessonId"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UpdateLessonRequest"]; + }; + }; + responses: { + /** @description Updated lesson */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Lesson"]; + }; + }; + /** @description Validation or business rule error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Not authenticated */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Caller is not an admin */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Lesson or related resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + completeLesson: { + parameters: { + query?: never; + header?: never; + path: { + lessonId: components["parameters"]["LessonId"]; + }; + cookie?: never; + }; + requestBody?: { + content: { + /** @example {} */ + "application/json": Record; + }; + }; + responses: { + /** @description Lesson (status `completed` or unchanged if already completed) */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Lesson"]; + }; + }; + /** @description Invalid transition (e.g. lesson cancelled) */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Not authenticated */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Only admin or assigned tutor may complete */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Lesson or student not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + cancelLesson: { + parameters: { + query?: never; + header?: never; + path: { + lessonId: components["parameters"]["LessonId"]; + }; + cookie?: never; + }; + requestBody?: { + content: { + /** @example {} */ + "application/json": Record; + }; + }; + responses: { + /** @description Lesson (status `cancelled` or unchanged if already cancelled) */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Lesson"]; + }; + }; + /** @description Invalid transition (e.g. lesson already completed) */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Not authenticated */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Only admin or assigned tutor may cancel */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Lesson or student not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; +} diff --git a/packages/api-client/src/index.ts b/packages/api-client/src/index.ts index b32e700..8b683ea 100644 --- a/packages/api-client/src/index.ts +++ b/packages/api-client/src/index.ts @@ -1 +1,15 @@ -export type ApiClientPackageReady = true; +/** + * Prefer `@studiqo/api-client/client` and `@studiqo/api-client/errors` for smaller bundles. + */ +export { + createStudiqoClient, + type StudiqoClient, + type StudiqoClientOptions, +} from "./create-client"; +export { + StudiqoApiError, + isStudiqoApiError, + throwIfStudiqoError, + unwrapStudiqoResponse, + unwrapStudiqoVoid, +} from "./errors";