diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9978f81 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,38 @@ +name: ci + +on: + pull_request: + push: + branches: [main, stage] + +jobs: + node: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm typecheck + - run: pnpm test + - run: pnpm check + env: + SKIP_DB_CHECKS: "1" + OTP_HASH_SECRET: ci-only-secret + SCHOOL_SECURITY_CODES_SECRET: ci-only-secret + + go: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: "1.25" + - name: vet and test every Go module + run: | + for m in $(find . -name go.mod -not -path "*/node_modules/*" -exec dirname {} \;); do + (cd "$m" && go vet ./... && go test ./...) + done diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e8903e5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +node_modules +dist +coverage +.env* +!.env.example +*.tsbuildinfo +.DS_Store diff --git a/go.work b/go.work new file mode 100644 index 0000000..a213941 --- /dev/null +++ b/go.work @@ -0,0 +1,8 @@ +go 1.25.0 + +use ( + ./packages/kyfaru-kit + ./services/communication + ./services/documents + ./services/payments +) diff --git a/go.work.sum b/go.work.sum new file mode 100644 index 0000000..beab6d5 --- /dev/null +++ b/go.work.sum @@ -0,0 +1,20 @@ +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.1/go.mod h1:Z7IJhJU+poOdJjUR2wpyY21ossQ1XS/R3Lk9Msq5kM4= +github.com/aws/aws-sdk-go-v2/service/signin v1.10.1/go.mod h1:xpo/geVldu8payT375WekctUzopG/hBU7miiqItMUlw= +github.com/aws/aws-sdk-go-v2/service/sso v1.38.1/go.mod h1:rRD/dnm7q0HYE/I5TMaPgkWyyUGLcwuxHLABsLnQ3e0= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.1/go.mod h1:skwM/xsbR/1ReUTesv9BhpJp1VjajR7DWQnuVLwiXsQ= +github.com/aws/aws-sdk-go-v2/service/sts v1.51.1/go.mod h1:26zA0GhDrLo+yiLI2yXWxqB1PdsShfLikoI7GOEgugM= +github.com/boombuler/barcode v1.0.1/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/phpdave11/gofpdi v1.0.13/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/ruudk/golang-pdf417 v0.0.0-20201230142125-a7e3863a1245/go.mod h1:pQAZKsJ8yyVxGRWYNEm9oFB8ieLgKFnamEyDmSA0BRk= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= +golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/telemetry v0.0.0-20260508192327-42602be52be6/go.mod h1:Eqhaxk/wZsWEH8CRxLwj6xzEJbz7k1EFGqx7nyCoabE= +golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= +golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= +golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/package.json b/package.json new file mode 100644 index 0000000..42cd902 --- /dev/null +++ b/package.json @@ -0,0 +1,14 @@ +{ + "name": "stackable", + "private": true, + "packageManager": "pnpm@10.30.1", + "scripts": { + "typecheck": "pnpm -r --if-present typecheck", + "test": "vitest run", + "check": "pnpm --filter stackable-app check", + "verify": "pnpm typecheck && pnpm test && pnpm check" + }, + "devDependencies": { + "vitest": "^3.2.4" + } +} diff --git a/packages/contracts/events/notification.requested.v1.json b/packages/contracts/events/notification.requested.v1.json new file mode 100644 index 0000000..7122ddd --- /dev/null +++ b/packages/contracts/events/notification.requested.v1.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://stackable.app/events/notification.requested.v1.json", + "title": "notification.requested.v1", + "type": "object", + "required": ["eventId", "schoolId", "type", "audience", "payload", "occurredAt"], + "additionalProperties": false, + "properties": { + "eventId": { "type": "string", "description": "Unique per event; consumers dedupe on it" }, + "schoolId": { "type": "string" }, + "type": { "type": "string", "description": "Notification kind, e.g. fee.invoice_issued" }, + "audience": { "type": "object", "description": "Who receives it (roles, user ids or class ids)", "additionalProperties": true }, + "payload": { "type": "object", "description": "Template variables", "additionalProperties": true }, + "occurredAt": { "type": "string", "format": "date-time" } + } +} diff --git a/packages/contracts/openapi/academics.yaml b/packages/contracts/openapi/academics.yaml new file mode 100644 index 0000000..fa377a6 --- /dev/null +++ b/packages/contracts/openapi/academics.yaml @@ -0,0 +1,514 @@ +openapi: 3.1.0 +info: + title: Stackable Academics Service + version: 0.1.0 + description: > + Homework, CATs and grading. Every /v1 path needs a Bearer service token (audience "academics"). + Release needs the token claim `rel: true`, set by the web gateway only after the school security + code is verified and the 5-try lockout is enforced. Answer keys are never returned to students or parents. +security: + - serviceToken: [] +paths: + /v1/homework/assignments: + post: + operationId: createAssignment + summary: Teacher creates a DRAFT assignment with its questions + requestBody: { $ref: "#/components/requestBodies/CreateAssignment" } + responses: + "201": { description: Created, content: { application/json: { schema: { $ref: "#/components/schemas/Assignment" } } } } + "400": { $ref: "#/components/responses/Error" } + "403": { $ref: "#/components/responses/Error" } + get: + operationId: listAssignments + summary: Teacher lists own assignments; student lists OPEN and CLOSED ones for classId + parameters: + - { name: classId, in: query, schema: { type: string } } + - { name: status, in: query, schema: { $ref: "#/components/schemas/AssignmentStatus" } } + responses: + "200": { $ref: "#/components/responses/ItemList" } + "400": { $ref: "#/components/responses/Error" } + /v1/homework/assignments/{id}: + get: + operationId: getAssignment + summary: One assignment; keys and correct flags only in the teacher view + parameters: [{ $ref: "#/components/parameters/Id" }] + responses: + "200": { description: The assignment with items, content: { application/json: { schema: { type: object } } } } + "404": { $ref: "#/components/responses/Error" } + /v1/homework/assignments/{id}/publish: + post: + operationId: publishAssignment + summary: DRAFT to OPEN; writes outbox assignment.published + parameters: [{ $ref: "#/components/parameters/Id" }] + responses: + "200": { description: Published, content: { application/json: { schema: { $ref: "#/components/schemas/Assignment" } } } } + "409": { $ref: "#/components/responses/Error" } + /v1/homework/assignments/{id}/close: + post: + operationId: closeAssignment + parameters: [{ $ref: "#/components/parameters/Id" }] + responses: + "200": { description: Closed, content: { application/json: { schema: { $ref: "#/components/schemas/Assignment" } } } } + "409": { $ref: "#/components/responses/Error" } + /v1/homework/assignments/{id}/submissions: + get: + operationId: listSubmissions + summary: Teacher lists submissions of an assignment + parameters: [{ $ref: "#/components/parameters/Id" }] + responses: + "200": { $ref: "#/components/responses/ItemList" } + /v1/homework/assignments/{id}/open: + post: + operationId: openSubmission + summary: Student starts or resumes a submission (assignment must be OPEN and in the student's class) + parameters: [{ $ref: "#/components/parameters/Id" }] + requestBody: + required: true + content: { application/json: { schema: { type: object, required: [classId], properties: { classId: { type: string } } } } } + responses: + "200": { description: Submission and saved answers, content: { application/json: { schema: { type: object } } } } + "404": { $ref: "#/components/responses/Error" } + /v1/homework/submissions/{id}/answers/{itemId}: + put: + operationId: saveAnswer + summary: Student saves one answer + parameters: + - { $ref: "#/components/parameters/Id" } + - { name: itemId, in: path, required: true, schema: { type: string, format: uuid } } + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + responseText: { type: string } + selectedOptionId: { type: string } + responses: + "200": { description: Saved, content: { application/json: { schema: { type: object } } } } + "409": { $ref: "#/components/responses/Error" } + /v1/homework/submissions/{id}/submit: + post: + operationId: submitSubmission + summary: Accepted after closes_at but flagged late; 409 HOMEWORK_AI_GATE_LOCKED while any answer is gate-locked + parameters: [{ $ref: "#/components/parameters/Id" }] + responses: + "200": + description: Submitted + content: + application/json: + schema: + type: object + required: [id, status, submittedAt, late] + properties: + id: { type: string } + status: { type: string } + submittedAt: { type: string, format: date-time } + late: { type: boolean } + "409": { $ref: "#/components/responses/Error" } + /v1/homework/submissions/{id}/review: + post: + operationId: reviewSubmission + summary: Teacher decision; reason is required for RETURNED and REJECTED + parameters: [{ $ref: "#/components/parameters/Id" }] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [decision] + properties: + decision: { type: string, enum: [APPROVED, RETURNED, REJECTED] } + reason: { type: string } + responses: + "200": { description: Reviewed, content: { application/json: { schema: { type: object } } } } + "400": { $ref: "#/components/responses/Error" } + "409": { $ref: "#/components/responses/Error" } + /v1/answers/{id}/gate: + post: + operationId: setAnswerGate + summary: The ai service locks or unlocks submission for one answer (role ai or service) + parameters: [{ $ref: "#/components/parameters/Id" }] + requestBody: + required: true + content: { application/json: { schema: { type: object, required: [locked], properties: { locked: { type: boolean } } } } } + responses: + "200": { description: Updated, content: { application/json: { schema: { type: object } } } } + "403": { $ref: "#/components/responses/Error" } + /v1/parents/{parentId}/children/{studentId}/homework: + get: + operationId: getParentHomework + summary: Parent read model; never answers, answer keys or transcripts + parameters: + - { name: parentId, in: path, required: true, schema: { type: string } } + - { name: studentId, in: path, required: true, schema: { type: string } } + - { name: aiHints, in: query, schema: { type: integer, minimum: 0 }, description: AI hint count supplied by the caller } + - { name: aiPractice, in: query, schema: { type: integer, minimum: 0 }, description: AI practice count supplied by the caller } + responses: + "200": { description: Homework status, content: { application/json: { schema: { $ref: "#/components/schemas/ParentHomework" } } } } + "403": { $ref: "#/components/responses/Error" } + /v1/parents/{parentId}/children/{studentId}/homework/{submissionId}/review: + post: + operationId: parentReviewHomework + summary: Parent acknowledges a child's submission + parameters: + - { name: parentId, in: path, required: true, schema: { type: string } } + - { name: studentId, in: path, required: true, schema: { type: string } } + - { name: submissionId, in: path, required: true, schema: { type: string, format: uuid } } + requestBody: + required: true + content: + application/json: + schema: { type: object, required: [acknowledged], properties: { acknowledged: { type: boolean }, comment: { type: string } } } + responses: + "200": { description: Saved, content: { application/json: { schema: { type: object } } } } + /v1/assessments: + post: + operationId: createAssessment + summary: Teacher creates a DRAFT cat or quiz (exams are a separate module) + requestBody: + required: true + content: { application/json: { schema: { $ref: "#/components/schemas/CreateAssessment" } } } + responses: + "201": { description: Created, content: { application/json: { schema: { $ref: "#/components/schemas/Assessment" } } } } + "400": { $ref: "#/components/responses/Error" } + get: + operationId: listAssessments + parameters: [{ name: classId, in: query, schema: { type: string } }] + responses: + "200": { $ref: "#/components/responses/ItemList" } + /v1/assessments/{id}/schedule: + post: + operationId: scheduleAssessment + parameters: [{ $ref: "#/components/parameters/Id" }] + responses: + "200": { description: Scheduled, content: { application/json: { schema: { $ref: "#/components/schemas/Assessment" } } } } + "409": { $ref: "#/components/responses/Error" } + /v1/assessments/{id}/accommodations: + post: + operationId: setAccommodation + summary: Extra minutes for one student, added to the deadline when they start + parameters: [{ $ref: "#/components/parameters/Id" }] + requestBody: + required: true + content: + application/json: + schema: { type: object, required: [studentId, extraMinutes], properties: { studentId: { type: string }, extraMinutes: { type: integer, minimum: 1, maximum: 240 } } } + responses: + "201": { description: Saved, content: { application/json: { schema: { type: object } } } } + /v1/assessments/{id}/attempts: + post: + operationId: startAttempt + summary: Starts or resumes an attempt inside the window; returns questions without keys and the server deadline + parameters: [{ $ref: "#/components/parameters/Id" }] + requestBody: + required: true + content: { application/json: { schema: { type: object, required: [classId], properties: { classId: { type: string } } } } } + responses: + "200": { description: Attempt, content: { application/json: { schema: { $ref: "#/components/schemas/StartedAttempt" } } } } + "404": { $ref: "#/components/responses/Error" } + "409": { $ref: "#/components/responses/Error" } + /v1/assessments/{id}/results: + get: + operationId: listResults + summary: Teacher sees all results; a student sees their own only after release + parameters: [{ $ref: "#/components/parameters/Id" }] + responses: + "200": { $ref: "#/components/responses/ItemList" } + /v1/assessments/{id}/release: + post: + operationId: releaseResults + summary: Releases every result in one transaction; needs claim rel=true and every script marked + parameters: [{ $ref: "#/components/parameters/Id" }] + responses: + "200": + description: Released + content: + application/json: + schema: + type: object + required: [assessmentId, releasedAt, count] + properties: + assessmentId: { type: string } + releasedAt: { type: string, format: date-time } + count: { type: integer } + "403": { $ref: "#/components/responses/Error" } + "409": { $ref: "#/components/responses/Error" } + /v1/attempts/{id}/answers/{questionId}: + put: + operationId: autosaveAnswer + summary: Autosave; 409 ATTEMPT_DEADLINE_PASSED after the server deadline + parameters: + - { $ref: "#/components/parameters/Id" } + - { name: questionId, in: path, required: true, schema: { type: string, format: uuid } } + requestBody: + required: true + content: { application/json: { schema: { type: object, required: [response], properties: { response: {} } } } } + responses: + "200": { description: Saved, content: { application/json: { schema: { type: object, properties: { savedAt: { type: string, format: date-time } } } } } } + "409": { $ref: "#/components/responses/Error" } + /v1/attempts/{id}/submit: + post: + operationId: submitAttempt + summary: After the deadline the attempt is recorded as AUTO_SUBMITTED + parameters: [{ $ref: "#/components/parameters/Id" }] + responses: + "200": { description: Submitted, content: { application/json: { schema: { $ref: "#/components/schemas/Attempt" } } } } + "409": { $ref: "#/components/responses/Error" } + /v1/attempts/{id}/mark: + post: + operationId: markAttempt + summary: Teacher marks answers; finalise needs every answered short answer marked and an active grading system + parameters: [{ $ref: "#/components/parameters/Id" }] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [marks, finalise] + properties: + finalise: { type: boolean } + marks: + type: array + items: { type: object, required: [questionId, marks], properties: { questionId: { type: string }, marks: { type: number, minimum: 0 } } } + responses: + "200": { description: Marked, content: { application/json: { schema: { $ref: "#/components/schemas/Attempt" } } } } + "409": { $ref: "#/components/responses/Error" } + /v1/attempts/{id}/scan-upload-link: + post: + operationId: requestScanUploadLink + summary: Placeholder object key for a paper scan; real signing lives in the documents service + parameters: [{ $ref: "#/components/parameters/Id" }] + responses: + "201": + description: Key + content: + application/json: + schema: + type: object + required: [objectKey, uploadUrl] + properties: { objectKey: { type: string }, uploadUrl: { type: [string, "null"] } } + /v1/grading/systems: + post: + operationId: createGradingSystem + summary: Admin creates an inactive system; bands must cover 0 to 100 with no gaps or overlaps + requestBody: + required: true + content: { application/json: { schema: { $ref: "#/components/schemas/CreateGradingSystem" } } } + responses: + "201": { description: Created, content: { application/json: { schema: { type: object } } } } + "400": { description: GRADING_BANDS_INVALID lists the offending rows in details.issues, content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } } + get: + operationId: listGradingSystems + responses: + "200": { $ref: "#/components/responses/ItemList" } + /v1/grading/systems/{id}/activate: + post: + operationId: activateGradingSystem + summary: Activates one system and deactivates the old one; old results keep their system id + parameters: [{ $ref: "#/components/parameters/Id" }] + responses: + "200": { description: Activated, content: { application/json: { schema: { type: object } } } } + /v1/grading/systems/{id}/pass-marks: + put: + operationId: setPassMark + summary: Sets the default (no subjectId) or a per-subject pass mark + parameters: [{ $ref: "#/components/parameters/Id" }] + requestBody: + required: true + content: + application/json: + schema: { type: object, required: [passPct], properties: { subjectId: { type: string }, passPct: { type: number, minimum: 0, maximum: 100 } } } + responses: + "200": { description: Saved, content: { application/json: { schema: { type: object } } } } +components: + securitySchemes: + serviceToken: + type: http + scheme: bearer + bearerFormat: JWT + parameters: + Id: + name: id + in: path + required: true + schema: { type: string, format: uuid } + requestBodies: + CreateAssignment: + required: true + content: + application/json: + schema: + type: object + required: [title, classId, subjectId, dueAt, closesAt, items] + properties: + title: { type: string } + classId: { type: string } + subjectId: { type: string } + dueAt: { type: string, format: date-time } + closesAt: { type: string, format: date-time } + items: + type: array + minItems: 1 + items: + type: object + required: [marks, question] + properties: + marks: { type: number } + question: + type: object + required: [type, stem] + properties: + type: { type: string, enum: [short_answer, multiple_choice, research] } + stem: { type: string } + answerKey: { type: string } + options: + type: array + items: { type: object, required: [label, isCorrect], properties: { label: { type: string }, isCorrect: { type: boolean } } } + responses: + Error: + description: Standard error envelope + content: + application/json: + schema: { $ref: "#/components/schemas/ErrorEnvelope" } + ItemList: + description: A bounded list + content: + application/json: + schema: { type: object, required: [items], properties: { items: { type: array, items: { type: object } } } } + schemas: + AssignmentStatus: { type: string, enum: [DRAFT, OPEN, CLOSED, ARCHIVED] } + Assignment: + type: object + required: [id, schoolId, classId, subjectId, teacherId, title, dueAt, closesAt, status] + properties: + id: { type: string } + schoolId: { type: string } + classId: { type: string } + subjectId: { type: string } + teacherId: { type: string } + title: { type: string } + dueAt: { type: string, format: date-time } + closesAt: { type: string, format: date-time } + status: { $ref: "#/components/schemas/AssignmentStatus" } + ParentHomework: + type: object + required: [studentId, aiUsage, items] + properties: + studentId: { type: string } + aiUsage: { type: object, required: [hints, practice], properties: { hints: { type: integer }, practice: { type: integer } } } + items: + type: array + items: + type: object + required: [submissionId, assignmentId, title, status, dueAt, closesAt, submittedAt, late, teacherComment] + properties: + submissionId: { type: string } + assignmentId: { type: string } + title: { type: string } + status: { type: string, enum: [IN_PROGRESS, SUBMITTED, APPROVED, RETURNED, REJECTED, MISSED] } + dueAt: { type: string, format: date-time } + closesAt: { type: string, format: date-time } + submittedAt: { type: [string, "null"], format: date-time } + late: { type: boolean } + teacherComment: { type: [string, "null"] } + Assessment: + type: object + required: [id, title, type, classId, durationMinutes, opensAt, closesAt, status] + properties: + id: { type: string } + title: { type: string } + type: { type: string, enum: [cat, quiz] } + classId: { type: string } + subjectId: { type: [string, "null"] } + durationMinutes: { type: integer } + opensAt: { type: string, format: date-time } + closesAt: { type: string, format: date-time } + status: { type: string, enum: [DRAFT, SCHEDULED, OPEN, CLOSED, MARKED, RELEASED] } + CreateAssessment: + type: object + required: [title, type, classId, durationMinutes, opensAt, closesAt, questions] + properties: + title: { type: string } + type: { type: string, enum: [cat, quiz] } + classId: { type: string } + subjectId: { type: string } + durationMinutes: { type: integer, minimum: 1, maximum: 600 } + opensAt: { type: string, format: date-time } + closesAt: { type: string, format: date-time } + questions: + type: array + minItems: 1 + items: + type: object + required: [type, stem, marks] + properties: + type: { type: string, enum: [short_answer, multiple_choice, reorder, calculation] } + stem: { type: string } + marks: { type: number } + choices: { type: array, items: { type: object, required: [label, isCorrect], properties: { label: { type: string }, isCorrect: { type: boolean } } } } + items: { type: array, items: { type: string }, description: Reorder items listed in the correct order } + expectedValue: { type: number } + tolerance: { type: number, minimum: 0 } + modelAnswer: { type: string } + StartedAttempt: + type: object + required: [attempt, serverNow, questions, answers] + properties: + attempt: { $ref: "#/components/schemas/Attempt" } + serverNow: { type: string, format: date-time } + questions: + type: array + items: + type: object + required: [id, position, type, stem, marks] + properties: + id: { type: string } + position: { type: integer } + type: { type: string } + stem: { type: string } + marks: { type: number } + choices: { type: array, items: { type: object, properties: { id: { type: string }, label: { type: string } } } } + answers: { type: array, items: { type: object, properties: { questionId: { type: string }, response: {} } } } + Attempt: + type: object + required: [id, assessmentId, status, dueAt] + properties: + id: { type: string } + assessmentId: { type: string } + status: { type: string, enum: [NOT_STARTED, IN_PROGRESS, SUBMITTED, UNDER_MARKING, MARKED, AUTO_SUBMITTED] } + startedAt: { type: [string, "null"], format: date-time } + dueAt: { type: [string, "null"], format: date-time } + submittedAt: { type: [string, "null"], format: date-time } + score: { type: [number, "null"] } + CreateGradingSystem: + type: object + required: [name, effectiveFrom, bands] + properties: + name: { type: string } + effectiveFrom: { type: string, format: date-time } + bands: + type: array + minItems: 1 + items: + type: object + required: [minPct, maxPct, grade, points] + properties: + minPct: { type: number, minimum: 0, maximum: 100 } + maxPct: { type: number, minimum: 0, maximum: 100 } + grade: { type: string } + points: { type: number } + remark: { type: string } + passMarks: + type: array + items: { type: object, required: [passPct], properties: { subjectId: { type: string }, passPct: { type: number } } } + ErrorEnvelope: + type: object + required: [error, code, details, requestId] + properties: + error: { type: string } + code: { type: string } + details: { type: object, additionalProperties: true } + requestId: { type: string } diff --git a/packages/contracts/openapi/ai.yaml b/packages/contracts/openapi/ai.yaml new file mode 100644 index 0000000..a9f646d --- /dev/null +++ b/packages/contracts/openapi/ai.yaml @@ -0,0 +1,244 @@ +openapi: 3.1.0 +info: + title: Stackable AI Service + version: 0.1.0 + description: AI tutor hints, the answer gate, practice sets, usage counts and study tools. Provider is fake until Groq is configured. +paths: + /v1/tutor/hint: + post: + operationId: requestHint + summary: Give one hint, or lock the question when the hint cap would be passed + security: [{ serviceToken: [] }] + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/HintRequest" } + responses: + "200": + description: The hint and the new gate state + content: + application/json: + schema: { $ref: "#/components/schemas/HintResponse" } + "400": { $ref: "#/components/responses/Error" } + "401": { $ref: "#/components/responses/Error" } + "403": { $ref: "#/components/responses/Error" } + "409": { $ref: "#/components/responses/Error" } + "429": { $ref: "#/components/responses/Error" } + "502": { $ref: "#/components/responses/Error" } + /v1/tutor/practice: + post: + operationId: createPractice + summary: Generate and open the practice set for a helped question (answer keys stay on the server) + security: [{ serviceToken: [] }] + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/PracticeRequest" } + responses: + "200": + description: The practice items without their keys + content: + application/json: + schema: { $ref: "#/components/schemas/PracticeResponse" } + "400": { $ref: "#/components/responses/Error" } + "401": { $ref: "#/components/responses/Error" } + "403": { $ref: "#/components/responses/Error" } + "409": { $ref: "#/components/responses/Error" } + "429": { $ref: "#/components/responses/Error" } + "502": { $ref: "#/components/responses/Error" } + /v1/gate/answer: + post: + operationId: submitGateAnswer + summary: Apply a submission result to the gate; `correct` is accepted from teacher and system tokens only + security: [{ serviceToken: [] }] + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/GateAnswerRequest" } + responses: + "200": + description: The new state, whether later content stays locked, and any points awarded + content: + application/json: + schema: { $ref: "#/components/schemas/GateAnswerResponse" } + "400": { $ref: "#/components/responses/Error" } + "401": { $ref: "#/components/responses/Error" } + "403": { $ref: "#/components/responses/Error" } + "409": { $ref: "#/components/responses/Error" } + /v1/usage/summary: + get: + operationId: getUsageSummary + summary: Counts only (no message content) for a parent or teacher + security: [{ serviceToken: [] }] + parameters: + - { name: studentId, in: query, required: true, schema: { type: string } } + responses: + "200": + description: Usage counts and points + content: + application/json: + schema: { $ref: "#/components/schemas/UsageSummary" } + "400": { $ref: "#/components/responses/Error" } + "401": { $ref: "#/components/responses/Error" } + "403": { $ref: "#/components/responses/Error" } + /v1/tools/{kind}: + post: + operationId: runStudyTool + summary: Flashcards, summary or quiz as structured JSON; always flagged AI-generated and needing teacher review + security: [{ serviceToken: [] }] + parameters: + - { name: kind, in: path, required: true, schema: { type: string, enum: [flashcards, summary, quiz] } } + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/ToolRequest" } + responses: + "200": + description: Generated content + content: + application/json: + schema: { $ref: "#/components/schemas/ToolResponse" } + "400": { $ref: "#/components/responses/Error" } + "401": { $ref: "#/components/responses/Error" } + "403": { $ref: "#/components/responses/Error" } + "404": { $ref: "#/components/responses/Error" } + "429": { $ref: "#/components/responses/Error" } + "502": { $ref: "#/components/responses/Error" } +components: + securitySchemes: + serviceToken: + type: http + scheme: bearer + bearerFormat: JWT + responses: + Error: + description: Standard error envelope + content: + application/json: + schema: { $ref: "#/components/schemas/ErrorEnvelope" } + schemas: + GateState: + description: Answer gate state (SDD P2-12) + oneOf: + - { type: object, required: [kind], properties: { kind: { const: OPEN } } } + - type: object + required: [kind, hints] + properties: { kind: { const: ASSISTED }, hints: { type: integer, minimum: 1 } } + - { type: object, required: [kind], properties: { kind: { const: PRACTICE_REQUIRED } } } + - type: object + required: [kind, done, hints] + properties: + kind: { const: PRACTICE_OPEN } + done: { type: integer, minimum: 0 } + hints: { type: integer, minimum: 0 } + - type: object + required: [kind, unaided] + properties: { kind: { const: DONE }, unaided: { type: boolean } } + - { type: object, required: [kind], properties: { kind: { const: LOCKED_REDO } } } + Allowance: + type: object + required: [status, limit, remaining] + properties: + status: { type: string, enum: [AVAILABLE, EXHAUSTED] } + limit: { type: integer } + remaining: { type: integer } + HintRequest: + type: object + required: [questionId, questionText] + properties: + questionId: { type: string, maxLength: 100 } + questionText: { type: string, maxLength: 4000 } + answerState: { type: string, enum: [UNANSWERED, WRONG], default: UNANSWERED } + helpsUsed: { type: integer, minimum: 0, description: Context only; the server counts hints itself } + studentMessage: { type: string, maxLength: 2000 } + studentId: { type: string, description: Teacher and system tokens only } + answerKey: + type: string + maxLength: 500 + description: Teacher and system tokens only; used for the leak check, never stored or returned + HintResponse: + type: object + required: [hint, state, locked, allowance, leakFlagged] + properties: + hint: { type: [string, "null"], description: Null when the question is now locked } + state: { $ref: "#/components/schemas/GateState" } + locked: { type: boolean } + allowance: { oneOf: [{ $ref: "#/components/schemas/Allowance" }, { type: "null" }] } + leakFlagged: { type: boolean, description: True when the first provider reply leaked and was replaced } + PracticeRequest: + type: object + required: [questionId, questionText] + properties: + questionId: { type: string, maxLength: 100 } + questionText: { type: string, maxLength: 4000 } + difficulty: { type: integer, minimum: 1, maximum: 5, default: 3 } + studentId: { type: string } + PracticeResponse: + type: object + required: [items, state, locked] + properties: + items: + type: array + items: + type: object + required: [id, position, question, difficulty] + properties: + id: { type: string } + position: { type: integer } + question: { type: string } + difficulty: { type: integer, minimum: 1, maximum: 5 } + state: { $ref: "#/components/schemas/GateState" } + locked: { type: boolean } + GateAnswerRequest: + type: object + required: [questionId] + properties: + questionId: { type: string, maxLength: 100 } + correct: { type: boolean, description: Verdict from the grading service; teacher and system tokens only } + practiceAnswer: { type: string, maxLength: 500, description: Required while the state is PRACTICE_OPEN } + studentId: { type: string } + GateAnswerResponse: + type: object + required: [state, locked, correct, pointsAwarded] + properties: + state: { $ref: "#/components/schemas/GateState" } + locked: { type: boolean } + correct: { type: boolean } + pointsAwarded: { type: integer } + UsageSummary: + type: object + required: [studentId, questionsHelped, hintsUsed, practiceDone, points] + properties: + studentId: { type: string } + questionsHelped: { type: integer } + hintsUsed: { type: integer } + practiceDone: { type: integer } + points: { type: integer } + ToolRequest: + type: object + required: [text] + properties: + text: { type: string, maxLength: 8000 } + count: { type: integer, minimum: 1, maximum: 20, default: 5 } + studentId: { type: string } + ToolResponse: + type: object + required: [kind, aiGenerated, needsTeacherReview, content] + properties: + kind: { type: string, enum: [flashcards, summary, quiz] } + aiGenerated: { const: true } + needsTeacherReview: { const: true } + content: + description: Flashcards [{front, back}], a summary {summary, keyPoints}, or quiz questions [{question, options, answerIndex?}] (answerIndex for teacher and system tokens only) + ErrorEnvelope: + type: object + required: [error, code, details, requestId] + properties: + error: { type: string } + code: { type: string } + details: { type: object, additionalProperties: true } + requestId: { type: string } diff --git a/packages/contracts/openapi/communication.yaml b/packages/contracts/openapi/communication.yaml new file mode 100644 index 0000000..12cfee5 --- /dev/null +++ b/packages/contracts/openapi/communication.yaml @@ -0,0 +1,296 @@ +openapi: 3.1.0 +info: + title: Stackable Communication Service + version: 0.1.0 +paths: + /v1/notifications: + post: + operationId: createNotification + summary: Queue a notification for already-resolved recipients + description: Repeated parents are merged into one message listing all their children. A repeated idempotencyKey returns the original with 200. + security: + - serviceToken: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CreateNotification" + responses: + "200": + description: Idempotency key already used; the original notification + content: + application/json: + schema: + $ref: "#/components/schemas/Notification" + "201": + description: Created + content: + application/json: + schema: + $ref: "#/components/schemas/Notification" + "400": + $ref: "#/components/responses/Error" + "401": + $ref: "#/components/responses/Error" + "500": + $ref: "#/components/responses/Error" + /v1/notifications/{id}/deliveries: + get: + operationId: getDeliveryCounts + summary: Count of deliveries per status + security: + - serviceToken: [] + parameters: + - $ref: "#/components/parameters/Id" + responses: + "200": + description: Counts for every status, zero when none + content: + application/json: + schema: + type: object + required: [notificationId, counts] + properties: + notificationId: { type: string, format: uuid } + counts: + type: object + required: [QUEUED, SENT, DELIVERED, FAILED, UNDELIVERED] + additionalProperties: { type: integer } + "400": + $ref: "#/components/responses/Error" + "401": + $ref: "#/components/responses/Error" + "404": + $ref: "#/components/responses/Error" + /v1/inbox: + get: + operationId: listInbox + summary: The calling user's notifications, newest first + security: + - serviceToken: [] + parameters: + - name: limit + in: query + schema: { type: integer, minimum: 1, maximum: 100, default: 20 } + - name: cursor + in: query + schema: { type: string } + responses: + "200": + description: One page + content: + application/json: + schema: + type: object + required: [items, nextCursor] + properties: + items: + type: array + items: + $ref: "#/components/schemas/InboxItem" + nextCursor: { type: [string, "null"] } + "400": + $ref: "#/components/responses/Error" + "401": + $ref: "#/components/responses/Error" + /v1/inbox/{id}/read: + post: + operationId: markRead + summary: Mark a notification read for the calling user + security: + - serviceToken: [] + parameters: + - $ref: "#/components/parameters/Id" + responses: + "200": + description: Marked read (repeat calls keep the first read time) + content: + application/json: + schema: + type: object + required: [read] + properties: + read: { type: boolean } + "400": + $ref: "#/components/responses/Error" + "401": + $ref: "#/components/responses/Error" + "404": + $ref: "#/components/responses/Error" + /v1/preferences: + get: + operationId: getPreferences + summary: The calling user's channel preferences + security: + - serviceToken: [] + responses: + "200": + description: Preference rows + content: + application/json: + schema: + $ref: "#/components/schemas/PreferenceList" + "401": + $ref: "#/components/responses/Error" + put: + operationId: putPreferences + summary: Upsert preferences for the listed types; other types are untouched + security: + - serviceToken: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/PreferenceList" + responses: + "200": + description: All of the user's preference rows after the update + content: + application/json: + schema: + $ref: "#/components/schemas/PreferenceList" + "400": + $ref: "#/components/responses/Error" + "401": + $ref: "#/components/responses/Error" + /v1/callbacks/{provider}: + post: + operationId: receiveCallback + summary: Provider delivery report (public, signature checked) + description: Stored once per provider event id; repeats return duplicate true and change nothing. + parameters: + - name: provider + in: path + required: true + schema: { type: string, enum: [fake, resend, africastalking] } + requestBody: + required: true + content: + application/json: + schema: { type: object, additionalProperties: true } + responses: + "200": + description: Accepted + content: + application/json: + schema: + type: object + required: [received, matched, duplicate] + properties: + received: { type: boolean } + matched: { type: boolean, description: False when no delivery has this provider reference } + duplicate: { type: boolean } + "400": + $ref: "#/components/responses/Error" + "401": + $ref: "#/components/responses/Error" + "404": + $ref: "#/components/responses/Error" +components: + securitySchemes: + serviceToken: + type: http + scheme: bearer + bearerFormat: JWT + parameters: + Id: + name: id + in: path + required: true + schema: { type: string, format: uuid } + responses: + Error: + description: Standard error envelope + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + schemas: + Channel: + type: string + enum: [push, sms, email] + CreateNotification: + type: object + required: [type, title, body, recipients, idempotencyKey] + properties: + type: { type: string, maxLength: 64 } + title: { type: string, maxLength: 200 } + body: { type: string, maxLength: 5000 } + urgent: { type: boolean, default: false, description: Urgent messages ignore quiet hours } + idempotencyKey: { type: string, maxLength: 128 } + recipients: + type: array + minItems: 1 + maxItems: 1000 + items: + type: object + required: [userId] + properties: + userId: { type: string, format: uuid } + childIds: + type: array + maxItems: 20 + items: { type: string, format: uuid } + channelsAllowed: + type: array + description: Empty or missing means every channel + items: + $ref: "#/components/schemas/Channel" + email: { type: string } + phone: { type: string } + Notification: + type: object + required: [id, type, title, body, urgent, senderIdentityId, idempotencyKey, createdAt] + properties: + id: { type: string, format: uuid } + type: { type: string } + title: { type: string } + body: { type: string } + urgent: { type: boolean } + senderIdentityId: { type: [string, "null"], format: uuid } + idempotencyKey: { type: string } + createdAt: { type: string, format: date-time } + InboxItem: + type: object + required: [id, type, title, body, urgent, childIds, createdAt, readAt] + properties: + id: { type: string, format: uuid } + type: { type: string } + title: { type: string } + body: { type: string } + urgent: { type: boolean } + childIds: + type: array + items: { type: string, format: uuid } + createdAt: { type: string, format: date-time } + readAt: { type: [string, "null"], format: date-time } + Preference: + type: object + required: [type, channels] + properties: + type: { type: string, description: Notification type, or * for the default } + channels: + type: array + minItems: 1 + items: + $ref: "#/components/schemas/Channel" + quietStart: { type: [string, "null"], pattern: "^([01]\\d|2[0-3]):[0-5]\\d$" } + quietEnd: { type: [string, "null"], pattern: "^([01]\\d|2[0-3]):[0-5]\\d$" } + PreferenceList: + type: object + required: [preferences] + properties: + preferences: + type: array + items: + $ref: "#/components/schemas/Preference" + ErrorEnvelope: + type: object + required: [error, code, requestId] + properties: + error: { type: string } + code: { type: string } + details: { type: object, additionalProperties: true } + requestId: { type: string } diff --git a/packages/contracts/openapi/documents.yaml b/packages/contracts/openapi/documents.yaml new file mode 100644 index 0000000..b7a7707 --- /dev/null +++ b/packages/contracts/openapi/documents.yaml @@ -0,0 +1,199 @@ +openapi: 3.1.0 +info: + title: Stackable Documents Service + version: 0.1.0 +paths: + /v1/documents: + post: + operationId: createDocument + summary: Serve a cached document, render a small one now, or queue a large one + security: + - serviceToken: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CreateDocumentRequest" + responses: + "200": + description: Cache hit + content: + application/json: + schema: + $ref: "#/components/schemas/DocumentResult" + "201": + description: Rendered now and cached + content: + application/json: + schema: + $ref: "#/components/schemas/DocumentResult" + "202": + description: Queued for the worker + content: + application/json: + schema: + $ref: "#/components/schemas/DocumentResult" + "400": + $ref: "#/components/responses/Error" + "401": + $ref: "#/components/responses/Error" + "500": + $ref: "#/components/responses/Error" + /v1/documents/{id}: + get: + operationId: getDocument + summary: Document status and a freshly signed url + security: + - serviceToken: [] + parameters: + - name: id + in: path + required: true + schema: { type: string, format: uuid } + responses: + "200": + description: The document + content: + application/json: + schema: + $ref: "#/components/schemas/DocumentView" + "401": + $ref: "#/components/responses/Error" + "404": + $ref: "#/components/responses/Error" + "500": + $ref: "#/components/responses/Error" + /v1/files/{key}: + get: + operationId: downloadFile + summary: Download through a signed link (local file store only) + description: The signature is the credential. R2 deployments serve downloads from R2 instead. + parameters: + - name: key + in: path + required: true + allowReserved: true + schema: { type: string } + - name: exp + in: query + required: true + schema: { type: integer } + - name: sig + in: query + required: true + schema: { type: string } + responses: + "200": + description: The file + content: + application/pdf: + schema: { type: string, format: binary } + application/vnd.openxmlformats-officedocument.spreadsheetml.sheet: + schema: { type: string, format: binary } + "403": + $ref: "#/components/responses/Error" + "404": + $ref: "#/components/responses/Error" +components: + securitySchemes: + serviceToken: + type: http + scheme: bearer + bearerFormat: JWT + responses: + Error: + description: Standard error envelope + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + schemas: + DocType: + type: string + enum: [receipt, report_card, fee_statement, class_sheet, generic] + Format: + type: string + enum: [pdf, xlsx] + Status: + type: string + enum: [SERVED, STORED_AND_CACHED, QUEUED_JOB, RENDERING, FAILED] + CreateDocumentRequest: + type: object + required: [type, format, dataVersion, data] + properties: + type: { $ref: "#/components/schemas/DocType" } + format: { $ref: "#/components/schemas/Format" } + params: { type: object, additionalProperties: true } + dataVersion: + type: string + maxLength: 128 + description: Change it whenever the source data changes; it is part of the cache key. + data: + description: Receipt data for type receipt, table data for every other type. + oneOf: + - $ref: "#/components/schemas/ReceiptData" + - $ref: "#/components/schemas/TableData" + ReceiptData: + type: object + required: [schoolName, receiptNo, lines] + properties: + schoolName: { type: string } + receiptNo: { type: string } + payerName: { type: string } + issuedAt: { type: string } + lines: + type: array + minItems: 1 + items: + type: object + required: [description, amountMinor] + properties: + description: { type: string } + amountMinor: { type: integer, format: int64, description: Amount in minor units (cents) } + TableData: + type: object + required: [schoolName, columns, rows] + properties: + schoolName: { type: string } + title: { type: string } + columns: + type: array + minItems: 1 + items: + type: object + required: [key, label] + properties: + key: { type: string } + label: { type: string } + money: { type: boolean, description: Cells are integer minor units } + rows: + type: array + items: { type: object, additionalProperties: true } + DocumentResult: + type: object + required: [id, status] + properties: + id: { type: string, format: uuid } + status: { $ref: "#/components/schemas/Status" } + url: { type: string } + renderMs: { type: integer, format: int64 } + DocumentView: + type: object + required: [id, status, type, format, createdAt] + properties: + id: { type: string, format: uuid } + status: { $ref: "#/components/schemas/Status" } + type: { $ref: "#/components/schemas/DocType" } + format: { $ref: "#/components/schemas/Format" } + url: { type: string } + renderMs: { type: integer, format: int64 } + createdAt: { type: string, format: date-time } + ErrorEnvelope: + type: object + required: [error, code, details, requestId] + properties: + error: { type: string } + code: { type: string } + details: { type: object, additionalProperties: true } + requestId: { type: string } diff --git a/packages/contracts/openapi/identity.yaml b/packages/contracts/openapi/identity.yaml new file mode 100644 index 0000000..75be3df --- /dev/null +++ b/packages/contracts/openapi/identity.yaml @@ -0,0 +1,51 @@ +openapi: 3.1.0 +info: + title: Stackable Identity Service + version: 0.1.0 +paths: + /v1/me: + get: + operationId: getMe + summary: Current actor taken from the service token + security: + - serviceToken: [] + responses: + "200": + description: The actor and school the token was issued for + content: + application/json: + schema: + $ref: "#/components/schemas/Me" + "401": + $ref: "#/components/responses/Error" + "500": + $ref: "#/components/responses/Error" +components: + securitySchemes: + serviceToken: + type: http + scheme: bearer + bearerFormat: JWT + responses: + Error: + description: Standard error envelope + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + schemas: + Me: + type: object + required: [userId, schoolId, role] + properties: + userId: { type: string } + schoolId: { type: string } + role: { type: [string, "null"] } + ErrorEnvelope: + type: object + required: [error, code, details, requestId] + properties: + error: { type: string } + code: { type: string } + details: { type: object, additionalProperties: true } + requestId: { type: string } diff --git a/packages/contracts/openapi/payments.yaml b/packages/contracts/openapi/payments.yaml new file mode 100644 index 0000000..84fbaef --- /dev/null +++ b/packages/contracts/openapi/payments.yaml @@ -0,0 +1,421 @@ +openapi: 3.1.0 +info: + title: Stackable Payments Service + version: 0.2.0 + description: Fees MVP. All amounts are int64 minor units (KES cents). Every route except the webhook needs a service token whose audience is "payments". +paths: + /v1/fee-structures: + post: + operationId: createFeeStructure + summary: Create a price list for one term and level + security: [{ serviceToken: [] }] + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/FeeStructureInput" } + responses: + "201": { description: Created, content: { application/json: { schema: { $ref: "#/components/schemas/FeeStructure" } } } } + "400": { $ref: "#/components/responses/Error" } + "401": { $ref: "#/components/responses/Error" } + "409": { $ref: "#/components/responses/Error" } + get: + operationId: listFeeStructures + summary: List fee structures (cursor paged by id) + security: [{ serviceToken: [] }] + parameters: + - { name: term, in: query, schema: { type: string } } + - { name: level, in: query, schema: { type: string } } + - { $ref: "#/components/parameters/After" } + - { $ref: "#/components/parameters/Limit" } + responses: + "200": + description: One page + content: + application/json: + schema: + type: object + required: [items, nextCursor] + properties: + items: { type: array, items: { $ref: "#/components/schemas/FeeStructure" } } + nextCursor: { type: string, description: "Empty when there is no next page" } + "401": { $ref: "#/components/responses/Error" } + /v1/invoices: + post: + operationId: issueInvoices + summary: Issue invoices to students and debit the ledger in one transaction + description: Send feeStructureId (mandatory items are billed) or custom lines with a term, not both. A student already invoiced for the structure and term gives 409 INVOICE_ALREADY_ISSUED and nothing is written. + security: [{ serviceToken: [] }] + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/IssueInvoicesInput" } + responses: + "201": + description: Issued + content: + application/json: + schema: + type: object + required: [invoices] + properties: + invoices: { type: array, items: { $ref: "#/components/schemas/Invoice" } } + "400": { $ref: "#/components/responses/Error" } + "401": { $ref: "#/components/responses/Error" } + "404": { $ref: "#/components/responses/Error" } + "409": { $ref: "#/components/responses/Error" } + /v1/students/{id}/statement: + get: + operationId: getStatement + summary: Balance by term, itemised lines and payments + security: [{ serviceToken: [] }] + parameters: + - { name: id, in: path, required: true, schema: { type: string, format: uuid } } + - { name: term, in: query, schema: { type: string }, description: "Filters lines; balanceMinor is always the full ledger sum" } + responses: + "200": { description: Statement, content: { application/json: { schema: { $ref: "#/components/schemas/Statement" } } } } + "400": { $ref: "#/components/responses/Error" } + "401": { $ref: "#/components/responses/Error" } + /v1/payments/intent: + post: + operationId: createPaymentIntent + summary: Create a PENDING payment and ask the rail to collect it + description: A repeated idempotencyKey with the same body returns the same payment with 200 and never charges twice; a different body gives 409 IDEMPOTENCY_KEY_REUSED. Provider failures use PAYMENT_INSUFFICIENT_FUNDS, PAYMENT_WRONG_PIN, PAYMENT_USER_CANCELLED (402), PAYMENT_TIMEOUT (504) and PAYMENT_RAIL_DOWN (503), with details.paymentId of the FAILED payment. + security: [{ serviceToken: [] }] + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/IntentInput" } + responses: + "200": { description: Replay of an earlier intent, content: { application/json: { schema: { $ref: "#/components/schemas/Payment" } } } } + "201": { description: Created, content: { application/json: { schema: { $ref: "#/components/schemas/Payment" } } } } + "400": { $ref: "#/components/responses/Error" } + "401": { $ref: "#/components/responses/Error" } + "402": { $ref: "#/components/responses/Error" } + "404": { $ref: "#/components/responses/Error" } + "409": { $ref: "#/components/responses/Error" } + "422": { $ref: "#/components/responses/Error" } + "503": { $ref: "#/components/responses/Error" } + "504": { $ref: "#/components/responses/Error" } + /v1/payments/{id}: + get: + operationId: getPayment + summary: Read one payment so a client can poll its status + security: [{ serviceToken: [] }] + parameters: + - { name: id, in: path, required: true, schema: { type: string, format: uuid } } + responses: + "200": { description: Payment, content: { application/json: { schema: { $ref: "#/components/schemas/Payment" } } } } + "401": { $ref: "#/components/responses/Error" } + "404": { $ref: "#/components/responses/Error" } + /v1/webhooks/{provider}: + post: + operationId: receiveWebhook + summary: Provider callback, applied exactly once + description: Called by the provider, so no service token. The first confirmation moves the payment to CONFIRMED and writes allocations, a CREDIT_PAYMENT ledger entry, a receipt and a payment.confirmed outbox row in one transaction. Repeats return duplicate true and change nothing. + parameters: + - { name: provider, in: path, required: true, schema: { type: string, enum: [mpesa, fake] } } + requestBody: + required: true + content: + application/json: + schema: { type: object, additionalProperties: true } + responses: + "200": + description: Accepted + content: + application/json: + schema: + type: object + required: [received, duplicate, outcome] + properties: + received: { type: boolean } + duplicate: { type: boolean } + outcome: { type: string, enum: [CONFIRMED, FAILED, UNMATCHED, IGNORED, RECORDED] } + "400": { $ref: "#/components/responses/Error" } + "401": { $ref: "#/components/responses/Error" } + "404": { $ref: "#/components/responses/Error" } + /v1/unmatched: + get: + operationId: listUnmatched + summary: Payments waiting for a human decision + security: [{ serviceToken: [] }] + parameters: + - { name: includeResolved, in: query, schema: { type: boolean, default: false } } + - { $ref: "#/components/parameters/After" } + - { $ref: "#/components/parameters/Limit" } + responses: + "200": + description: One page + content: + application/json: + schema: + type: object + required: [items, nextCursor] + properties: + items: { type: array, items: { $ref: "#/components/schemas/UnmatchedPayment" } } + nextCursor: { type: string } + "401": { $ref: "#/components/responses/Error" } + /v1/unmatched/{id}/resolve: + post: + operationId: resolveUnmatched + summary: Assign queued money to a student and confirm the payment + description: id is the payment id. With no invoiceLineIds the whole amount becomes student credit. A second call gives 409 PAYMENT_ALREADY_RESOLVED. + security: [{ serviceToken: [] }] + parameters: + - { name: id, in: path, required: true, schema: { type: string, format: uuid } } + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/ResolveInput" } + responses: + "200": { description: Resolved, content: { application/json: { schema: { $ref: "#/components/schemas/Payment" } } } } + "400": { $ref: "#/components/responses/Error" } + "401": { $ref: "#/components/responses/Error" } + "404": { $ref: "#/components/responses/Error" } + "409": { $ref: "#/components/responses/Error" } + /v1/reconciliation: + get: + operationId: getReconciliation + summary: Payment rows for the Excel export done by the documents service + security: [{ serviceToken: [] }] + parameters: + - { name: from, in: query, required: true, schema: { type: string }, description: "RFC 3339 or YYYY-MM-DD, inclusive" } + - { name: to, in: query, required: true, schema: { type: string }, description: "RFC 3339 or YYYY-MM-DD, exclusive" } + - { $ref: "#/components/parameters/After" } + - { $ref: "#/components/parameters/Limit" } + responses: + "200": + description: One page, ordered by payment id + content: + application/json: + schema: + type: object + required: [rows, nextCursor] + properties: + rows: { type: array, items: { $ref: "#/components/schemas/ReconRow" } } + nextCursor: { type: string } + "400": { $ref: "#/components/responses/Error" } + "401": { $ref: "#/components/responses/Error" } +components: + securitySchemes: + serviceToken: + type: http + scheme: bearer + bearerFormat: JWT + parameters: + After: + name: after + in: query + description: Cursor, the last id of the previous page + schema: { type: string, format: uuid } + Limit: + name: limit + in: query + schema: { type: integer, minimum: 1, maximum: 500, default: 100 } + responses: + Error: + description: Standard error envelope + content: + application/json: + schema: { $ref: "#/components/schemas/ErrorEnvelope" } + schemas: + ErrorEnvelope: + type: object + required: [error, code, requestId] + properties: + error: { type: string, description: Message safe to show the user } + code: { type: string, description: Stable machine code such as VALIDATION_FAILED or PAYMENT_TIMEOUT } + details: { description: Failed fields for validation errors, or ids such as paymentId } + requestId: { type: string } + Money: + type: integer + format: int64 + description: Minor units (KES cents) + FeeItem: + type: object + required: [id, name, amountMinor, mandatory] + properties: + id: { type: string, format: uuid } + name: { type: string } + amountMinor: { $ref: "#/components/schemas/Money" } + mandatory: { type: boolean } + FeeStructureInput: + type: object + required: [name, term, level, items] + properties: + name: { type: string } + term: { type: string, examples: ["2026-T1"] } + level: { type: string } + items: + type: array + minItems: 1 + maxItems: 50 + items: + type: object + required: [name, amountMinor] + properties: + name: { type: string } + amountMinor: { type: integer, format: int64, minimum: 1 } + mandatory: { type: boolean } + FeeStructure: + type: object + required: [id, name, term, level, items, createdAt] + properties: + id: { type: string, format: uuid } + name: { type: string } + term: { type: string } + level: { type: string } + items: { type: array, items: { $ref: "#/components/schemas/FeeItem" } } + createdAt: { type: string, format: date-time } + IssueInvoicesInput: + type: object + required: [studentIds] + properties: + studentIds: { type: array, minItems: 1, maxItems: 500, items: { type: string, format: uuid } } + feeStructureId: { type: string, format: uuid } + term: { type: string, description: Required with custom lines } + lines: + type: array + maxItems: 50 + items: + type: object + required: [description, amountMinor] + properties: + description: { type: string } + amountMinor: { type: integer, format: int64, minimum: 1 } + feeItemId: { type: string, format: uuid } + InvoiceLine: + type: object + required: [id, invoiceId, description, amountMinor] + properties: + id: { type: string, format: uuid } + invoiceId: { type: string, format: uuid } + feeItemId: { type: string, format: uuid } + description: { type: string } + amountMinor: { $ref: "#/components/schemas/Money" } + Invoice: + type: object + required: [id, studentId, term, status, issuedAt, totalMinor, lines] + properties: + id: { type: string, format: uuid } + studentId: { type: string, format: uuid } + term: { type: string } + status: { type: string, enum: [ISSUED, PARTIAL, PAID] } + feeStructureId: { type: string, format: uuid } + issuedAt: { type: string, format: date-time } + totalMinor: { $ref: "#/components/schemas/Money" } + lines: { type: array, items: { $ref: "#/components/schemas/InvoiceLine" } } + Statement: + type: object + required: [studentId, balanceMinor, creditMinor, terms, payments] + properties: + studentId: { type: string, format: uuid } + balanceMinor: { type: integer, format: int64, description: Ledger sum; negative means the student is in credit } + creditMinor: { $ref: "#/components/schemas/Money" } + terms: + type: array + items: + type: object + required: [term, invoicedMinor, paidMinor, balanceMinor, lines] + properties: + term: { type: string } + invoicedMinor: { $ref: "#/components/schemas/Money" } + paidMinor: { $ref: "#/components/schemas/Money" } + balanceMinor: { $ref: "#/components/schemas/Money" } + lines: + type: array + items: + type: object + required: [lineId, invoiceId, description, amountMinor, paidMinor, outstandingMinor] + properties: + lineId: { type: string, format: uuid } + invoiceId: { type: string, format: uuid } + description: { type: string } + amountMinor: { $ref: "#/components/schemas/Money" } + paidMinor: { $ref: "#/components/schemas/Money" } + outstandingMinor: { $ref: "#/components/schemas/Money" } + payments: + type: array + items: + type: object + required: [id, rail, amountMinor, status, createdAt] + properties: + id: { type: string, format: uuid } + rail: { $ref: "#/components/schemas/Rail" } + amountMinor: { $ref: "#/components/schemas/Money" } + status: { $ref: "#/components/schemas/PaymentStatus" } + failureCode: { $ref: "#/components/schemas/FailureCode" } + receiptNumber: { type: string } + createdAt: { type: string, format: date-time } + Rail: + type: string + enum: [mpesa, card, bank, fake] + PaymentStatus: + type: string + enum: [PENDING, CONFIRMED, REVERSED, FAILED, UNMATCHED] + FailureCode: + type: string + enum: [INSUFFICIENT_FUNDS, WRONG_PIN, TIMEOUT, USER_CANCELLED, RAIL_DOWN] + IntentInput: + type: object + required: [studentId, amountMinor, rail, invoiceLineIds, idempotencyKey] + properties: + studentId: { type: string, format: uuid } + amountMinor: { type: integer, format: int64, minimum: 1, description: Partial payments are allowed; may not exceed what is outstanding on the chosen lines } + rail: { $ref: "#/components/schemas/Rail" } + invoiceLineIds: { type: array, minItems: 1, maxItems: 50, items: { type: string, format: uuid } } + idempotencyKey: { type: string, maxLength: 128 } + payerPhone: { type: string, description: Required for mpesa, examples: ["0712345678"] } + Payment: + type: object + required: [id, rail, amountMinor, status, kind, invoiceLineIds, createdAt, updatedAt] + properties: + id: { type: string, format: uuid } + studentId: { type: string, format: uuid } + payerUserId: { type: string } + rail: { $ref: "#/components/schemas/Rail" } + providerTransactionId: { type: string } + amountMinor: { $ref: "#/components/schemas/Money" } + status: { $ref: "#/components/schemas/PaymentStatus" } + failureCode: { $ref: "#/components/schemas/FailureCode" } + kind: { type: string, enum: [INVOICE_LINE, EVENT, WALLET, FUNDRAISER] } + invoiceLineIds: { type: array, items: { type: string, format: uuid } } + createdAt: { type: string, format: date-time } + updatedAt: { type: string, format: date-time } + UnmatchedPayment: + type: object + required: [paymentId, reason, rail, amountMinor, createdAt] + properties: + paymentId: { type: string, format: uuid } + reason: { type: string, enum: [NO_MATCHING_PAYMENT, AMOUNT_MISMATCH] } + rail: { $ref: "#/components/schemas/Rail" } + amountMinor: { $ref: "#/components/schemas/Money" } + studentId: { type: string, format: uuid } + providerTransactionId: { type: string } + createdAt: { type: string, format: date-time } + resolvedBy: { type: string } + resolvedAt: { type: string, format: date-time } + ResolveInput: + type: object + required: [studentId] + properties: + studentId: { type: string, format: uuid } + invoiceLineIds: { type: array, maxItems: 50, items: { type: string, format: uuid } } + ReconRow: + type: object + required: [paymentId, rail, status, amountMinor, allocatedMinor, creditMinor, createdAt] + properties: + paymentId: { type: string, format: uuid } + receiptNumber: { type: string } + studentId: { type: string, format: uuid } + rail: { $ref: "#/components/schemas/Rail" } + providerTransactionId: { type: string } + status: { $ref: "#/components/schemas/PaymentStatus" } + amountMinor: { $ref: "#/components/schemas/Money" } + allocatedMinor: { $ref: "#/components/schemas/Money" } + creditMinor: { $ref: "#/components/schemas/Money" } + createdAt: { type: string, format: date-time } diff --git a/packages/contracts/package.json b/packages/contracts/package.json new file mode 100644 index 0000000..d0adf1e --- /dev/null +++ b/packages/contracts/package.json @@ -0,0 +1,17 @@ +{ + "name": "@stackable/contracts", + "version": "0.1.0", + "private": true, + "type": "module", + "exports": { ".": "./src/index.ts" }, + "scripts": { + "typecheck": "tsc --noEmit", + "test": "vitest run --root ../.." + }, + "dependencies": { + "zod": "^4.0.0" + }, + "devDependencies": { + "typescript": "^5.9.0" + } +} diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts new file mode 100644 index 0000000..04e1acc --- /dev/null +++ b/packages/contracts/src/index.ts @@ -0,0 +1,28 @@ +import { z } from "zod"; + +/** Body every service returns on failure; clients branch on `code`, never on `error`. */ +export const ErrorEnvelopeSchema = z.object({ + error: z.string(), + code: z.string(), + details: z.record(z.string(), z.unknown()), + requestId: z.string(), +}); +export type ErrorEnvelope = z.infer; + +/** Claims of the short-lived HS256 token the gateway sends to services. */ +export const ServiceTokenClaimsSchema = z.object({ + iss: z.string().min(1), + aud: z.string().min(1), + sub: z.string().min(1), + schoolId: z.string().min(1), + role: z.string().min(1).optional(), + exp: z.number().int(), +}); +export type ServiceTokenClaims = z.infer; + +export const MeResponseSchema = z.object({ + userId: z.string(), + schoolId: z.string(), + role: z.string().nullable(), +}); +export type MeResponse = z.infer; diff --git a/packages/contracts/tsconfig.json b/packages/contracts/tsconfig.json new file mode 100644 index 0000000..638bc06 --- /dev/null +++ b/packages/contracts/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2023", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "types": [] + }, + "include": ["src"] +} diff --git a/packages/kyfaru-kit/errors.go b/packages/kyfaru-kit/errors.go new file mode 100644 index 0000000..345dd19 --- /dev/null +++ b/packages/kyfaru-kit/errors.go @@ -0,0 +1,87 @@ +package kit + +import ( + "errors" + "net/http" +) + +// AppError is an operational error that maps to one HTTP status and one stable code. +type AppError struct { + Status int + Code string + Message string + Details any +} + +func (e *AppError) Error() string { return e.Code + ": " + e.Message } + +func newErr(status int, code, message string, details any) *AppError { + return &AppError{Status: status, Code: code, Message: message, Details: details} +} + +// Validation builds a 400 error; details usually lists the failed fields. +func Validation(message string, details any) *AppError { + return newErr(http.StatusBadRequest, "VALIDATION_FAILED", message, details) +} + +// Unauthorized builds a 401 error. +func Unauthorized(message string) *AppError { + return newErr(http.StatusUnauthorized, "AUTH_UNAUTHORIZED", message, nil) +} + +// Forbidden builds a 403 error. +func Forbidden(message string) *AppError { + return newErr(http.StatusForbidden, "AUTH_FORBIDDEN", message, nil) +} + +// NotFound builds a 404 error. +func NotFound(message string) *AppError { + return newErr(http.StatusNotFound, "RESOURCE_NOT_FOUND", message, nil) +} + +// Conflict builds a 409 error. +func Conflict(message string) *AppError { + return newErr(http.StatusConflict, "RESOURCE_CONFLICT", message, nil) +} + +// RateLimited builds a 429 error. +func RateLimited(message string) *AppError { + return newErr(http.StatusTooManyRequests, "RATE_LIMITED", message, nil) +} + +// Upstream builds a 502 error for a failing dependency. +func Upstream(message string) *AppError { + return newErr(http.StatusBadGateway, "UPSTREAM_FAILED", message, nil) +} + +// Unavailable builds a 503 error. +func Unavailable(message string) *AppError { + return newErr(http.StatusServiceUnavailable, "SERVICE_UNAVAILABLE", message, nil) +} + +// Internal builds a 500 error with a fixed safe message. +func Internal() *AppError { + return newErr(http.StatusInternalServerError, "INTERNAL_ERROR", "Something went wrong. Please try again.", nil) +} + +type envelope struct { + Error string `json:"error"` + Code string `json:"code"` + Details any `json:"details,omitempty"` + RequestID string `json:"requestId"` +} + +// WriteError sends the standard envelope; unknown errors are logged and become a safe 500. +func WriteError(w http.ResponseWriter, r *http.Request, err error) { + var appErr *AppError + if !errors.As(err, &appErr) { + LoggerFrom(r.Context()).Error("unhandled error", "err", err) + appErr = Internal() + } + WriteJSON(w, appErr.Status, envelope{ + Error: appErr.Message, + Code: appErr.Code, + Details: appErr.Details, + RequestID: RequestID(r.Context()), + }) +} diff --git a/packages/kyfaru-kit/go.mod b/packages/kyfaru-kit/go.mod new file mode 100644 index 0000000..9e0d57b --- /dev/null +++ b/packages/kyfaru-kit/go.mod @@ -0,0 +1,15 @@ +module github.com/stackable/kyfaru-kit + +go 1.25.0 + +require ( + github.com/go-chi/chi/v5 v5.3.2 + github.com/golang-jwt/jwt/v5 v5.3.1 + github.com/jackc/pgx/v5 v5.11.0 +) + +require ( + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + golang.org/x/text v0.29.0 // indirect +) diff --git a/packages/kyfaru-kit/go.sum b/packages/kyfaru-kit/go.sum new file mode 100644 index 0000000..a785dba --- /dev/null +++ b/packages/kyfaru-kit/go.sum @@ -0,0 +1,30 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-chi/chi/v5 v5.3.2 h1:5YQkICvTCSZ25hoRsyJazN0scjzKGiu4VAUc7H1o1nY= +github.com/go-chi/chi/v5 v5.3.2/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg= +github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= +golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/packages/kyfaru-kit/health.go b/packages/kyfaru-kit/health.go new file mode 100644 index 0000000..00b03a6 --- /dev/null +++ b/packages/kyfaru-kit/health.go @@ -0,0 +1,75 @@ +package kit + +import ( + "context" + "net/http" + "sync" + "time" +) + +const checkTimeout = 2 * time.Second // a slow dependency must not hang the probe + +// Check is one dependency probe; a failing Required check makes the service not ready. +type Check struct { + Name string + Required bool + Fn func(ctx context.Context) error +} + +type checkResult struct { + Name string `json:"name"` + Required bool `json:"required"` + Status string `json:"status"` + LatencyMs int64 `json:"latencyMs"` +} + +func runCheck(ctx context.Context, c Check) checkResult { + ctx, cancel := context.WithTimeout(ctx, checkTimeout) + defer cancel() + start := time.Now() + status := "ok" + if err := c.Fn(ctx); err != nil { + LoggerFrom(ctx).Warn("readiness check failed", "check", c.Name, "err", err) + status = "failed" + } + return checkResult{Name: c.Name, Required: c.Required, Status: status, LatencyMs: time.Since(start).Milliseconds()} +} + +func healthHandler(name, version string, started time.Time) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + WriteJSON(w, http.StatusOK, map[string]any{ + "status": "ok", + "name": name, + "version": version, + "uptimeSeconds": int64(time.Since(started).Seconds()), + }) + } +} + +func readyHandler(name, version string, checks []Check) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + results := make([]checkResult, len(checks)) + var wg sync.WaitGroup + for i, c := range checks { + wg.Add(1) + go func() { + defer wg.Done() + results[i] = runCheck(r.Context(), c) + }() + } + wg.Wait() + + status, code := "ready", http.StatusOK + for _, res := range results { + if res.Status == "ok" { + continue + } + if res.Required { + status, code = "unavailable", http.StatusServiceUnavailable + break + } + status = "degraded" + } + WriteJSON(w, code, map[string]any{"status": status, "name": name, "version": version, "checks": results}) + } +} diff --git a/packages/kyfaru-kit/http.go b/packages/kyfaru-kit/http.go new file mode 100644 index 0000000..da6b3a1 --- /dev/null +++ b/packages/kyfaru-kit/http.go @@ -0,0 +1,36 @@ +package kit + +import ( + "encoding/json" + "net/http" +) + +const maxBodyBytes = 1 << 20 // 1 MiB cap so one request cannot exhaust memory + +// HandlerFunc is an http handler that returns its error instead of writing it. +type HandlerFunc func(w http.ResponseWriter, r *http.Request) error + +// Handle adapts a HandlerFunc so every returned error becomes the standard envelope. +func Handle(fn HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if err := fn(w, r); err != nil { + WriteError(w, r, err) + } + } +} + +// WriteJSON writes v as JSON with the given status. +func WriteJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) // headers are already sent, so a write failure cannot be reported +} + +// DecodeJSON reads a size-limited JSON body into dst and returns a validation error on bad input. +func DecodeJSON(w http.ResponseWriter, r *http.Request, dst any) error { + r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes) + if err := json.NewDecoder(r.Body).Decode(dst); err != nil { + return Validation("Request body must be valid JSON.", nil) + } + return nil +} diff --git a/packages/kyfaru-kit/kit_test.go b/packages/kyfaru-kit/kit_test.go new file mode 100644 index 0000000..788d82f --- /dev/null +++ b/packages/kyfaru-kit/kit_test.go @@ -0,0 +1,371 @@ +package kit + +import ( + "context" + "encoding/json" + "errors" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" +) + +const ( + testSecret = "test-secret-test-secret-test-secret" + testIssuer = "gateway" + testAudience = "payments" +) + +func quietLogger() *slog.Logger { return slog.New(slog.NewJSONHandler(io.Discard, nil)) } + +func do(t *testing.T, h http.Handler, method, path string, headers map[string]string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(method, path, nil) + for k, v := range headers { + req.Header.Set(k, v) + } + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + return rec +} + +func decode(t *testing.T, rec *httptest.ResponseRecorder) map[string]any { + t.Helper() + var out map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { + t.Fatalf("body is not JSON: %q", rec.Body.String()) + } + return out +} + +func TestErrorEnvelope(t *testing.T) { + cases := []struct { + err *AppError + status int + code string + }{ + {Validation("bad", map[string]string{"email": "required"}), 400, "VALIDATION_FAILED"}, + {Unauthorized("no"), 401, "AUTH_UNAUTHORIZED"}, + {Forbidden("no"), 403, "AUTH_FORBIDDEN"}, + {NotFound("no"), 404, "RESOURCE_NOT_FOUND"}, + {Conflict("no"), 409, "RESOURCE_CONFLICT"}, + {RateLimited("no"), 429, "RATE_LIMITED"}, + {Upstream("no"), 502, "UPSTREAM_FAILED"}, + {Unavailable("no"), 503, "SERVICE_UNAVAILABLE"}, + {Internal(), 500, "INTERNAL_ERROR"}, + } + for _, c := range cases { + t.Run(c.code, func(t *testing.T) { + r := NewRouter(Config{Name: "t", Logger: quietLogger()}) + r.Get("/x", Handle(func(w http.ResponseWriter, r *http.Request) error { return c.err })) + rec := do(t, r, "GET", "/x", map[string]string{"X-Request-Id": "req-1"}) + body := decode(t, rec) + if rec.Code != c.status || body["code"] != c.code || body["requestId"] != "req-1" || body["error"] == "" { + t.Fatalf("got %d %v", rec.Code, body) + } + }) + } +} + +func TestValidationDetailsAreReturned(t *testing.T) { + r := NewRouter(Config{Name: "t", Logger: quietLogger()}) + r.Get("/x", Handle(func(w http.ResponseWriter, r *http.Request) error { + return Validation("bad", map[string]string{"email": "required"}) + })) + body := decode(t, do(t, r, "GET", "/x", nil)) + details, _ := body["details"].(map[string]any) + if details["email"] != "required" { + t.Fatalf("details missing: %v", body) + } +} + +func TestUnknownErrorDoesNotLeak(t *testing.T) { + r := NewRouter(Config{Name: "t", Logger: quietLogger()}) + r.Get("/x", Handle(func(w http.ResponseWriter, r *http.Request) error { + return errors.New("password=hunter2 at db.internal:5432") + })) + rec := do(t, r, "GET", "/x", nil) + if rec.Code != 500 || strings.Contains(rec.Body.String(), "hunter2") { + t.Fatalf("leaked or wrong status: %d %s", rec.Code, rec.Body.String()) + } +} + +func TestPanicIsRecoveredWithoutLeak(t *testing.T) { + r := NewRouter(Config{Name: "t", Logger: quietLogger()}) + r.Get("/boom", func(w http.ResponseWriter, r *http.Request) { panic("secret internal detail") }) + rec := do(t, r, "GET", "/boom", nil) + body := decode(t, rec) + if rec.Code != 500 || body["code"] != "INTERNAL_ERROR" || strings.Contains(rec.Body.String(), "secret") { + t.Fatalf("got %d %s", rec.Code, rec.Body.String()) + } +} + +func TestNotFoundAndMethodNotAllowedUseEnvelope(t *testing.T) { + r := NewRouter(Config{Name: "t", Logger: quietLogger()}) + if body := decode(t, do(t, r, "GET", "/nope", nil)); body["code"] != "RESOURCE_NOT_FOUND" { + t.Fatalf("404 body: %v", body) + } + if rec := do(t, r, "POST", "/health", nil); rec.Code != 405 || decode(t, rec)["code"] != "METHOD_NOT_ALLOWED" { + t.Fatalf("405: %d %s", rec.Code, rec.Body.String()) + } +} + +func TestRequestID(t *testing.T) { + r := NewRouter(Config{Name: "t", Logger: quietLogger()}) + t.Run("reuses a safe incoming id", func(t *testing.T) { + rec := do(t, r, "GET", "/health", map[string]string{"X-Request-Id": "abc-123"}) + if got := rec.Header().Get("X-Request-Id"); got != "abc-123" { + t.Fatalf("got %q", got) + } + }) + t.Run("generates one when missing", func(t *testing.T) { + if got := do(t, r, "GET", "/health", nil).Header().Get("X-Request-Id"); len(got) != 32 { + t.Fatalf("got %q", got) + } + }) + t.Run("replaces an unsafe id", func(t *testing.T) { + got := do(t, r, "GET", "/health", map[string]string{"X-Request-Id": "bad id\twith spaces"}).Header().Get("X-Request-Id") + if got == "bad id\twith spaces" || len(got) != 32 { + t.Fatalf("got %q", got) + } + }) + t.Run("is available in the handler context", func(t *testing.T) { + var seen string + r.Get("/ctx", func(w http.ResponseWriter, r *http.Request) { seen = RequestID(r.Context()) }) + do(t, r, "GET", "/ctx", map[string]string{"X-Request-Id": "ctx-1"}) + if seen != "ctx-1" { + t.Fatalf("got %q", seen) + } + }) +} + +func TestHealth(t *testing.T) { + r := NewRouter(Config{Name: "payments", Version: "1.2.3", Logger: quietLogger()}) + rec := do(t, r, "GET", "/health", nil) + body := decode(t, rec) + if rec.Code != 200 || body["status"] != "ok" || body["name"] != "payments" || body["version"] != "1.2.3" { + t.Fatalf("got %d %v", rec.Code, body) + } + if _, ok := body["uptimeSeconds"]; !ok { + t.Fatal("uptimeSeconds missing") + } +} + +func okCheck(context.Context) error { return nil } + +func failCheck(context.Context) error { return errors.New("down") } + +func TestReady(t *testing.T) { + cases := []struct { + name string + checks []Check + code int + status string + }{ + {"no checks", nil, 200, "ready"}, + {"all ok", []Check{{"db", true, okCheck}}, 200, "ready"}, + {"optional failing is degraded", []Check{{"db", true, okCheck}, {"vendor", false, failCheck}}, 200, "degraded"}, + {"required failing is 503", []Check{{"db", true, failCheck}, {"vendor", false, okCheck}}, 503, "unavailable"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + r := NewRouter(Config{Name: "t", Logger: quietLogger(), Checks: c.checks}) + rec := do(t, r, "GET", "/ready", nil) + if rec.Code != c.code || decode(t, rec)["status"] != c.status { + t.Fatalf("got %d %s", rec.Code, rec.Body.String()) + } + if strings.Contains(rec.Body.String(), "down") { + t.Fatal("check error text leaked") + } + }) + } +} + +func TestReadyCheckTimesOut(t *testing.T) { + slow := func(ctx context.Context) error { + <-ctx.Done() + return ctx.Err() + } + r := NewRouter(Config{Name: "t", Logger: quietLogger(), Checks: []Check{{"slow", true, slow}}}) + start := time.Now() + rec := do(t, r, "GET", "/ready", nil) + if rec.Code != 503 || time.Since(start) > 4*time.Second { + t.Fatalf("got %d after %s", rec.Code, time.Since(start)) + } +} + +func signed(t *testing.T, secret string, mutate func(*ServiceClaims)) string { + t.Helper() + claims := ServiceClaims{ + SchoolID: "school-1", + RegisteredClaims: jwt.RegisteredClaims{ + Issuer: testIssuer, + Audience: jwt.ClaimStrings{testAudience}, + Subject: "user-1", + ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Minute)), + }, + } + if mutate != nil { + mutate(&claims) + } + tok, err := SignServiceToken(secret, claims) + if err != nil { + t.Fatal(err) + } + return tok +} + +func TestServiceToken(t *testing.T) { + v, err := NewServiceTokenVerifier(testSecret, testIssuer, testAudience) + if err != nil { + t.Fatal(err) + } + r := NewRouter(Config{Name: "t", Logger: quietLogger()}) + var got *ServiceClaims + r.With(RequireServiceToken(v)).Get("/secure", func(w http.ResponseWriter, r *http.Request) { + got, _ = ClaimsFrom(r.Context()) + }) + call := func(auth string) *httptest.ResponseRecorder { + h := map[string]string{} + if auth != "" { + h["Authorization"] = auth + } + return do(t, r, "GET", "/secure", h) + } + + t.Run("valid", func(t *testing.T) { + rec := call("Bearer " + signed(t, testSecret, nil)) + if rec.Code != 200 || got == nil || got.SchoolID != "school-1" || got.Subject != "user-1" { + t.Fatalf("got %d %+v", rec.Code, got) + } + }) + t.Run("expired", func(t *testing.T) { + tok := signed(t, testSecret, func(c *ServiceClaims) { c.ExpiresAt = jwt.NewNumericDate(time.Now().Add(-time.Minute)) }) + if rec := call("Bearer " + tok); rec.Code != 401 { + t.Fatalf("got %d", rec.Code) + } + }) + t.Run("no expiry", func(t *testing.T) { + tok := signed(t, testSecret, func(c *ServiceClaims) { c.ExpiresAt = nil }) + if rec := call("Bearer " + tok); rec.Code != 401 { + t.Fatalf("got %d", rec.Code) + } + }) + t.Run("wrong audience", func(t *testing.T) { + tok := signed(t, testSecret, func(c *ServiceClaims) { c.Audience = jwt.ClaimStrings{"identity"} }) + if rec := call("Bearer " + tok); rec.Code != 401 { + t.Fatalf("got %d", rec.Code) + } + }) + t.Run("wrong issuer", func(t *testing.T) { + tok := signed(t, testSecret, func(c *ServiceClaims) { c.Issuer = "attacker" }) + if rec := call("Bearer " + tok); rec.Code != 401 { + t.Fatalf("got %d", rec.Code) + } + }) + t.Run("wrong secret", func(t *testing.T) { + if rec := call("Bearer " + signed(t, "another-secret-another-secret-12", nil)); rec.Code != 401 { + t.Fatalf("got %d", rec.Code) + } + }) + t.Run("missing school", func(t *testing.T) { + tok := signed(t, testSecret, func(c *ServiceClaims) { c.SchoolID = "" }) + if rec := call("Bearer " + tok); rec.Code != 401 { + t.Fatalf("got %d", rec.Code) + } + }) + t.Run("alg none is rejected", func(t *testing.T) { + tok, _ := jwt.NewWithClaims(jwt.SigningMethodNone, ServiceClaims{SchoolID: "s", RegisteredClaims: jwt.RegisteredClaims{ + Issuer: testIssuer, Audience: jwt.ClaimStrings{testAudience}, Subject: "u", + ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Minute)), + }}).SignedString(jwt.UnsafeAllowNoneSignatureType) + if rec := call("Bearer " + tok); rec.Code != 401 { + t.Fatalf("got %d", rec.Code) + } + }) + t.Run("missing header", func(t *testing.T) { + rec := call("") + if rec.Code != 401 || decode(t, rec)["code"] != "AUTH_UNAUTHORIZED" { + t.Fatalf("got %d %s", rec.Code, rec.Body.String()) + } + }) +} + +func TestServiceTokenRefusesMissingSecret(t *testing.T) { + if _, err := NewServiceTokenVerifier("", testIssuer, testAudience); err == nil { + t.Fatal("expected error for empty secret") + } + t.Setenv(serviceTokenSecretEnv, "") + if _, err := NewServiceTokenVerifierFromEnv(testIssuer, testAudience); err == nil { + t.Fatal("expected error when env is unset") + } + t.Setenv(serviceTokenSecretEnv, testSecret) + if _, err := NewServiceTokenVerifierFromEnv(testIssuer, testAudience); err != nil { + t.Fatalf("unexpected: %v", err) + } +} + +type fakeTx struct { + pgx.Tx + execSQL string + execArgs []any + execErr error + committed bool + rolled bool +} + +func (f *fakeTx) Exec(_ context.Context, sql string, args ...any) (pgconn.CommandTag, error) { + f.execSQL, f.execArgs = sql, args + return pgconn.CommandTag{}, f.execErr +} +func (f *fakeTx) Commit(context.Context) error { f.committed = true; return nil } +func (f *fakeTx) Rollback(context.Context) error { f.rolled = true; return nil } + +type fakePool struct{ tx *fakeTx } + +func (p fakePool) Begin(context.Context) (pgx.Tx, error) { return p.tx, nil } + +func TestWithSchool(t *testing.T) { + t.Run("sets app.school_id before fn and commits", func(t *testing.T) { + tx := &fakeTx{} + err := WithSchool(context.Background(), fakePool{tx}, "school-9", func(context.Context, pgx.Tx) error { + if !strings.Contains(tx.execSQL, "set_config('app.school_id', $1, true)") || tx.execArgs[0] != "school-9" { + t.Fatalf("setting not applied before fn: %q %v", tx.execSQL, tx.execArgs) + } + return nil + }) + if err != nil || !tx.committed { + t.Fatalf("err=%v committed=%v", err, tx.committed) + } + }) + t.Run("fn error rolls back without commit", func(t *testing.T) { + tx := &fakeTx{} + boom := errors.New("boom") + err := WithSchool(context.Background(), fakePool{tx}, "s", func(context.Context, pgx.Tx) error { return boom }) + if !errors.Is(err, boom) || tx.committed || !tx.rolled { + t.Fatalf("err=%v committed=%v rolled=%v", err, tx.committed, tx.rolled) + } + }) + t.Run("set_config failure never runs fn", func(t *testing.T) { + tx := &fakeTx{execErr: errors.New("db down")} + ran := false + err := WithSchool(context.Background(), fakePool{tx}, "s", func(context.Context, pgx.Tx) error { ran = true; return nil }) + if err == nil || ran || tx.committed { + t.Fatalf("err=%v ran=%v committed=%v", err, ran, tx.committed) + } + }) + t.Run("empty school id is a validation error", func(t *testing.T) { + err := WithSchool(context.Background(), fakePool{&fakeTx{}}, "", func(context.Context, pgx.Tx) error { return nil }) + var appErr *AppError + if !errors.As(err, &appErr) || appErr.Status != 400 { + t.Fatalf("got %v", err) + } + }) +} diff --git a/packages/kyfaru-kit/logger.go b/packages/kyfaru-kit/logger.go new file mode 100644 index 0000000..d4f711f --- /dev/null +++ b/packages/kyfaru-kit/logger.go @@ -0,0 +1,66 @@ +package kit + +import ( + "context" + "log/slog" + "net/http" + "os" + "runtime/debug" + "time" +) + +const loggerKey ctxKey = "logger" + +// NewLogger returns a JSON slog logger tagged with the service name. +func NewLogger(service string) *slog.Logger { + return slog.New(slog.NewJSONHandler(os.Stdout, nil)).With("service", service) +} + +// LoggerFrom returns the request logger from ctx, falling back to the default logger. +func LoggerFrom(ctx context.Context) *slog.Logger { + if l, ok := ctx.Value(loggerKey).(*slog.Logger); ok { + return l + } + return slog.Default() +} + +type statusWriter struct { + http.ResponseWriter + status int +} + +func (s *statusWriter) WriteHeader(code int) { + s.status = code + s.ResponseWriter.WriteHeader(code) +} + +// LoggingMiddleware puts a requestId-tagged logger in ctx and logs one line per request. +func LoggingMiddleware(base *slog.Logger) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + l := base.With("requestId", RequestID(r.Context())) + sw := &statusWriter{ResponseWriter: w, status: http.StatusOK} + next.ServeHTTP(sw, r.WithContext(context.WithValue(r.Context(), loggerKey, l))) + l.Info("request", "method", r.Method, "path", r.URL.Path, "status", sw.status, "ms", time.Since(start).Milliseconds()) + }) + } +} + +// RecoverMiddleware turns a panic into a logged 500 envelope without leaking internals. +func RecoverMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer func() { + rec := recover() + if rec == nil { + return + } + if rec == http.ErrAbortHandler { + panic(rec) // net/http uses this value to abort a response on purpose + } + LoggerFrom(r.Context()).Error("panic", "panic", rec, "stack", string(debug.Stack())) + WriteError(w, r, Internal()) + }() + next.ServeHTTP(w, r) + }) +} diff --git a/packages/kyfaru-kit/requestid.go b/packages/kyfaru-kit/requestid.go new file mode 100644 index 0000000..1c515c9 --- /dev/null +++ b/packages/kyfaru-kit/requestid.go @@ -0,0 +1,54 @@ +package kit + +import ( + "context" + "crypto/rand" + "encoding/hex" + "net/http" +) + +const ( + requestIDHeader = "X-Request-Id" + maxRequestIDLen = 128 // longer ids are replaced so logs cannot be flooded +) + +type ctxKey string + +const requestIDKey ctxKey = "requestId" + +func newRequestID() string { + b := make([]byte, 16) + _, _ = rand.Read(b) // crypto/rand does not fail on supported platforms + return hex.EncodeToString(b) +} + +func isSafeRequestID(id string) bool { + if id == "" || len(id) > maxRequestIDLen { + return false + } + for _, c := range id { + ok := c == '-' || c == '_' || (c >= '0' && c <= '9') || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') + if !ok { + return false + } + } + return true +} + +// RequestIDMiddleware reuses a safe incoming x-request-id or makes one, then echoes it. +func RequestIDMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + id := r.Header.Get(requestIDHeader) + if !isSafeRequestID(id) { + id = newRequestID() + } + w.Header().Set(requestIDHeader, id) + next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), requestIDKey, id))) + }) +} + +// RequestID returns the request id stored in ctx, or an empty string. +func RequestID(ctx context.Context) string { + id, _ := ctx.Value(requestIDKey).(string) + return id +} diff --git a/packages/kyfaru-kit/school.go b/packages/kyfaru-kit/school.go new file mode 100644 index 0000000..f7c372c --- /dev/null +++ b/packages/kyfaru-kit/school.go @@ -0,0 +1,34 @@ +package kit + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5" +) + +// TxBeginner is satisfied by *pgxpool.Pool. +type TxBeginner interface { + Begin(ctx context.Context) (pgx.Tx, error) +} + +// WithSchool runs fn in a transaction whose app.school_id is set, so RLS applies to every query in fn. +// The setting is transaction-local (set_config third arg true) and cannot leak to other pool users. +func WithSchool(ctx context.Context, pool TxBeginner, schoolID string, fn func(ctx context.Context, tx pgx.Tx) error) error { + if schoolID == "" { + return Validation("schoolId is required.", nil) + } + tx, err := pool.Begin(ctx) + if err != nil { + return fmt.Errorf("begin tx: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() // no-op after a successful commit + + if _, err := tx.Exec(ctx, "select set_config('app.school_id', $1, true)", schoolID); err != nil { + return fmt.Errorf("set app.school_id: %w", err) + } + if err := fn(ctx, tx); err != nil { + return err + } + return tx.Commit(ctx) +} diff --git a/packages/kyfaru-kit/server.go b/packages/kyfaru-kit/server.go new file mode 100644 index 0000000..db2275e --- /dev/null +++ b/packages/kyfaru-kit/server.go @@ -0,0 +1,58 @@ +package kit + +import ( + "context" + "errors" + "log/slog" + "net/http" + "time" + + "github.com/go-chi/chi/v5" +) + +const shutdownTimeout = 10 * time.Second // lets in-flight requests finish on SIGTERM + +// Config describes one service for NewRouter. +type Config struct { + Name string + Version string + Logger *slog.Logger + Checks []Check +} + +// NewRouter returns a chi router with request-id, logging, recover, /health, /ready and envelope 404/405. +func NewRouter(cfg Config) chi.Router { + if cfg.Logger == nil { + cfg.Logger = NewLogger(cfg.Name) + } + started := time.Now() + r := chi.NewRouter() + r.Use(RequestIDMiddleware, LoggingMiddleware(cfg.Logger), RecoverMiddleware) + r.Get("/health", healthHandler(cfg.Name, cfg.Version, started)) + r.Get("/ready", readyHandler(cfg.Name, cfg.Version, cfg.Checks)) + r.NotFound(Handle(func(w http.ResponseWriter, r *http.Request) error { return NotFound("Route not found.") })) + r.MethodNotAllowed(Handle(func(w http.ResponseWriter, r *http.Request) error { + return newErr(http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "Method not allowed.", nil) + })) + return r +} + +// Serve runs handler on addr until ctx is cancelled, then shuts down gracefully. +func Serve(ctx context.Context, addr string, handler http.Handler, logger *slog.Logger) error { + srv := &http.Server{Addr: addr, Handler: handler, ReadHeaderTimeout: 5 * time.Second} + errCh := make(chan error, 1) + go func() { errCh <- srv.ListenAndServe() }() + logger.Info("listening", "addr", addr) + + select { + case err := <-errCh: + return err + case <-ctx.Done(): + shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout) + defer cancel() + if err := srv.Shutdown(shutdownCtx); err != nil && !errors.Is(err, http.ErrServerClosed) { + return err + } + return nil + } +} diff --git a/packages/kyfaru-kit/servicetoken.go b/packages/kyfaru-kit/servicetoken.go new file mode 100644 index 0000000..9c9a41e --- /dev/null +++ b/packages/kyfaru-kit/servicetoken.go @@ -0,0 +1,91 @@ +package kit + +import ( + "context" + "errors" + "net/http" + "os" + "strings" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +const ( + serviceTokenSecretEnv = "SERVICE_TOKEN_SECRET" + tokenLeeway = 5 * time.Second // tolerates small clock drift between services +) + +// ServiceClaims are the verified claims of a service token. +type ServiceClaims struct { + SchoolID string `json:"schoolId"` + jwt.RegisteredClaims +} + +// ServiceTokenVerifier checks HS256 service tokens for one issuer and audience. +type ServiceTokenVerifier struct { + secret []byte + issuer string + audience string +} + +// NewServiceTokenVerifier refuses an empty secret so there is never a fallback key. +func NewServiceTokenVerifier(secret, issuer, audience string) (*ServiceTokenVerifier, error) { + if secret == "" { + return nil, errors.New("service token secret is not set") + } + return &ServiceTokenVerifier{secret: []byte(secret), issuer: issuer, audience: audience}, nil +} + +// NewServiceTokenVerifierFromEnv reads SERVICE_TOKEN_SECRET and fails if it is unset. +func NewServiceTokenVerifierFromEnv(issuer, audience string) (*ServiceTokenVerifier, error) { + return NewServiceTokenVerifier(os.Getenv(serviceTokenSecretEnv), issuer, audience) +} + +// Verify parses a token and returns its claims, or an Unauthorized error. +func (v *ServiceTokenVerifier) Verify(token string) (*ServiceClaims, error) { + claims := &ServiceClaims{} + _, err := jwt.ParseWithClaims(token, claims, func(*jwt.Token) (any, error) { return v.secret, nil }, + jwt.WithValidMethods([]string{"HS256"}), + jwt.WithIssuer(v.issuer), + jwt.WithAudience(v.audience), + jwt.WithExpirationRequired(), + jwt.WithLeeway(tokenLeeway), + ) + if err != nil || claims.Subject == "" || claims.SchoolID == "" { + return nil, Unauthorized("Invalid or expired service token.") + } + return claims, nil +} + +// SignServiceToken creates a token for callers and tests. +func SignServiceToken(secret string, claims ServiceClaims) (string, error) { + return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(secret)) +} + +const claimsKey ctxKey = "serviceClaims" + +// ClaimsFrom returns the verified service claims stored by RequireServiceToken. +func ClaimsFrom(ctx context.Context) (*ServiceClaims, bool) { + c, ok := ctx.Value(claimsKey).(*ServiceClaims) + return c, ok +} + +// RequireServiceToken rejects requests without a valid bearer service token. +func RequireServiceToken(v *ServiceTokenVerifier) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + raw, found := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ") + if !found || raw == "" { + WriteError(w, r, Unauthorized("Missing service token.")) + return + } + claims, err := v.Verify(raw) + if err != nil { + WriteError(w, r, err) + return + } + next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), claimsKey, claims))) + }) + } +} diff --git a/packages/service-kit/README.md b/packages/service-kit/README.md new file mode 100644 index 0000000..00522a3 --- /dev/null +++ b/packages/service-kit/README.md @@ -0,0 +1,4 @@ +# @stackable/service-kit + +Hono over Fastify: smaller, web-standard, and `app.request()` tests need no server or port. +Gives every TS service the same envelope, request id, `/health`, `/ready` and service-token check. diff --git a/packages/service-kit/package.json b/packages/service-kit/package.json new file mode 100644 index 0000000..c815993 --- /dev/null +++ b/packages/service-kit/package.json @@ -0,0 +1,23 @@ +{ + "name": "@stackable/service-kit", + "version": "0.1.0", + "private": true, + "type": "module", + "exports": { ".": "./src/index.ts" }, + "scripts": { + "typecheck": "tsc --noEmit", + "test": "vitest run --root ../.." + }, + "dependencies": { + "@stackable/contracts": "workspace:*", + "hono": "^4.9.0", + "jose": "^6.0.0", + "pg": "^8.16.0", + "zod": "^4.0.0" + }, + "devDependencies": { + "@types/node": "^24.0.0", + "@types/pg": "^8.15.0", + "typescript": "^5.9.0" + } +} diff --git a/packages/service-kit/src/errors.test.ts b/packages/service-kit/src/errors.test.ts new file mode 100644 index 0000000..d62c159 --- /dev/null +++ b/packages/service-kit/src/errors.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; +import { ErrorEnvelopeSchema } from "@stackable/contracts"; +import { errors, toErrorResponse } from "./errors"; + +describe("toErrorResponse", () => { + const cases = [ + ["validation", errors.validation(), 400, "REQUEST_VALIDATION_FAILED"], + ["unauthorized", errors.unauthorized(), 401, "AUTH_UNAUTHORIZED"], + ["forbidden", errors.forbidden(), 403, "AUTH_FORBIDDEN"], + ["notFound", errors.notFound(), 404, "RESOURCE_NOT_FOUND"], + ["conflict", errors.conflict(), 409, "RESOURCE_CONFLICT"], + ["rateLimited", errors.rateLimited(), 429, "RATE_LIMIT_EXCEEDED"], + ["upstream", errors.upstream(), 502, "UPSTREAM_FAILED"], + ["unavailable", errors.unavailable(), 503, "SERVICE_UNAVAILABLE"], + ["internal", errors.internal(), 500, "INTERNAL_ERROR"], + ] as const; + + it.each(cases)("maps %s to its status and code", (_name, err, status, code) => { + const res = toErrorResponse(err, "req-1"); + expect(res.status).toBe(status); + expect(res.body.code).toBe(code); + expect(ErrorEnvelopeSchema.safeParse(res.body).success).toBe(true); + expect(res.body.requestId).toBe("req-1"); + }); + + it("turns unknown errors into a generic 500 without leaking the message", () => { + const res = toErrorResponse(new Error("password=hunter2 in connection string"), "req-2"); + expect(res.status).toBe(500); + expect(JSON.stringify(res.body)).not.toContain("hunter2"); + }); + + it("turns a ZodError into a 400 listing the failed fields", () => { + const parsed = z.object({ age: z.number() }).safeParse({ age: "x" }); + const res = toErrorResponse(parsed.error, "req-3"); + expect(res.status).toBe(400); + expect(res.body.details).toEqual({ fields: [{ path: "age", message: expect.any(String) }] }); + }); +}); diff --git a/packages/service-kit/src/errors.ts b/packages/service-kit/src/errors.ts new file mode 100644 index 0000000..a4c3c54 --- /dev/null +++ b/packages/service-kit/src/errors.ts @@ -0,0 +1,55 @@ +import { ZodError } from "zod"; +import { HTTPException } from "hono/http-exception"; +import type { ContentfulStatusCode } from "hono/utils/http-status"; +import type { ErrorEnvelope } from "@stackable/contracts"; + +type Details = Record; + +/** An expected failure; its message is safe to show the caller. */ +export class AppError extends Error { + readonly status: ContentfulStatusCode; + readonly code: string; + readonly details: Details; + + constructor(status: ContentfulStatusCode, code: string, message: string, details: Details = {}) { + super(message); + this.name = "AppError"; + this.status = status; + this.code = code; + this.details = details; + } +} + +export const errors = { + validation: (message = "Request validation failed", details: Details = {}) => + new AppError(400, "REQUEST_VALIDATION_FAILED", message, details), + unauthorized: (message = "Authentication required") => new AppError(401, "AUTH_UNAUTHORIZED", message), + forbidden: (message = "You do not have access to this resource") => new AppError(403, "AUTH_FORBIDDEN", message), + notFound: (message = "Resource not found") => new AppError(404, "RESOURCE_NOT_FOUND", message), + conflict: (message = "Resource state conflict", details: Details = {}) => + new AppError(409, "RESOURCE_CONFLICT", message, details), + rateLimited: (message = "Too many requests") => new AppError(429, "RATE_LIMIT_EXCEEDED", message), + upstream: (message = "A dependency failed") => new AppError(502, "UPSTREAM_FAILED", message), + unavailable: (message = "Service temporarily unavailable") => new AppError(503, "SERVICE_UNAVAILABLE", message), + internal: () => new AppError(500, "INTERNAL_ERROR", "Something went wrong"), +}; + +/** Maps any thrown value to a status and envelope; unknown errors become a generic 500. */ +export function toErrorResponse(err: unknown, requestId: string): { status: ContentfulStatusCode; body: ErrorEnvelope } { + let appError: AppError; + if (err instanceof AppError) { + appError = err; + } else if (err instanceof ZodError) { + appError = errors.validation("Request validation failed", { + fields: err.issues.map((i) => ({ path: i.path.join("."), message: i.message })), + }); + } else if (err instanceof HTTPException && err.status === 400) { + appError = errors.validation("Malformed request"); + } else { + appError = errors.internal(); + } + return { + status: appError.status, + body: { error: appError.message, code: appError.code, details: appError.details, requestId }, + }; +} diff --git a/packages/service-kit/src/index.ts b/packages/service-kit/src/index.ts new file mode 100644 index 0000000..768b8f7 --- /dev/null +++ b/packages/service-kit/src/index.ts @@ -0,0 +1,7 @@ +export { createService, requireServiceToken, READY_CHECK_TIMEOUT_MS } from "./service"; +export type { AppEnv, ReadyCheck, ServiceOptions } from "./service"; +export { AppError, errors, toErrorResponse } from "./errors"; +export { signServiceToken, verifyServiceToken, SERVICE_TOKEN_ISSUER } from "./token"; +export { withSchool } from "./with-school"; +export { createLogger } from "./logger"; +export type { Logger } from "./logger"; diff --git a/packages/service-kit/src/logger.ts b/packages/service-kit/src/logger.ts new file mode 100644 index 0000000..1a3c496 --- /dev/null +++ b/packages/service-kit/src/logger.ts @@ -0,0 +1,20 @@ +export type LogFields = Record; + +export interface Logger { + info(msg: string, fields?: LogFields): void; + warn(msg: string, fields?: LogFields): void; + error(msg: string, fields?: LogFields): void; + child(fields: LogFields): Logger; +} + +/** One JSON line per event on stdout so any log shipper can parse it. */ +export function createLogger(service: string, base: LogFields = {}): Logger { + const write = (level: string, msg: string, fields: LogFields = {}) => + console.log(JSON.stringify({ time: new Date().toISOString(), level, service, msg, ...base, ...fields })); + return { + info: (msg, fields) => write("info", msg, fields), + warn: (msg, fields) => write("warn", msg, fields), + error: (msg, fields) => write("error", msg, fields), + child: (fields) => createLogger(service, { ...base, ...fields }), + }; +} diff --git a/packages/service-kit/src/service.test.ts b/packages/service-kit/src/service.test.ts new file mode 100644 index 0000000..d918b7e --- /dev/null +++ b/packages/service-kit/src/service.test.ts @@ -0,0 +1,99 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createService } from "./service"; +import { errors } from "./errors"; + +beforeEach(() => { + vi.spyOn(console, "log").mockImplementation(() => undefined); +}); +afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); +}); + +describe("request id", () => { + it("echoes an incoming x-request-id and puts it in error bodies", async () => { + const app = createService({ name: "t", version: "1" }); + app.get("/boom", () => { + throw errors.conflict("nope"); + }); + const res = await app.request("/boom", { headers: { "x-request-id": "abc-123" } }); + expect(res.headers.get("x-request-id")).toBe("abc-123"); + expect(await res.json()).toMatchObject({ code: "RESOURCE_CONFLICT", requestId: "abc-123" }); + }); + + it("generates an id when missing or malformed", async () => { + const app = createService({ name: "t", version: "1" }); + const missing = await app.request("/health"); + const bad = await app.request("/health", { headers: { "x-request-id": "bad id with spaces" } }); + expect(missing.headers.get("x-request-id")).toMatch(/^[0-9a-f-]{36}$/); + expect(bad.headers.get("x-request-id")).toMatch(/^[0-9a-f-]{36}$/); + }); +}); + +describe("error handling", () => { + it("hides internals of unexpected errors and returns the envelope", async () => { + const app = createService({ name: "t", version: "1" }); + app.get("/crash", () => { + throw new Error("db password leaked"); + }); + const res = await app.request("/crash"); + expect(res.status).toBe(500); + expect(JSON.stringify(await res.json())).not.toContain("leaked"); + }); + + it("returns the envelope for unknown routes", async () => { + const res = await createService({ name: "t", version: "1" }).request("/nope"); + expect(res.status).toBe(404); + expect(await res.json()).toMatchObject({ code: "RESOURCE_NOT_FOUND" }); + }); +}); + +describe("/health", () => { + it("is always 200 with name, version and uptime", async () => { + const res = await createService({ name: "svc", version: "9.9.9" }).request("/health"); + expect(res.status).toBe(200); + expect(await res.json()).toMatchObject({ status: "ok", name: "svc", version: "9.9.9", uptimeSeconds: expect.any(Number) }); + }); +}); + +describe("/ready", () => { + const pass = async () => undefined; + const fail = async () => { + throw new Error("down"); + }; + + it("is ready when all checks pass", async () => { + const app = createService({ name: "t", version: "1", checks: [{ name: "db", run: pass }] }); + const res = await app.request("/ready"); + expect(res.status).toBe(200); + expect(await res.json()).toMatchObject({ status: "ready", checks: [{ name: "db", ok: true }] }); + }); + + it("is degraded (200) when only an optional check fails", async () => { + const app = createService({ + name: "t", + version: "1", + checks: [{ name: "db", run: pass }, { name: "resend", run: fail, required: false }], + }); + const res = await app.request("/ready"); + expect(res.status).toBe(200); + expect(await res.json()).toMatchObject({ status: "degraded" }); + }); + + it("is 503 when a required check fails", async () => { + const app = createService({ name: "t", version: "1", checks: [{ name: "db", run: fail }] }); + const res = await app.request("/ready"); + expect(res.status).toBe(503); + expect(await res.json()).toMatchObject({ status: "not_ready", checks: [{ name: "db", ok: false }] }); + }); + + it("fails a check that hangs past the timeout", async () => { + vi.useFakeTimers(); + const hang = () => new Promise(() => undefined); + const app = createService({ name: "t", version: "1", checks: [{ name: "slow", run: hang }] }); + const pending = app.request("/ready"); + await vi.advanceTimersByTimeAsync(2100); + const res = await pending; + expect(res.status).toBe(503); + }); +}); diff --git a/packages/service-kit/src/service.ts b/packages/service-kit/src/service.ts new file mode 100644 index 0000000..bd3b4ab --- /dev/null +++ b/packages/service-kit/src/service.ts @@ -0,0 +1,106 @@ +import { Hono, type MiddlewareHandler } from "hono"; +import type { ServiceTokenClaims } from "@stackable/contracts"; +import { errors, toErrorResponse } from "./errors"; +import { createLogger, type Logger } from "./logger"; +import { verifyServiceToken } from "./token"; + +export const READY_CHECK_TIMEOUT_MS = 2000; // a hung dependency must not hang the probe +const REQUEST_ID_PATTERN = /^[\w.-]{1,128}$/; // reject anything that could forge log lines + +export interface ReadyCheck { + name: string; + run: () => Promise; + /** Defaults to true; a failing optional check only marks the service degraded. */ + required?: boolean; +} + +export interface ServiceOptions { + name: string; + version: string; + checks?: ReadyCheck[]; +} + +export type AppEnv = { + Variables: { requestId: string; log: Logger; claims: ServiceTokenClaims }; +}; + +async function runCheck(check: ReadyCheck) { + const started = Date.now(); + let timer: NodeJS.Timeout | undefined; + let ok = true; + let failure: unknown; + try { + await Promise.race([ + check.run(), + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error("check timed out")), READY_CHECK_TIMEOUT_MS); + }), + ]); + } catch (err) { + ok = false; + failure = err; + } finally { + clearTimeout(timer); + } + return { name: check.name, ok, latencyMs: Date.now() - started, required: check.required !== false, failure }; +} + +/** Builds a Hono app with request ids, the error envelope, /health and /ready already wired. */ +export function createService({ name, version, checks = [] }: ServiceOptions): Hono { + const app = new Hono(); + const logger = createLogger(name); + const startedAt = Date.now(); + + const requestId: MiddlewareHandler = async (c, next) => { + const incoming = c.req.header("x-request-id"); + const id = incoming && REQUEST_ID_PATTERN.test(incoming) ? incoming : crypto.randomUUID(); + const log = logger.child({ requestId: id }); + c.set("requestId", id); + c.set("log", log); + c.header("x-request-id", id); + const began = Date.now(); + await next(); + log.info("request", { method: c.req.method, path: c.req.path, status: c.res.status, durationMs: Date.now() - began }); + }; + app.use("*", requestId); + + app.get("/health", (c) => + c.json({ status: "ok", name, version, uptimeSeconds: Math.floor((Date.now() - startedAt) / 1000) }), + ); + + app.get("/ready", async (c) => { + const results = await Promise.all(checks.map(runCheck)); + for (const r of results) { + if (!r.ok) c.var.log.warn("ready check failed", { check: r.name, reason: String(r.failure) }); + } + const body = results.map(({ name: checkName, ok, latencyMs }) => ({ name: checkName, ok, latencyMs })); + if (results.some((r) => !r.ok && r.required)) return c.json({ status: "not_ready", checks: body }, 503); + return c.json({ status: results.some((r) => !r.ok) ? "degraded" : "ready", checks: body }); + }); + + app.notFound(() => { + throw errors.notFound("Route not found"); + }); + + app.onError((err, c) => { + const id = c.var.requestId ?? "unknown"; + const { status, body } = toErrorResponse(err, id); + if (status === 500) { + (c.var.log ?? logger).error("unhandled error", { requestId: id, err: String(err), stack: (err as Error).stack }); + } + return c.json(body, status); + }); + + return app; +} + +/** Guards a route: requires a valid Bearer service token for this audience and exposes its claims. */ +export function requireServiceToken(audience: string): MiddlewareHandler { + return async (c, next) => { + const header = c.req.header("authorization") ?? ""; + const token = header.startsWith("Bearer ") ? header.slice("Bearer ".length) : ""; + if (!token) throw errors.unauthorized("Missing service token"); + c.set("claims", await verifyServiceToken(token, audience)); + await next(); + }; +} diff --git a/packages/service-kit/src/token.test.ts b/packages/service-kit/src/token.test.ts new file mode 100644 index 0000000..5651515 --- /dev/null +++ b/packages/service-kit/src/token.test.ts @@ -0,0 +1,48 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { SignJWT } from "jose"; +import { signServiceToken, verifyServiceToken } from "./token"; + +const SECRET = "test-secret-test-secret-test-secret-1234"; +const base = { audience: "identity", sub: "user-1", schoolId: "school-1" }; + +beforeEach(() => { + process.env.SERVICE_TOKEN_SECRET = SECRET; +}); +afterEach(() => { + delete process.env.SERVICE_TOKEN_SECRET; +}); + +describe("service token", () => { + it("accepts a valid token and returns its claims", async () => { + const claims = await verifyServiceToken(await signServiceToken({ ...base, role: "teacher" }), "identity"); + expect(claims).toMatchObject({ sub: "user-1", schoolId: "school-1", aud: "identity", role: "teacher" }); + }); + + it("rejects an expired token", async () => { + const token = await signServiceToken({ ...base, ttlSeconds: -10 }); + await expect(verifyServiceToken(token, "identity")).rejects.toMatchObject({ status: 401 }); + }); + + it("rejects the wrong audience", async () => { + const token = await signServiceToken({ ...base, audience: "payments" }); + await expect(verifyServiceToken(token, "identity")).rejects.toMatchObject({ status: 401 }); + }); + + it("rejects a token signed with another secret", async () => { + const forged = await new SignJWT({ schoolId: "school-1" }) + .setProtectedHeader({ alg: "HS256" }) + .setIssuer("stackable-gateway") + .setAudience("identity") + .setSubject("user-1") + .setExpirationTime("1m") + .sign(new TextEncoder().encode("another-secret-another-secret-12345")); + await expect(verifyServiceToken(forged, "identity")).rejects.toMatchObject({ status: 401 }); + }); + + it("throws when the secret is unset, for signing and verifying", async () => { + const token = await signServiceToken(base); + delete process.env.SERVICE_TOKEN_SECRET; + await expect(signServiceToken(base)).rejects.toThrow("SERVICE_TOKEN_SECRET"); + await expect(verifyServiceToken(token, "identity")).rejects.toThrow("SERVICE_TOKEN_SECRET"); + }); +}); diff --git a/packages/service-kit/src/token.ts b/packages/service-kit/src/token.ts new file mode 100644 index 0000000..9723bd8 --- /dev/null +++ b/packages/service-kit/src/token.ts @@ -0,0 +1,52 @@ +import { SignJWT, jwtVerify } from "jose"; +import { ServiceTokenClaimsSchema, type ServiceTokenClaims } from "@stackable/contracts"; +import { errors } from "./errors"; + +export const SERVICE_TOKEN_ISSUER = "stackable-gateway"; +const MIN_SECRET_LENGTH = 32; // HS256 needs a key at least as long as its 256-bit hash +export const DEFAULT_TOKEN_TTL_SECONDS = 60; // short-lived so a leaked token is nearly useless + +// A missing secret must stop the service, never fall back to a default. +function getSecret(): Uint8Array { + const secret = process.env.SERVICE_TOKEN_SECRET; + if (!secret || secret.length < MIN_SECRET_LENGTH) { + throw new Error(`SERVICE_TOKEN_SECRET must be set to at least ${MIN_SECRET_LENGTH} characters`); + } + return new TextEncoder().encode(secret); +} + +export interface SignTokenInput { + audience: string; + sub: string; + schoolId: string; + role?: string; + ttlSeconds?: number; +} + +/** Issues a service token for one actor and school; used by the gateway and by tests. */ +export async function signServiceToken(input: SignTokenInput): Promise { + const now = Math.floor(Date.now() / 1000); + return new SignJWT({ schoolId: input.schoolId, ...(input.role ? { role: input.role } : {}) }) + .setProtectedHeader({ alg: "HS256" }) + .setIssuer(SERVICE_TOKEN_ISSUER) + .setAudience(input.audience) + .setSubject(input.sub) + .setIssuedAt(now) + .setExpirationTime(now + (input.ttlSeconds ?? DEFAULT_TOKEN_TTL_SECONDS)) + .sign(getSecret()); +} + +/** Returns the claims of a valid token for this audience; throws unauthorized otherwise. */ +export async function verifyServiceToken(token: string, audience: string): Promise { + const secret = getSecret(); + try { + const { payload } = await jwtVerify(token, secret, { + algorithms: ["HS256"], + issuer: SERVICE_TOKEN_ISSUER, + audience, + }); + return ServiceTokenClaimsSchema.parse(payload); + } catch { + throw errors.unauthorized("Invalid or expired service token"); + } +} diff --git a/packages/service-kit/src/with-school.test.ts b/packages/service-kit/src/with-school.test.ts new file mode 100644 index 0000000..c596379 --- /dev/null +++ b/packages/service-kit/src/with-school.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it, vi } from "vitest"; +import type { Pool } from "pg"; +import { withSchool } from "./with-school"; + +function fakePool() { + const query = vi.fn(async () => ({ rows: [] })); + const release = vi.fn(); + const pool = { connect: async () => ({ query, release }) } as unknown as Pool; + return { pool, query, release }; +} + +describe("withSchool", () => { + it("sets app.school_id inside a transaction and commits", async () => { + const { pool, query, release } = fakePool(); + const result = await withSchool(pool, "school-1", async () => "done"); + expect(result).toBe("done"); + expect(query.mock.calls).toEqual([ + ["BEGIN"], + ["select set_config('app.school_id', $1, true)", ["school-1"]], + ["COMMIT"], + ]); + expect(release).toHaveBeenCalledOnce(); + }); + + it("rolls back, rethrows and releases when fn fails", async () => { + const { pool, query, release } = fakePool(); + await expect(withSchool(pool, "school-1", async () => Promise.reject(new Error("x")))).rejects.toThrow("x"); + expect(query).toHaveBeenLastCalledWith("ROLLBACK"); + expect(release).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/service-kit/src/with-school.ts b/packages/service-kit/src/with-school.ts new file mode 100644 index 0000000..719ee89 --- /dev/null +++ b/packages/service-kit/src/with-school.ts @@ -0,0 +1,19 @@ +import type { Pool, PoolClient } from "pg"; + +/** Runs fn in a transaction scoped to one school so row-level security applies to every query in it. */ +export async function withSchool(pool: Pool, schoolId: string, fn: (client: PoolClient) => Promise): Promise { + const client = await pool.connect(); + try { + await client.query("BEGIN"); + // is_local=true drops the setting at commit, so a pooled connection never leaks a tenant. + await client.query("select set_config('app.school_id', $1, true)", [schoolId]); + const result = await fn(client); + await client.query("COMMIT"); + return result; + } catch (err) { + await client.query("ROLLBACK"); + throw err; + } finally { + client.release(); + } +} diff --git a/packages/service-kit/tsconfig.json b/packages/service-kit/tsconfig.json new file mode 100644 index 0000000..cc2c43a --- /dev/null +++ b/packages/service-kit/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2023", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "types": ["node"] + }, + "include": ["src"] +} diff --git a/stackable-app/pnpm-lock.yaml b/pnpm-lock.yaml similarity index 50% rename from stackable-app/pnpm-lock.yaml rename to pnpm-lock.yaml index eb843e3..91aff9f 100644 --- a/stackable-app/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,351 +7,431 @@ settings: importers: .: + devDependencies: + vitest: + specifier: ^3.2.4 + version: 3.2.7(@types/node@25.9.9)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.15) + + packages/contracts: + dependencies: + zod: + specifier: ^4.0.0 + version: 4.6.5 + devDependencies: + typescript: + specifier: ^5.9.0 + version: 5.9.3 + + packages/service-kit: + dependencies: + '@stackable/contracts': + specifier: workspace:* + version: link:../contracts + hono: + specifier: ^4.9.0 + version: 4.13.13 + jose: + specifier: ^6.0.0 + version: 6.2.12 + pg: + specifier: ^8.16.0 + version: 8.23.1 + zod: + specifier: ^4.0.0 + version: 4.6.5 + devDependencies: + '@types/node': + specifier: ^24.0.0 + version: 24.19.1 + '@types/pg': + specifier: ^8.15.0 + version: 8.23.1 + typescript: + specifier: ^5.9.0 + version: 5.9.3 + + services/academics: + dependencies: + '@hono/node-server': + specifier: ^1.19.0 + version: 1.19.17(hono@4.13.13) + '@stackable/contracts': + specifier: workspace:* + version: link:../../packages/contracts + '@stackable/service-kit': + specifier: workspace:* + version: link:../../packages/service-kit + pg: + specifier: ^8.16.0 + version: 8.23.1 + devDependencies: + '@types/node': + specifier: ^24.0.0 + version: 24.19.1 + '@types/pg': + specifier: ^8.15.0 + version: 8.23.1 + tsx: + specifier: ^4.20.0 + version: 4.23.15 + typescript: + specifier: ^5.9.0 + version: 5.9.3 + + services/ai: + dependencies: + '@hono/node-server': + specifier: ^1.19.0 + version: 1.19.17(hono@4.13.13) + '@stackable/contracts': + specifier: workspace:* + version: link:../../packages/contracts + '@stackable/service-kit': + specifier: workspace:* + version: link:../../packages/service-kit + pg: + specifier: ^8.16.0 + version: 8.23.1 + devDependencies: + '@types/node': + specifier: ^24.0.0 + version: 24.19.1 + '@types/pg': + specifier: ^8.15.0 + version: 8.23.1 + tsx: + specifier: ^4.20.0 + version: 4.23.15 + typescript: + specifier: ^5.9.0 + version: 5.9.3 + + services/identity: + dependencies: + '@hono/node-server': + specifier: ^1.19.0 + version: 1.19.17(hono@4.13.13) + '@stackable/contracts': + specifier: workspace:* + version: link:../../packages/contracts + '@stackable/service-kit': + specifier: workspace:* + version: link:../../packages/service-kit + pg: + specifier: ^8.16.0 + version: 8.23.1 + devDependencies: + '@types/node': + specifier: ^24.0.0 + version: 24.19.1 + '@types/pg': + specifier: ^8.15.0 + version: 8.23.1 + tsx: + specifier: ^4.20.0 + version: 4.23.15 + typescript: + specifier: ^5.9.0 + version: 5.9.3 + + stackable-app: dependencies: '@aws-sdk/client-s3': specifier: ^3.1068.0 - version: 3.1068.0 + version: 3.1146.0 '@aws-sdk/s3-request-presigner': specifier: ^3.1068.0 - version: 3.1068.0 + version: 3.1146.0 '@iconify-icon/react': specifier: ^3.0.3 - version: 3.0.3(react@19.2.4) + version: 3.0.4(react@19.3.0) '@infobip-api/sdk': specifier: ^0.3.2 version: 0.3.2 '@prisma/adapter-pg': specifier: ^7.8.0 - version: 7.8.0 + version: 7.10.0 '@prisma/client': specifier: ^7.8.0 - version: 7.8.0(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3) + version: 7.10.0(prisma@7.10.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@5.9.3))(typescript@5.9.3) '@react-email/components': specifier: ^1.0.8 - version: 1.0.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 1.0.12(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@react-email/render': specifier: ^2.0.4 - version: 2.0.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 2.1.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@sentry/nextjs': specifier: '11' - version: 11.0.0(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) + version: 11.4.0(@babel/core@7.29.7)(next@16.3.8(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@types/node@25.9.9)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react@19.3.0) '@supabase/ssr': specifier: ^0.9.0 - version: 0.9.0(@supabase/supabase-js@2.98.0) + version: 0.9.0(@supabase/supabase-js@2.117.2(@opentelemetry/api@1.9.1)) '@supabase/supabase-js': specifier: ^2.98.0 - version: 2.98.0 + version: 2.117.2(@opentelemetry/api@1.9.1) '@tanstack/react-query': specifier: ^5.101.0 - version: 5.101.0(react@19.2.4) + version: 5.104.1(react@19.3.0) '@tanstack/react-table': specifier: ^8.21.3 - version: 8.21.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 8.21.3(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@upstash/qstash': specifier: ^2.9.0 - version: 2.9.0 + version: 2.12.0 '@upstash/ratelimit': specifier: ^2.0.8 - version: 2.0.8(@upstash/redis@1.38.0) + version: 2.2.0(@upstash/redis@1.39.0) '@upstash/redis': specifier: ^1.38.0 - version: 1.38.0 + version: 1.39.0 axios: specifier: ^1.13.6 - version: 1.13.6 + version: 1.20.0 bcryptjs: specifier: ^3.0.3 version: 3.0.3 better-auth: - specifier: ^1.6.18 - version: 1.6.18(@opentelemetry/api@1.9.1)(@prisma/client@7.8.0(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3))(mysql2@3.15.3)(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(pg@8.21.0)(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + specifier: 1.6.18 + version: 1.6.18(@opentelemetry/api@1.9.1)(@prisma/client@7.10.0(prisma@7.10.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@5.9.3))(typescript@5.9.3))(mysql2@3.15.3)(next@16.3.8(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@types/node@25.9.9)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(pg@8.23.1)(prisma@7.10.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@5.9.3))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(vitest@3.2.7(@types/node@25.9.9)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.15)) clsx: specifier: ^2.1.1 version: 2.1.1 framer-motion: specifier: ^12.38.0 - version: 12.38.0(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 12.43.0(@emotion/is-prop-valid@1.4.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) lucide-react: specifier: ^0.577.0 - version: 0.577.0(react@19.2.4) + version: 0.577.0(react@19.3.0) next: specifier: ^16.1.6 - version: 16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - next-auth: - specifier: 5.0.0-beta.30 - version: 5.0.0-beta.30(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) + version: 16.3.8(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@types/node@25.9.9)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) next-themes: specifier: ^0.4.6 - version: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 0.4.6(react-dom@19.3.0(react@19.3.0))(react@19.3.0) pg: specifier: ^8.21.0 - version: 8.21.0 + version: 8.23.1 pingram: specifier: ^1.0.1 - version: 1.0.1 + version: 1.0.36 posthog-js: specifier: ^1.434.15 - version: 1.434.15(@types/react@19.2.14)(preact-render-to-string@6.5.11(preact@10.24.3))(react@19.2.4) + version: 1.435.8(@types/react@19.3.0)(react@19.3.0) qrcode: specifier: ^1.5.4 version: 1.5.4 react: specifier: ^19.2.4 - version: 19.2.4 + version: 19.3.0 react-apexcharts: specifier: ^2.1.0 - version: 2.1.0(apexcharts@5.10.3)(react@19.2.4) + version: 2.1.1(apexcharts@7.8.0)(react@19.3.0) react-dom: specifier: ^19.2.4 - version: 19.2.4(react@19.2.4) + version: 19.3.0(react@19.3.0) resend: specifier: ^6.9.3 - version: 6.9.3(@react-email/render@2.0.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)) + version: 6.32.0(@react-email/render@2.1.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0)) styled-components: specifier: ^6.3.11 - version: 6.3.11(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 6.5.3(react-dom@19.3.0(react@19.3.0))(react@19.3.0) zod: specifier: ^4.4.3 - version: 4.4.3 + version: 4.6.5 devDependencies: '@tailwindcss/postcss': specifier: ^4.2.1 - version: 4.2.1 + version: 4.3.3 '@types/bcryptjs': specifier: ^3.0.0 version: 3.0.0 '@types/node': specifier: ^25.3.5 - version: 25.3.5 + version: 25.9.9 '@types/pg': specifier: ^8.20.0 - version: 8.20.0 + version: 8.23.1 '@types/qrcode': specifier: ^1.5.6 version: 1.5.6 '@types/react': specifier: ^19.2.14 - version: 19.2.14 + version: 19.3.0 '@types/react-dom': specifier: ^19.2.3 - version: 19.2.3(@types/react@19.2.14) + version: 19.3.0(@types/react@19.3.0) babel-plugin-react-compiler: specifier: ^1.0.0 version: 1.0.0 eslint: specifier: ^9.39.4 - version: 9.39.4(jiti@2.6.1) + version: 9.39.5(jiti@2.7.0) eslint-config-next: specifier: ^16.1.6 - version: 16.1.6(@typescript-eslint/parser@8.56.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + version: 16.3.8(@typescript-eslint/parser@8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) postcss: specifier: ^8.5.8 - version: 8.5.8 + version: 8.5.28 prisma: specifier: ^7.8.0 - version: 7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3) + version: 7.10.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@5.9.3) tailwindcss: specifier: ^4.2.1 - version: 4.2.1 + version: 4.3.3 tsx: specifier: ^4.22.4 - version: 4.22.4 + version: 4.23.15 typescript: specifier: ^5.9.3 version: 5.9.3 packages: - '@alloc/quick-lru@5.2.0': - resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} + '@alloc/quick-lru@5.3.0': + resolution: {integrity: sha512-U4+70Pc5ZS9osnCBCE5Jha/ciHM+Yp+CNMNC/7HvYbNRk1Ldd+f7qO65W5qfhu/TCv+/ozljlXXe9Nj8419DMA==} engines: {node: '>=10'} - '@auth/core@0.41.0': - resolution: {integrity: sha512-Wd7mHPQ/8zy6Qj7f4T46vg3aoor8fskJm6g2Zyj064oQ3+p0xNZXAV60ww0hY+MbTesfu29kK14Zk5d5JTazXQ==} - peerDependencies: - '@simplewebauthn/browser': ^9.0.1 - '@simplewebauthn/server': ^9.0.2 - nodemailer: ^6.8.0 - peerDependenciesMeta: - '@simplewebauthn/browser': - optional: true - '@simplewebauthn/server': - optional: true - nodemailer: - optional: true - - '@aws-crypto/crc32@5.2.0': - resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==} - engines: {node: '>=16.0.0'} - - '@aws-crypto/crc32c@5.2.0': - resolution: {integrity: sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag==} - - '@aws-crypto/sha1-browser@5.2.0': - resolution: {integrity: sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg==} - - '@aws-crypto/sha256-browser@5.2.0': - resolution: {integrity: sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==} - - '@aws-crypto/sha256-js@5.2.0': - resolution: {integrity: sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==} - engines: {node: '>=16.0.0'} - - '@aws-crypto/supports-web-crypto@5.2.0': - resolution: {integrity: sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==} - - '@aws-crypto/util@5.2.0': - resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==} - - '@aws-sdk/checksums@3.1000.5': - resolution: {integrity: sha512-zOXUUnilC6lgCsQtp77p/QNPmRlTES9Xi6tlDwbR6kfC/kz5PCzZckgHWm5z+8DskdwuMAbFDq61x3zr10GEEQ==} + '@aws-sdk/checksums@3.1001.1': + resolution: {integrity: sha512-x12Q17KYlJAd3nKf8LV5LV0vt8sh8/6YfQLGPtrGnQf/tW4jqxPGq5GPpuVitpQYM3eUR4XB7CbxZf751NMbLw==} engines: {node: '>=20.0.0'} - '@aws-sdk/client-s3@3.1068.0': - resolution: {integrity: sha512-lFgaIpxZvloNbJvQ337YPdMXhzI2zJdDw13nATVGnkAGNoNPx4ksD84AQAcuW75hsaaMaIuNmXU9sSx6+FTirA==} + '@aws-sdk/client-s3@3.1146.0': + resolution: {integrity: sha512-WY0YCBzxc4muFfY6UbGpA+oib0nGT/Px2aEPGYX7pp1la2OmDLChnK81mu8H7LdYvwGU1pLAsLIa/qgad0Wd7g==} engines: {node: '>=20.0.0'} - '@aws-sdk/core@3.974.20': - resolution: {integrity: sha512-7sDi2B2N3mc3nf1nz6FyEx/FCrJ1N1QnBmraHHQNabFaeAh2IaOOLml48/rHOD1bICHgTRkbBgNTvUzEr5Z35g==} + '@aws-sdk/core@3.978.1': + resolution: {integrity: sha512-LbY9aGsEiznDWmUc30Nwv3aIX/+dbwTx8KfS0yOC3NPYMO+O91e6jkT1azf34FwjOndq8/Q+RcVVZz5xnerwdg==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-env@3.972.46': - resolution: {integrity: sha512-+GPXVS2srMOlH74S+SmC1gVuP2TvUZ0siuC0onKO93q+udP+M72dmY8wJfVQ5CX9z/9X5A1HHwz5yRIGBtskvQ==} + '@aws-sdk/credential-provider-env@3.972.72': + resolution: {integrity: sha512-xTKO/FWJPozTIXbozVnVGoNBhaGba8TBcx+KyUjRVeOlXE+dUc7GTR1cLvu0uTdIdmemzaFbqqCshXeZA1fZew==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-http@3.972.48': - resolution: {integrity: sha512-fA5loSdlocacRxyUXtpoHSMuk5rsIKRDzQYVMnMxjcmFeZshaJlJ8lymy/hYKji6sne/UmNGj5pxuEs6kq/Qcg==} + '@aws-sdk/credential-provider-http@3.972.74': + resolution: {integrity: sha512-u91E/hT8f4d1xy0Jl7VG4nVKJ3lxbrZkoBTeSVoJdWBiSEUMwMS/9+e0H/aJVQV//Lt5wuzP+E69v4aRSsNTmw==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-ini@3.972.53': - resolution: {integrity: sha512-ZfdhIOR41q8TcWEnUac+gCOb+O2LBWdHLmjedXpXz4IEFW2ppNuFcm6p0sMTavpM+zD5TYfpH5Gp7guRyqSgsQ==} + '@aws-sdk/credential-provider-ini@3.973.17': + resolution: {integrity: sha512-ged4KXdBkvIC81bLvNHHuQKdKak/VXhQTR1NWYTTqW0474nlmsxy9O/vlgTIohDDWH3xpBdtVMZRyjb+DnocDA==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-login@3.972.52': - resolution: {integrity: sha512-9hu2oR0qH7Fst5Tzdx+UWxm+w5zCXtErTLtOOW5hwwQc170CLwOeniRxyFY6s9mHfGEfC5zFukNBdKBwJR8mhQ==} + '@aws-sdk/credential-provider-login@3.972.79': + resolution: {integrity: sha512-L+Z85anONJd8MaiuraO4wRxATCdEejBZ3K3eymzWI5JPXa9sOS9CkIm72PBKqXKX+Z9p9NGMX5AIMXm0LEflgw==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-node@3.972.55': - resolution: {integrity: sha512-zMGLa/dhESVqmCD7mmIFFKSwSFrJGScvCXcjvBZEVOOMauFS5JRQvLTMukFpMEFWiV6dTAlsen2ATDBulLPtbg==} + '@aws-sdk/credential-provider-node@3.972.84': + resolution: {integrity: sha512-oHt854odINVwzwsh+c5x69j0ajm4DbqqqVJ+O1ECsCIZeMDAbzFpXItaqP7UZstJj/ATdTk/KFSH0LaNAgV+kA==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-process@3.972.46': - resolution: {integrity: sha512-VUoNFBIjWrUN8NbFiQiuxQEgFjvziAlBRPK+ddh27aj65gk0BYu6bLZnrdrNZwpW6vAihtSUtEMQ1PUJ32QRPA==} + '@aws-sdk/credential-provider-process@3.972.72': + resolution: {integrity: sha512-rLIp2xbMjX/k9/od7APpqq1ZgXXnV0pOL1Th3ZsL8Wu0TRtBsDTVS8iPqcfRFcHakFxPvR04OSTv2ka2qOb/2A==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-sso@3.972.52': - resolution: {integrity: sha512-nb2/n4o/HQf+FVpVbZe9vCTFngmuDoIsltMgLAtjixaKzvzhB4J8WSDFyWgnErgLHk55ctWH+I4PU+LIHhyffg==} + '@aws-sdk/credential-provider-sso@3.973.16': + resolution: {integrity: sha512-IGihaJfFZYacJJr/odqILCoK7W/mvrZ7cuK7ECn3sAu4vLC6u0V8bS7mCGbdugJ8Aum2tnvqmx0F2MRFp2rn9g==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-web-identity@3.972.52': - resolution: {integrity: sha512-lKj6aRSGbqLmpYmM24bY7a1Xmfcq2vkE3hv8CSPYfc1yCu0BPu/XEJ1L4Fm61MsU6ULLNSG8UGsffNoFUBjESA==} + '@aws-sdk/credential-provider-web-identity@3.972.78': + resolution: {integrity: sha512-/y9WvNtlcPBGLR0qc1a+9J/xtYZfVczvLUOuXaVWylzttH7ewsxwHtjmiJSolNrVSDorIxHGHMU61CbonRkmwA==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-flexible-checksums@3.974.30': - resolution: {integrity: sha512-OaIhub+3yTgfFWPzKO8OzOZFIMUoJaiS5v67y3spQg7SoULGoMx4jKVBbE+uhnzkiZXQ+rEDS0RqrK4/aD1yJw==} + '@aws-sdk/middleware-sdk-s3@3.972.77': + resolution: {integrity: sha512-E7W2UOeUoc+lg3uIfR/dM7ZwusHwhBQrKMnlkRv4EXRR+C0YtV1pg25xC7GdZIhXH+NAMgZPCbE7o5to2cjFiw==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-sdk-s3@3.972.51': - resolution: {integrity: sha512-keQgcIUTcHL0Qn7guhsuLaxQU36r9norCrxgaPH4DNCwon4TPtXdI/UdYuycl9vj3Dlwc3YR1dfL3U+6iIwJ6w==} + '@aws-sdk/nested-clients@3.997.46': + resolution: {integrity: sha512-oRxtBcka/JGHGs9l9p9IVajGoTP8vTPmoAzdHGy4Qcy9P5vPnDf6nhIeM/COQNY9k/OahImTRaLkHftoXvfcmQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/nested-clients@3.997.20': - resolution: {integrity: sha512-IYJuLpXp2DEILVQpQOy0PMpkftv0AHEOCn52o0atyOaumA0CdWQ3klPyXdViGYLbNpESsVFMVybvHUeZAuiGxA==} + '@aws-sdk/s3-request-presigner@3.1146.0': + resolution: {integrity: sha512-5BEfVr2OYX2bwB45NSWAXPoTN5M2NPLOHohgKV+XfMNcL5TF7UQN4hAOVF4S6DwrA4BrI3MsAry7vqdZ2aJEjg==} engines: {node: '>=20.0.0'} - '@aws-sdk/s3-request-presigner@3.1068.0': - resolution: {integrity: sha512-XTUoaBQH3pUlegqdoo7vkrOvlcRhSz/EO0/JYy9qKz4jdsOnPUlPY0p+rKTl27F91Gjgip8OXopiOFxVeN3Yvg==} + '@aws-sdk/signature-v4-multi-region@3.996.47': + resolution: {integrity: sha512-Zk08macMvQTHzQJCLJVkOlviVoqwYMrpXv4lmLN7b7sAbiMoOK7Go0NYdR5UeF+MW8LIbRmwrNy9u/5VvX1U5g==} engines: {node: '>=20.0.0'} - '@aws-sdk/signature-v4-multi-region@3.996.34': - resolution: {integrity: sha512-mx1L5qlumSOt/nKM3BFaHE2HVkWwz0i4Bw0pyYO42FfX/FeLlo8YI6csC0gSPprEk6fTIqI+CZN9RwUwKd5krQ==} + '@aws-sdk/token-providers@3.1138.0': + resolution: {integrity: sha512-GpyAr0DD63YOEmYFM6Df+gJuIgC92MMTiBK4FTKfxii5MJ9ge20epR7LyroulscYlG89J+ZB2ivFDPjvfQhzdw==} engines: {node: '>=20.0.0'} - '@aws-sdk/token-providers@3.1066.0': - resolution: {integrity: sha512-UqEUJq7dqa44hneLDUcX7UJy95cg8YqEWyakRpvIPnrNS3Mq+UlQHgCDGu5pvwAPtlIW4qcYbvW6reG6++FyvA==} + '@aws-sdk/types@3.974.6': + resolution: {integrity: sha512-v/clNZzZnDxGyvpHMOGpJKVXFAExJzUNAAjaWGdcx8QAcXLGwTaOkw33p5SHAi0YAioK32xB3hWwOekRVfmfKg==} engines: {node: '>=20.0.0'} - '@aws-sdk/types@3.973.12': - resolution: {integrity: sha512-43ajd1NF0RMgX5k0hxCNUyEdrtFUsb2aHT2QvpktSC/2Eyb2Jr/JPVqdp0XIoaHWikZJq5tNWSLO6kB5q2eMCA==} + '@aws-sdk/xml-builder@3.972.41': + resolution: {integrity: sha512-ctjVSyCMegrWfXlx6VqzSBFI6UqmQ5ZlnfMhdLIiWmhoH8UAQxSCP5N3OpG7X3k4LnS7ou74C4mt20+bfTW2aQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/util-locate-window@3.965.7': - resolution: {integrity: sha512-M0D6oIpohdNHjc7udzTHEQyot0+0iuA36jc2I9Hps+f/GtKi2HO/pyijQnCnNcwZqLB5+rtn81z3eZK/GyjAmA==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/xml-builder@3.972.29': - resolution: {integrity: sha512-fk0niuGFxfi8yIJuMVM4mhwObkiQSuwZFj3tAPrLVx64Pk3BkrEIpqjzHKY4hKoEBUD6Jg/S74Zj9jy+5F3DnQ==} - engines: {node: '>=20.0.0'} - - '@aws/lambda-invoke-store@0.2.4': - resolution: {integrity: sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==} + '@aws/lambda-invoke-store@0.3.0': + resolution: {integrity: sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==} engines: {node: '>=18.0.0'} - '@babel/code-frame@7.29.0': - resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} - '@babel/compat-data@7.29.0': - resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==} + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} engines: {node: '>=6.9.0'} - '@babel/core@7.29.0': - resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} engines: {node: '>=6.9.0'} - '@babel/generator@7.29.1': - resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} + '@babel/generator@7.29.8': + resolution: {integrity: sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==} engines: {node: '>=6.9.0'} - '@babel/helper-compilation-targets@7.28.6': - resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} engines: {node: '>=6.9.0'} - '@babel/helper-globals@7.28.0': - resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} engines: {node: '>=6.9.0'} - '@babel/helper-module-imports@7.28.6': - resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} engines: {node: '>=6.9.0'} - '@babel/helper-module-transforms@7.28.6': - resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0 - '@babel/helper-string-parser@7.27.1': - resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} engines: {node: '>=6.9.0'} - '@babel/helper-validator-identifier@7.28.5': - resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} engines: {node: '>=6.9.0'} - '@babel/helper-validator-option@7.27.1': - resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} engines: {node: '>=6.9.0'} - '@babel/helpers@7.28.6': - resolution: {integrity: sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==} + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} engines: {node: '>=6.9.0'} - '@babel/parser@7.29.0': - resolution: {integrity: sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==} + '@babel/parser@7.29.9': + resolution: {integrity: sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==} engines: {node: '>=6.0.0'} hasBin: true - '@babel/template@7.28.6': - resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} engines: {node: '>=6.9.0'} - '@babel/traverse@7.29.0': - resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} + '@babel/traverse@7.29.8': + resolution: {integrity: sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==} engines: {node: '>=6.9.0'} - '@babel/types@7.29.0': - resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} engines: {node: '>=6.9.0'} '@better-auth/core@1.6.18': @@ -430,31 +510,40 @@ packages: '@better-auth/utils@0.4.1': resolution: {integrity: sha512-SZBPRPF3z0nBvE5ygOkxae35wnnXPRShmqFo78S+qslLeFoPu/pMgnXAuNKFMMybac3tiLaVg1e3MQW5MC+1iA==} + '@better-auth/utils@0.4.2': + resolution: {integrity: sha512-AUxrvu+HaaODsUyzDxFgwd/8RZ1yZaYo42LXKSrU2oGgR38pS1ij8nqQKNgtTWoYGpNevNXtCfgTy6loHveW9A==} + '@better-fetch/fetch@1.3.0': resolution: {integrity: sha512-Lgkl18IrUURFqa1nE38GNDWXf7XGzfxXQDCE/alCQV0yZ98YrPGtlmW61ch1T4YRt3lxrSAGA+Ft73FzuWWb3A==} - '@electric-sql/pglite-socket@0.1.1': - resolution: {integrity: sha512-p2hoXw3Z3LQHwTeikdZNsFBOvXGqKY2hk51BBw+8NKND8eoH+8LFOtW9Z8CQKmTJ2qqGYu82ipqiyFZOTTXNfw==} + '@better-fetch/fetch@1.3.2': + resolution: {integrity: sha512-Gs7n99b5tqUC6cQAPbV0uED3IraHB6xQbHLQ/C3l7ZFafHScOx9pQ+DYmP5blbLFShVWLqxNUlI9wi4xU/X+ow==} + + '@electric-sql/pglite-socket@0.1.3': + resolution: {integrity: sha512-LAciWM0M1dCL8hlsxu2venbVZcdxema0BtDfpWYVqr+Y468UADw0pFWidhKw1M8sfJ8rdLT71tjMmnirf/IZRQ==} hasBin: true peerDependencies: - '@electric-sql/pglite': 0.4.1 + '@electric-sql/pglite': 0.4.3 - '@electric-sql/pglite-tools@0.3.1': - resolution: {integrity: sha512-C+T3oivmy9bpQvSxVqXA1UDY8cB9Eb9vZHL9zxWwEUfDixbXv4G3r2LjoTdR33LD8aomR3O9ZXEO3XEwr/cUCA==} + '@electric-sql/pglite-tools@0.3.3': + resolution: {integrity: sha512-AlzLJTRJ8+UFgK8CmxIpyIpJ0+YaFw02IiOSdYrqxwPXdSyeIShz8aa9Tq+tYFXdPwcaMp/Fc80mQZ1dkOQ/wg==} peerDependencies: - '@electric-sql/pglite': 0.4.1 + '@electric-sql/pglite': 0.4.3 - '@electric-sql/pglite@0.4.1': - resolution: {integrity: sha512-mZ9NzzUSYPOCnxHH1oAHPRzoMFJHY472raDKwXl/+6oPbpdJ7g8LsCN4FSaIIfkiCKHhb3iF/Zqo3NYxaIhU7Q==} + '@electric-sql/pglite@0.4.3': + resolution: {integrity: sha512-ichuWTgtd4mOM1G4SpyGJa5trT03lWbMypDV0fUXUCXg5hiHqVAz/bZyV68NqmkLB7WcYmj1RMJVSp8HV/v/ZQ==} - '@emnapi/core@1.8.1': - resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==} + '@emnapi/core@1.10.0': + resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} - '@emnapi/runtime@1.8.1': - resolution: {integrity: sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==} + '@emnapi/runtime@1.10.0': + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} - '@emnapi/wasi-threads@1.1.0': - resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==} + '@emnapi/runtime@1.11.3': + resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} + + '@emnapi/wasi-threads@1.2.1': + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} '@emotion/is-prop-valid@1.4.0': resolution: {integrity: sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==} @@ -462,165 +551,168 @@ packages: '@emotion/memoize@0.9.0': resolution: {integrity: sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==} - '@emotion/unitless@0.10.0': - resolution: {integrity: sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==} - - '@esbuild/aix-ppc64@0.28.1': - resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] - '@esbuild/android-arm64@0.28.1': - resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} engines: {node: '>=18'} cpu: [arm64] os: [android] - '@esbuild/android-arm@0.28.1': - resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} engines: {node: '>=18'} cpu: [arm] os: [android] - '@esbuild/android-x64@0.28.1': - resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} engines: {node: '>=18'} cpu: [x64] os: [android] - '@esbuild/darwin-arm64@0.28.1': - resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] - '@esbuild/darwin-x64@0.28.1': - resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} engines: {node: '>=18'} cpu: [x64] os: [darwin] - '@esbuild/freebsd-arm64@0.28.1': - resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-x64@0.28.1': - resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] - '@esbuild/linux-arm64@0.28.1': - resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} engines: {node: '>=18'} cpu: [arm64] os: [linux] - '@esbuild/linux-arm@0.28.1': - resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} engines: {node: '>=18'} cpu: [arm] os: [linux] - '@esbuild/linux-ia32@0.28.1': - resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} engines: {node: '>=18'} cpu: [ia32] os: [linux] - '@esbuild/linux-loong64@0.28.1': - resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} engines: {node: '>=18'} cpu: [loong64] os: [linux] - '@esbuild/linux-mips64el@0.28.1': - resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] - '@esbuild/linux-ppc64@0.28.1': - resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] - '@esbuild/linux-riscv64@0.28.1': - resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] - '@esbuild/linux-s390x@0.28.1': - resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} engines: {node: '>=18'} cpu: [s390x] os: [linux] - '@esbuild/linux-x64@0.28.1': - resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} engines: {node: '>=18'} cpu: [x64] os: [linux] - '@esbuild/netbsd-arm64@0.28.1': - resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-x64@0.28.1': - resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] - '@esbuild/openbsd-arm64@0.28.1': - resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-x64@0.28.1': - resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] - '@esbuild/openharmony-arm64@0.28.1': - resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] - '@esbuild/sunos-x64@0.28.1': - resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} engines: {node: '>=18'} cpu: [x64] os: [sunos] - '@esbuild/win32-arm64@0.28.1': - resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} engines: {node: '>=18'} cpu: [arm64] os: [win32] - '@esbuild/win32-ia32@0.28.1': - resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} engines: {node: '>=18'} cpu: [ia32] os: [win32] - '@esbuild/win32-x64@0.28.1': - resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} engines: {node: '>=18'} cpu: [x64] os: [win32] + '@eslint-community/eslint-utils@4.10.1': + resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + '@eslint-community/eslint-utils@4.9.1': resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -643,12 +735,12 @@ packages: resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/eslintrc@3.3.5': - resolution: {integrity: sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==} + '@eslint/eslintrc@3.3.7': + resolution: {integrity: sha512-F42g89Qd5oAWtp0k0nnSrjziAKza7w8SVT4mStc18LZMaRb4J1HQAHLCalEtDCxrTuksx7NU9qsmeLwpOfPqWw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/js@9.39.4': - resolution: {integrity: sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==} + '@eslint/js@9.39.5': + resolution: {integrity: sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@eslint/object-schema@2.1.7': @@ -659,18 +751,22 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@hono/node-server@1.19.11': - resolution: {integrity: sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==} + '@hono/node-server@1.19.17': + resolution: {integrity: sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ==} engines: {node: '>=18.14.1'} peerDependencies: hono: ^4 - '@humanfs/core@0.19.1': - resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} engines: {node: '>=18.18.0'} - '@humanfs/node@0.16.7': - resolution: {integrity: sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==} + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} engines: {node: '>=18.18.0'} '@humanwhocodes/module-importer@1.0.1': @@ -681,8 +777,8 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} - '@iconify-icon/react@3.0.3': - resolution: {integrity: sha512-i4N/J0+UZWQu5Ty9Z+SxsNsuNdY7w2TnSuI65Qw9UrWudvnRodKfTUMvyg0hvxSZolqAE1YJVZu1HqgfpgPX9w==} + '@iconify-icon/react@3.0.4': + resolution: {integrity: sha512-0xhOSFqAOT3grFA6XpTsQcoapIEOfaFaCHjWaEsJITwLC7Hj/oL/2A118wpZNmEVKCtwwHf1hw2C+wgKujv4Dg==} peerDependencies: react: '>=18.0.0' @@ -693,152 +789,161 @@ packages: resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} - '@img/sharp-darwin-arm64@0.34.5': - resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-darwin-arm64@0.35.5': + resolution: {integrity: sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==} + engines: {node: '>=20.9.0'} cpu: [arm64] os: [darwin] - '@img/sharp-darwin-x64@0.34.5': - resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-darwin-x64@0.35.5': + resolution: {integrity: sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==} + engines: {node: '>=20.9.0'} cpu: [x64] os: [darwin] - '@img/sharp-libvips-darwin-arm64@1.2.4': - resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} + '@img/sharp-freebsd-wasm32@0.35.5': + resolution: {integrity: sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==} + engines: {node: '>=20.9.0'} + os: [freebsd] + + '@img/sharp-libvips-darwin-arm64@1.3.4': + resolution: {integrity: sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==} cpu: [arm64] os: [darwin] - '@img/sharp-libvips-darwin-x64@1.2.4': - resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} + '@img/sharp-libvips-darwin-x64@1.3.4': + resolution: {integrity: sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==} cpu: [x64] os: [darwin] - '@img/sharp-libvips-linux-arm64@1.2.4': - resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} + '@img/sharp-libvips-linux-arm64@1.3.4': + resolution: {integrity: sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==} cpu: [arm64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-arm@1.2.4': - resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} + '@img/sharp-libvips-linux-arm@1.3.4': + resolution: {integrity: sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==} cpu: [arm] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-ppc64@1.2.4': - resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} + '@img/sharp-libvips-linux-ppc64@1.3.4': + resolution: {integrity: sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==} cpu: [ppc64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-riscv64@1.2.4': - resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} + '@img/sharp-libvips-linux-riscv64@1.3.4': + resolution: {integrity: sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==} cpu: [riscv64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-s390x@1.2.4': - resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} + '@img/sharp-libvips-linux-s390x@1.3.4': + resolution: {integrity: sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==} cpu: [s390x] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-x64@1.2.4': - resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} + '@img/sharp-libvips-linux-x64@1.3.4': + resolution: {integrity: sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==} cpu: [x64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linuxmusl-arm64@1.2.4': - resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} + '@img/sharp-libvips-linuxmusl-arm64@1.3.4': + resolution: {integrity: sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==} cpu: [arm64] os: [linux] libc: [musl] - '@img/sharp-libvips-linuxmusl-x64@1.2.4': - resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} + '@img/sharp-libvips-linuxmusl-x64@1.3.4': + resolution: {integrity: sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==} cpu: [x64] os: [linux] libc: [musl] - '@img/sharp-linux-arm64@0.34.5': - resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linux-arm64@0.35.5': + resolution: {integrity: sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==} + engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] libc: [glibc] - '@img/sharp-linux-arm@0.34.5': - resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linux-arm@0.35.5': + resolution: {integrity: sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==} + engines: {node: '>=20.9.0'} cpu: [arm] os: [linux] libc: [glibc] - '@img/sharp-linux-ppc64@0.34.5': - resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linux-ppc64@0.35.5': + resolution: {integrity: sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==} + engines: {node: '>=20.9.0'} cpu: [ppc64] os: [linux] libc: [glibc] - '@img/sharp-linux-riscv64@0.34.5': - resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linux-riscv64@0.35.5': + resolution: {integrity: sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==} + engines: {node: '>=20.9.0'} cpu: [riscv64] os: [linux] libc: [glibc] - '@img/sharp-linux-s390x@0.34.5': - resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linux-s390x@0.35.5': + resolution: {integrity: sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==} + engines: {node: '>=20.9.0'} cpu: [s390x] os: [linux] libc: [glibc] - '@img/sharp-linux-x64@0.34.5': - resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linux-x64@0.35.5': + resolution: {integrity: sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==} + engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] libc: [glibc] - '@img/sharp-linuxmusl-arm64@0.34.5': - resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linuxmusl-arm64@0.35.5': + resolution: {integrity: sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==} + engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] libc: [musl] - '@img/sharp-linuxmusl-x64@0.34.5': - resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linuxmusl-x64@0.35.5': + resolution: {integrity: sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==} + engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] libc: [musl] - '@img/sharp-wasm32@0.34.5': - resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-wasm32@0.35.5': + resolution: {integrity: sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==} + engines: {node: '>=20.9.0'} + + '@img/sharp-webcontainers-wasm32@0.35.5': + resolution: {integrity: sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==} + engines: {node: '>=20.9.0'} cpu: [wasm32] - '@img/sharp-win32-arm64@0.34.5': - resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-win32-arm64@0.35.5': + resolution: {integrity: sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==} + engines: {node: '>=20.9.0'} cpu: [arm64] os: [win32] - '@img/sharp-win32-ia32@0.34.5': - resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-win32-ia32@0.35.5': + resolution: {integrity: sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==} + engines: {node: ^20.9.0} cpu: [ia32] os: [win32] - '@img/sharp-win32-x64@0.34.5': - resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-win32-x64@0.35.5': + resolution: {integrity: sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==} + engines: {node: '>=20.9.0'} cpu: [x64] os: [win32] @@ -856,15 +961,12 @@ packages: resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} - '@jridgewell/sourcemap-codec@1.5.5': - resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - '@kurkle/color@0.3.4': - resolution: {integrity: sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==} - '@napi-rs/lzma-linux-x64-gnu@1.5.1': resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==} engines: {node: ^22.20 || ^24.12 || >=25} @@ -872,78 +974,79 @@ packages: os: [linux] libc: [glibc] - '@napi-rs/wasm-runtime@0.2.12': - resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} + '@napi-rs/wasm-runtime@1.2.4': + resolution: {integrity: sha512-AJxoUD2/15ESHbvpcyjU274nsAPLuOtPHCk0vKJM5pj//Fg/B1FXNWjPnXTT9PymCYYiHo4zPj0ZomXBKhoy7g==} + engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} + peerDependencies: + '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4 + '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4 - '@next/env@16.1.6': - resolution: {integrity: sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ==} + '@next/env@16.3.8': + resolution: {integrity: sha512-Al9zqHVV7TJv0eFuOU4U7Lvv74PTih4Ch63sk2xCIpSTkE3udFnaOcnzP2lQVymiL7yS9Cj2iClUXlR3EQ5sEw==} - '@next/eslint-plugin-next@16.1.6': - resolution: {integrity: sha512-/Qq3PTagA6+nYVfryAtQ7/9FEr/6YVyvOtl6rZnGsbReGLf0jZU6gkpr1FuChAQpvV46a78p4cmHOVP8mbfSMQ==} + '@next/eslint-plugin-next@16.3.8': + resolution: {integrity: sha512-eCR9RcLTZrVS+K3+VOhi0xMdUUGNuriCHHhU1ZoABC/QQ/Gp0OWbLKQxV2/9DkmihYdYRcQWFmZXW8M1AMUXYw==} - '@next/swc-darwin-arm64@16.1.6': - resolution: {integrity: sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw==} + '@next/swc-darwin-arm64@16.3.8': + resolution: {integrity: sha512-2JPRMh2nmQG5CiL7cXGL9AGwnPWJQ//cTtAUCT+w511QHk79SYz3LGv/pc5X643B/WEO0rvu3Yww0hqwt3kgeA==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@next/swc-darwin-x64@16.1.6': - resolution: {integrity: sha512-BLFPYPDO+MNJsiDWbeVzqvYd4NyuRrEYVB5k2N3JfWncuHAy2IVwMAOlVQDFjj+krkWzhY2apvmekMkfQR0CUQ==} + '@next/swc-darwin-x64@16.3.8': + resolution: {integrity: sha512-GZtCCOBKJ4leVIT/Th0llWKhD1ca92lzbQiS5R5ON9QkoiFnilFsebDae1JU2a3HWoKMEmEZWGs1AGLavVM72Q==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@next/swc-linux-arm64-gnu@16.1.6': - resolution: {integrity: sha512-OJYkCd5pj/QloBvoEcJ2XiMnlJkRv9idWA/j0ugSuA34gMT6f5b7vOiCQHVRpvStoZUknhl6/UxOXL4OwtdaBw==} + '@next/swc-linux-arm64-gnu@16.3.8': + resolution: {integrity: sha512-O659ygeQYqneJ1fBKMpFxIFqYkYswu8IAS1OCKK/4f3ZgJJm1dRz4fVJZRi/kLLWjnBKnebOePA4WNv+sV1pVA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [glibc] - '@next/swc-linux-arm64-musl@16.1.6': - resolution: {integrity: sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==} + '@next/swc-linux-arm64-musl@16.3.8': + resolution: {integrity: sha512-dSjKSyWpzxoO1d3DIZZcP4XJcNaKeLmxQMFOiYl5vuBRMmweIqnAhty8tAmRsvTss779cK1FtYnDMj40e4TQlg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [musl] - '@next/swc-linux-x64-gnu@16.1.6': - resolution: {integrity: sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==} + '@next/swc-linux-x64-gnu@16.3.8': + resolution: {integrity: sha512-lbqOuz3RPRcv+o9msNsJw5x4+Y1ZwPTs6vmL6DCf7i0fZfvng/F59wyeDwqHIvV0mK//RBy/jJkZ+nCKsSMXjQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [glibc] - '@next/swc-linux-x64-musl@16.1.6': - resolution: {integrity: sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==} + '@next/swc-linux-x64-musl@16.3.8': + resolution: {integrity: sha512-+316WswI8ScVgZeUd+1KGaXkHhaYQzCjvH/05TZSpJ8zBizb1a4G7DtO7F12jcBIqMOtsz9ji1t48fmKtzqsGA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [musl] - '@next/swc-win32-arm64-msvc@16.1.6': - resolution: {integrity: sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==} + '@next/swc-win32-arm64-msvc@16.3.8': + resolution: {integrity: sha512-ji0gd4kMYUxO+1fJBIbiBVRCjzG/lloiyCccnlebvb1ZJ5qXCPZqYg4Jl1DrrixnWNMKylzgpmMWx0yNDYXlzw==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@next/swc-win32-x64-msvc@16.1.6': - resolution: {integrity: sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A==} + '@next/swc-win32-x64-msvc@16.3.8': + resolution: {integrity: sha512-WcTlaKt/TWkh5kUjdJcUmB1XgZ+1c6fz4Y9fDHL73YNSdGaUWjceeWrrlwF0nv19iABYWC4iAq1oX1w4Bn0vfg==} engines: {node: '>= 10'} cpu: [x64] os: [win32] - '@noble/ciphers@2.2.0': - resolution: {integrity: sha512-Z6pjIZ/8IJcCGzb2S/0Px5J81yij85xASuk1teLNeg75bfT07MV3a/O2Mtn1I2se43k3lkVEcFaR10N4cgQcZA==} + '@noble/ciphers@2.4.0': + resolution: {integrity: sha512-AnjFn0Jv92laAkvMrghlFZq4qQCIN/4DxFV/eooqtC2YTjB7kBeLMS2T9KJX4Dn+ZVXLOwK0lSgqDtx9gvxtiw==} engines: {node: '>= 20.19.0'} - '@noble/hashes@2.2.0': - resolution: {integrity: sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==} + '@noble/hashes@2.4.0': + resolution: {integrity: sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==} engines: {node: '>= 20.19.0'} - '@nodable/entities@2.2.0': - resolution: {integrity: sha512-9uGyhaQavEUMC8AIddIjau4NsnsXhou+j5sBAGojCM1oxmQpVKTWR/9JxABD6UAv12vpIms55fPZKFQEhG6uBg==} - '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -964,155 +1067,152 @@ packages: resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==} engines: {node: '>=8.0.0'} - '@opentelemetry/semantic-conventions@1.41.1': - resolution: {integrity: sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==} + '@opentelemetry/semantic-conventions@1.43.0': + resolution: {integrity: sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==} engines: {node: '>=14'} - '@oxc-parser/binding-android-arm-eabi@0.143.0': - resolution: {integrity: sha512-n9uozULWflPqBtdmI8lAabLqGKNgLVNN0ZH8HfgCwpKGNtzRzauB76jTiW/3YLkcA7N1zskpi9GdVnZuu1SAvg==} + '@oxc-parser/binding-android-arm-eabi@0.152.0': + resolution: {integrity: sha512-393VtirINkae4XHBTkq/yQxhOwEr4V+r0et37P2fp+sYZSdYgZTRDPo5Um8vBh0QjMZ+RmaMy4PP0qwr4cTMwA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] - '@oxc-parser/binding-android-arm64@0.143.0': - resolution: {integrity: sha512-9BbdjHETk6O3zH/DDid9IgBtF0GlpLabNKN231uraXpRDSfY+iiZxTP5bk1Z63GBownVdhdINFIeddmMz4MzpQ==} + '@oxc-parser/binding-android-arm64@0.152.0': + resolution: {integrity: sha512-iYFcExATJNZ0rZj0o02THbD1Nc9jAW0nVgIQPee6ejbSASpbZXlIMw8jxtpzaG14JQTWwGSZ6U5dEtRJXE6V3Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@oxc-parser/binding-darwin-arm64@0.143.0': - resolution: {integrity: sha512-gh+6ecoHUy4/sUcolBl/1qPXKBbYNxFY0Pk0ujgQvINTMSftJY7o4yb8gOkDJPeZeB8+a+u7xTe6umoP8N5HFA==} + '@oxc-parser/binding-darwin-arm64@0.152.0': + resolution: {integrity: sha512-fnU+DfUzi5/rFy0LzmipYRz06PAUUkOS8kCJQ3Eg6oqlunC1XZXoffFqxmdL51RmcbYtUG3P8DVG1U5fckoa2A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@oxc-parser/binding-darwin-x64@0.143.0': - resolution: {integrity: sha512-qd1hl2d+lXgHv/VQ/M9qm8TrMC5T4RqDBwtOnl+1D0QMjwcz+8AaB4JSg8STgeag0GP6a6L74XEGAsrTSJWNzQ==} + '@oxc-parser/binding-darwin-x64@0.152.0': + resolution: {integrity: sha512-Y3U2okryWTs7hDld6UhjJwLNf8/do+x4g4C+1U5SiGwrTLwbO83WJYJA8+l9iFSilWBgnfpd/+U76tfeNxUHzA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@oxc-parser/binding-freebsd-x64@0.143.0': - resolution: {integrity: sha512-M5XXcNa7aOqLPKTR41msfghKu2yQ4xWvCm11/gwU0JzOzHNk5sgW//rVEjJ+LO48+VDAMzXTSzurUVxIDKwozw==} + '@oxc-parser/binding-freebsd-x64@0.152.0': + resolution: {integrity: sha512-XkhkRrrPffEw+ZLv/d/5pEni0buxinm/E/N6fLbJCunD1DBbeCTtVP23ESV+4CWCWdSz397asks1ViGslpmWiQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@oxc-parser/binding-linux-arm-gnueabihf@0.143.0': - resolution: {integrity: sha512-T/GXusuOkPNQhCQCSBbcU/N8j0rAypuDBl1IyFK+lyYT594XsVz80clPC/OtbSSpBGyJxj8uYEfctxVuxVYoww==} + '@oxc-parser/binding-linux-arm-gnueabihf@0.152.0': + resolution: {integrity: sha512-MLzsRRbdfmdzo6JEhbC1yKU9ItfvM8tEf18HM32jCiNYZEz6J3bRBRl3cTkKR0vBdrk9ZVlcARXtwHccs+epDA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxc-parser/binding-linux-arm-musleabihf@0.143.0': - resolution: {integrity: sha512-oKu4RcBlXSqo3OC62dp6YTnQaZIurNDpCX3BnAM3+bJxt7s8J2TJKMnC0UYer1qhlRaDCg6wkTaTw+2IlsZ12w==} + '@oxc-parser/binding-linux-arm-musleabihf@0.152.0': + resolution: {integrity: sha512-HHerTEJz7Iy10Ji6BGsk4gqG6PU0UzFuBu91jIEOX89FnrI5ECWSEr7BLpkYQhp7U0QFtMnBhNfQKI0hYZK78w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxc-parser/binding-linux-arm64-gnu@0.143.0': - resolution: {integrity: sha512-WJBbD186AZmMGaSIhlktC+rPl8L3peCTXAh88Ih9uEvK0en2mPojGyCGYiL6mHtV1RPV3JyfJW5t6n5hh0lXhA==} + '@oxc-parser/binding-linux-arm64-gnu@0.152.0': + resolution: {integrity: sha512-9Bb8GyzZ2uSLzRDZ27usaKokr07mxiBe2dDF5b6bd2janmaJTT1gL51UfP7AU30yFiXrJvcRyFKREAgKwZYs0w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@oxc-parser/binding-linux-arm64-musl@0.143.0': - resolution: {integrity: sha512-t1AcYOwEzgceadT4v5e+vaCCb0AncCA3v5AyzfBAz/tMq11qzVccXKzNHtkWdjBsgvTKwRkaUF3QvT4kot8vcQ==} + '@oxc-parser/binding-linux-arm64-musl@0.152.0': + resolution: {integrity: sha512-om7iDMUroI74A/lfRRWY9/Ev3VWrVsrUZ4tyvyh8raBey/sdcfyAD7tYWc+iykAiotnmftyKTpUKFr1QnaBrAQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@oxc-parser/binding-linux-ppc64-gnu@0.143.0': - resolution: {integrity: sha512-RsnO/NoD8376LMJq8JS8TwI0ieNaFRTuNe2GVJntQg6gwZNMENZsEbknHdVwjpOmxdGLGodcwaGSbAeRr5Bgjw==} + '@oxc-parser/binding-linux-ppc64-gnu@0.152.0': + resolution: {integrity: sha512-Lf1OL2lMTqwDMFC6jnRb30B+WYHKV0XT+bPHcFM6xHtNVwxpobiTzUIvKH6a0LWpvfwyru8Yu6zCGHqqFHH+gg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@oxc-parser/binding-linux-riscv64-gnu@0.143.0': - resolution: {integrity: sha512-48fSVfR9TZi5CASZFyv0VC6z6BCoeihFsX031mAD/oSH7d9PYsPgIqza7d9mjP7Z2KTEpTFyH6SIu0Ui6R1vdg==} + '@oxc-parser/binding-linux-riscv64-gnu@0.152.0': + resolution: {integrity: sha512-BYWSMQK+YJ9ZILStPcmmiSVq8KEG9Qdq/c4BHPC3rUEq/fEDCVjUIwdj5ZzjgZ9P9is9PKu0tcAXzq1ZXg4urQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [glibc] - '@oxc-parser/binding-linux-riscv64-musl@0.143.0': - resolution: {integrity: sha512-T8CpdD+SfE01DnIOD4HpVxu0ZJOfMJ/VhCvikKfaXAxkZ+9veyLM/D2hpi7Y2hFUyPmVQO3FNZHmYzV/WlVR4g==} + '@oxc-parser/binding-linux-riscv64-musl@0.152.0': + resolution: {integrity: sha512-d2LqHNNNqdH185sRK5dPKWZu201JrNu9/CRFuVEuT+ht7n/QaVblEMWT9MALQPrDWYvhG10P+NSKJFquDWZ09w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [musl] - '@oxc-parser/binding-linux-s390x-gnu@0.143.0': - resolution: {integrity: sha512-QLdeMsCcacenPEFsfxnBUDF1y6opyz5+fmOz9bfD5Y7fiGCMupUCuB3KTPQhNwshIG1P9fPqar9MHxuBDd4bwQ==} + '@oxc-parser/binding-linux-s390x-gnu@0.152.0': + resolution: {integrity: sha512-8gBeHt5rA/8+pKhHl+zLggfa3L/V/L2at96p7ty6pBE7id6lFO19nFUc15u2PcjC4OYeYK465MKy1fhhbpFIGw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@oxc-parser/binding-linux-x64-gnu@0.143.0': - resolution: {integrity: sha512-659ujfqLy6k7cuH3sbzhd8b+ztSq+i6E2E9pG78Q0BmHjAExfGIdgc8cGgMdwAozDXeZFHkJ+LXYJdWsaGdgyw==} + '@oxc-parser/binding-linux-x64-gnu@0.152.0': + resolution: {integrity: sha512-ltgrSF6L+yPTVsIdaAyqKq3zTqzj/1VlNbxWZuxAe8flR3sWdyNsKvdXukoO8/ucJbLickhmieRBge+o/e8Ugw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@oxc-parser/binding-linux-x64-musl@0.143.0': - resolution: {integrity: sha512-/Mw/9j4TfZcnKphPrzOE6t4MMknXadcAAuVUlDRTF/ETWB5xOgQvOJV2Mh9We/bWxZdoxaGAdc+hy4GuYwQ2yQ==} + '@oxc-parser/binding-linux-x64-musl@0.152.0': + resolution: {integrity: sha512-LcMp6BvrBnewSfdkLHoSqidhoAq4syKOTBV5TYHWd+2VnGkfD/nPRRVIzTBt/Lkbt7pfOFDGtkiTntFS0p6sQQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@oxc-parser/binding-openharmony-arm64@0.143.0': - resolution: {integrity: sha512-8rIKWR2BFuifbIK/1XB9wTaSdtuJ25dlE7ZQYDnEwj/2xH2vHsxnvIjHT3ZjSVuLLwGGlSslIG/fbOJ8TV8rTw==} + '@oxc-parser/binding-openharmony-arm64@0.152.0': + resolution: {integrity: sha512-jbhoMCslVxzRCRNQ8TSHxjYdSKRvk8O6ciLVDkahJvmyHDzBqba/bMQHM6ULKj/rgHNNbSzlIiakdx8SEfkJzA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@oxc-parser/binding-win32-arm64-msvc@0.143.0': - resolution: {integrity: sha512-5U9kQYMfRRI6Zq7KDxgbIP0RMnKrfn3gLepRMgJuRkPSUALTiRCk9d/uyhb4lGDjUdzwK7mBkKqhLgzBPCmLpQ==} + '@oxc-parser/binding-win32-arm64-msvc@0.152.0': + resolution: {integrity: sha512-JZYVlhv36mr/nertnRB7vchJECOBb1Lz6lltalILsB+o9M/is+H6BEDRLQKdqA4JKs6NjJszo46+iM5sfwj6jQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@oxc-parser/binding-win32-ia32-msvc@0.143.0': - resolution: {integrity: sha512-25P7AaHk4R88Yv2XH4gToDVmh0cOu+bEURQU10CRrmvgabfRArSGAP5osmwUKeSUHj0VS50upbpbRWWW/m7mHA==} + '@oxc-parser/binding-win32-ia32-msvc@0.152.0': + resolution: {integrity: sha512-1X9oganvVsunRH0lH10UU05N+H9atpX6WsOybByUjwnfcd3h/0tYwvuv16XuHglrIUOnxV2TodbLVEwwafd64g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] - '@oxc-parser/binding-win32-x64-msvc@0.143.0': - resolution: {integrity: sha512-ORMh3JE1s6V7ySicdRK7vgaDQnn5o+UHg9ct989PlWHbel8O9ARrmWXM6kZjrBMtNucxNayQ8g69G0VfWzhANw==} + '@oxc-parser/binding-win32-x64-msvc@0.152.0': + resolution: {integrity: sha512-sw0M80/dn9rcpu+Rqqph/C+MjvDDce6MlXmFjsNQtPEymha0eJy3ml6+foSUTqFfpDIDyHZZ4eoJ43IoMe8bJw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] - '@oxc-project/types@0.143.0': - resolution: {integrity: sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA==} - - '@panva/hkdf@1.2.1': - resolution: {integrity: sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==} + '@oxc-project/types@0.152.0': + resolution: {integrity: sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw==} - '@posthog/browser-common@0.9.0': - resolution: {integrity: sha512-EWoVzs1vVY7ihpn8yOjj3jyjriHUEEMVSBNL/TW3hk09fTriWRe76vJXrRg0G0yjTYxHmtlSkNn4mOV24ZEzzA==} + '@posthog/browser-common@0.9.1': + resolution: {integrity: sha512-Dl8uV2OZAxfHfWyC7K0Y3HtT7ld+WGLbXIwgCgAGziOMGDIa/bepCRrCxy7IxsEzqY1HOrq/VDdcoNN7hBTSQQ==} - '@posthog/core@1.55.2': - resolution: {integrity: sha512-2mYxGbDmTLUs096VbcZqSWo0lrI48/BrX65r54NynxxFq409BOx9w73HxOAtOKZ1vblkdV6/GxQTe7KDcIQidg==} + '@posthog/core@1.55.3': + resolution: {integrity: sha512-XnmG9jo7WlxTeN4oCNWH4ESLQHR6ZPZ+mcq+8JxU62k5i1BAe3BSrybnmXHjqKp7e3rUvLK2bOzGZGSlfHvlNA==} - '@posthog/types@1.412.4': - resolution: {integrity: sha512-Q7lV9O9TbLngjOYw1ucm3bS3tn48xb1B4ZQdDy8gv7yfKe99hCTeWYG4xZ36nJM80VxpTF9TriiJt5hngDOkBg==} + '@posthog/types@1.413.0': + resolution: {integrity: sha512-tYAVzNojWckuG/wxBB0fwE70sFruQQq4xbhkci7eH2ARU3mMfUTkvpN1HwBMpm0AtbxidLiH0IFvagmdJ8opPw==} - '@prisma/adapter-pg@7.8.0': - resolution: {integrity: sha512-ygb3UkerK3v8MDpXVgCISdRNDozpxh6+JVJgiIGbSr5KBgz10LLf5ejUskPGoXlsIjxsOu6nuy1JVQr2EKGSlg==} + '@prisma/adapter-pg@7.10.0': + resolution: {integrity: sha512-N7nwSor0HO1Kz6xBv0TPAjAPysKK0fac6p4fVN3ensLOuzc/83Fgmln5k92eK/cvzqdkSR/2kkAqlbcdwVrwpw==} - '@prisma/client-runtime-utils@7.8.0': - resolution: {integrity: sha512-5NQZztQ0oY/ADFkmd9gPuweH5A1/CCY8YQPorLLO0Mu6a87mY5gsnDkzmFmIHs9NFaLnZojzgddFVN4RpKYrdw==} + '@prisma/client-runtime-utils@7.10.0': + resolution: {integrity: sha512-cnCy7lUV8/CctgKVEmqAbSLAmwqJdE/qAlqTBk/0NDk59zEb2cZ0M0M0E4vVPnqbSEYudRroQDvOWfUZH6RIfw==} - '@prisma/client@7.8.0': - resolution: {integrity: sha512-HFp3Dawv/3sU3JtlPha90IB+48lS7zHiH4LKZPjmcE8YH5P9DOXGPvo8dqOtO7MqLDd1p2hOWMcFlRT1DMblHw==} + '@prisma/client@7.10.0': + resolution: {integrity: sha512-Ubw/QS9JGIBSBUsyxAUQuK/Jcu0Tsva7le7QbLd91Kix9yJvYDdj5QkwgEbbZniH80dd+sziQcALPc+HnvQC8Q==} engines: {node: ^20.19 || ^22.12 || >=24.0} peerDependencies: prisma: '*' @@ -1123,45 +1223,45 @@ packages: typescript: optional: true - '@prisma/config@7.8.0': - resolution: {integrity: sha512-HFESzd9rx2ZQxlK+TL7tu1HPvCqrHiL6LCxYykI2c34mvaUuIVVl3lYuicJD/MNnzgPnyeBEMlK4WTomJCV5jw==} + '@prisma/config@7.10.0': + resolution: {integrity: sha512-Rcg828gIRE3HOQ3pOATFjV5d/P0U9OIobxhd/IMxlfWjA4vru0eGwb0AIwFw0rmcLMVShohZYWPixVxkBHsxUA==} + + '@prisma/debug@7.10.0': + resolution: {integrity: sha512-caygJKtltmRIgdJ3jRpkOr7yM4DW6zxo5uOmojKWFb3asnxWoRkQOwZmXBgD8FZp4htrX+nMpcWqDwzlQ1+Y4g==} '@prisma/debug@7.2.0': resolution: {integrity: sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw==} - '@prisma/debug@7.8.0': - resolution: {integrity: sha512-p+QZReysDUqXC+mk17q9a+Y/qzh4c2KYliDK30buYUyfrGeTGSyfmc0AIrJRhZJrLHhRiJa9Au/J72h3C+szvA==} + '@prisma/dev@0.24.17': + resolution: {integrity: sha512-UvdZzmpFwknnfreh6Jije84ekkYGPYEJhXG1tFzCsCfQyzJifrOo38eZc0qajzvaC6OLUOrN9ML5XfCnEZL9DA==} - '@prisma/dev@0.24.3': - resolution: {integrity: sha512-ffHlQuKXZiaDt9Go0OnCTdJZrHxK0k7omJKNV86/VjpsXu5EIHZLK0T7JSWgvNlJwh56kW9JFu9v0qJciFzepg==} + '@prisma/driver-adapter-utils@7.10.0': + resolution: {integrity: sha512-u8zkcRLlaryO652T4qavBg0HmzNW5tSKdsCn6hc1PhWAp/J6k0vrxLuUs+b9o+HcjsK7Dfa01o4OFSn0frauJA==} - '@prisma/driver-adapter-utils@7.8.0': - resolution: {integrity: sha512-/Q13o0ZT0rjc1Xk0Q9KhZYwuq2EW/vSbWUBKfgEKkaCuB/Sg6bqnjmTZqC5cD4d6y1vfFAEwBRzfzoSMIVJ55A==} + '@prisma/engines-version@7.10.0-4.0edf323efd1d98336f3f0a68684b56f689b900d3': + resolution: {integrity: sha512-8OJ6RuZTZ06eFUOtBwxVmv8XMmOW6HWN5F+uxUbZkGxR0Bfab1dfAdXaHPmR5mb59E+fmUo8IOzXlbLY1SClbw==} - '@prisma/engines-version@7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a': - resolution: {integrity: sha512-fJPQxCkLgA5EayWaW8eArgCvjJ+N+Kz3VyeNKMEeYiQC4alNkxRKFVAGxv/ZUzuJISKqdw+zGeDbS6mn6RCPOA==} + '@prisma/engines@7.10.0': + resolution: {integrity: sha512-KNumN6NHFwybvfdYzTee9pqwx5PvknpWAaHn6L5NsbrKdl+SQrsVZs9opKs6U6SAsvB26HDt3WybRjOhgoWOYQ==} - '@prisma/engines@7.8.0': - resolution: {integrity: sha512-jx3rCnNNrt5uzbkKlegtQ2GZHxSlihMCzutgT/BP6UIDF1r9tDI39hV/0T/cHZgzJ3ELbuQPXlVZy+Y1n0pcgw==} + '@prisma/fetch-engine@7.10.0': + resolution: {integrity: sha512-Zqyu8DY14t6W/xwmAxUYWCXtHrvQnSvT644EAZSsdM8NSmCS74vJJbBKdVsK3ucFpnUWkEpbO1a0CxJXrg130g==} - '@prisma/fetch-engine@7.8.0': - resolution: {integrity: sha512-gwB0Euiz/DDRyxFRpLXYlK3RfaZUj1c5dAYMuhZYfApg7arknJlcb9bIsOHDppJmbqYaVA+yBIiFMDBfprsNPQ==} + '@prisma/get-platform@7.10.0': + resolution: {integrity: sha512-0bra1LFYi8xNw0yqV62bHJNQk4BKleOngZiqPWIQc7a3+9q6rqsnqJ15BuepP8943PFMvtmgnP52juZsyYkA6w==} '@prisma/get-platform@7.2.0': resolution: {integrity: sha512-k1V0l0Td1732EHpAfi2eySTezyllok9dXb6UQanajkJQzPUGi3vO2z7jdkz67SypFTdmbnyGYxvEvYZdZsMAVA==} - '@prisma/get-platform@7.8.0': - resolution: {integrity: sha512-WlxgRGnolL8VH2EmkH1R/DkKNr/mVdS3G2h42IZFFZ3eUrH9OT6t73kIOSlkkrv50wG123Iq8d96ufv5LlZktw==} - '@prisma/query-plan-executor@7.2.0': resolution: {integrity: sha512-EOZmNzcV8uJ0mae3DhTsiHgoNCuu1J9mULQpGCh62zN3PxPTd+qI9tJvk5jOst8WHKQNwJWR3b39t0XvfBB0WQ==} - '@prisma/streams-local@0.1.2': - resolution: {integrity: sha512-l49yTxKKF2odFxaAXTmwmkBKL3+bVQ1tFOooGifu4xkdb9NMNLxHj27XAhTylWZod8I+ISGM5erU1xcl/oBCtg==} - engines: {bun: '>=1.3.6', node: '>=22.0.0'} + '@prisma/streams-local@0.1.11': + resolution: {integrity: sha512-0TcebL559MByKqTJ+SsrFIEg228iw8UCVRFckzgfRSiJqczhs+MuAgWOF9lnOIV/IVqvu+KMnFTH0eDeTQMpUg==} + engines: {bun: '>=1.2.0', node: '>=22.0.0'} - '@prisma/studio-core@0.27.3': - resolution: {integrity: sha512-AADjNFPdsrglxHQVTmHFqv6DuKQZ5WY4p5/gVFY017twvNrSwpLJ9lqUbYYxEu2W7nbvVxTZA8deJ8LseNALsw==} + '@prisma/studio-core@0.33.0': + resolution: {integrity: sha512-V2fX/nKEymNTrHXwfP26PGjoLStO35Ogu+ex7CFJbLrMYEcZxxZpiSNOs7px23Hk5mzLWvM5RsqG6Ka+rha+wg==} engines: {node: ^20.19 || ^22.12 || >=24.0, pnpm: '8'} peerDependencies: '@types/react': ^18.0.0 || ^19.0.0 @@ -1242,104 +1342,127 @@ packages: '@types/react': optional: true - '@react-email/body@0.2.1': - resolution: {integrity: sha512-ljDiQiJDu/Fq//vSIIP0z5Nuvt4+DX1RqGasstChDGJB/14ogd4VdNS9aacoede/ZjGy3o3Qb+cxyS+XgM6SwQ==} + '@react-email/body@0.3.0': + resolution: {integrity: sha512-uGo0BOOzjbMUo3lu+BIDWayvn5o6Xyfmnlla5VGf05n8gHMvO1ll7U4FtzWe3hxMLwt53pmc4iE0M+B5slG+Ug==} engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/button@0.2.1': resolution: {integrity: sha512-qXyj7RZLE7POy9BMKSoqQ00tOXThjOZSUnI2Yu9i29IHngPlmrNayIWBoVKtElES7OWwypUcpiajwi1mUWx6/A==} engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/code-block@0.2.1': resolution: {integrity: sha512-M3B7JpVH4ytgn83/ujRR1k1DQHvTeABiDM61OvAbjLRPhC/5KLHU5KkzIbbuGIrjWwxAbL1kSQzU8MhLEtSxyw==} engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/code-inline@0.0.6': resolution: {integrity: sha512-jfhebvv3dVsp3OdPgKXnk8+e2pBiDVZejDOBFzBa/IblrAJ9cQDkN6rBD5IyEg8hTOxwbw3iaI/yZFmDmIguIA==} engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/column@0.0.14': resolution: {integrity: sha512-f+W+Bk2AjNO77zynE33rHuQhyqVICx4RYtGX9NKsGUg0wWjdGP0qAuIkhx9Rnmk4/hFMo1fUrtYNqca9fwJdHg==} engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc - '@react-email/components@1.0.8': - resolution: {integrity: sha512-zY81ED6o5MWMzBkr9uZFuT24lWarT+xIbOZxI6C9dsFmCWBczM8IE1BgOI8rhpUK4JcYVDy1uKxYAFqsx2Bc4w==} + '@react-email/components@1.0.12': + resolution: {integrity: sha512-tH18JhPDWgE+3jnYkzyB6ZrZdfNnEsFe4PwmuXmlOw4NGIysP8wPY5aXZg++pTG9qUabXg1nzX/FGHGkObH8xQ==} engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/container@0.0.16': resolution: {integrity: sha512-QWBB56RkkU0AJ9h+qy33gfT5iuZknPC7Un/IjZv9B0QmMIK+WWacc0cH6y2SV5Cv/b99hU94fjEMOOO4enpkbQ==} engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/font@0.0.10': resolution: {integrity: sha512-0urVSgCmQIfx5r7Xc586miBnQUVnGp3OTYUm8m5pwtQRdTRO5XrTtEfNJ3JhYhSOruV0nD8fd+dXtKXobum6tA==} engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/head@0.0.13': resolution: {integrity: sha512-AJg6le/08Gz4tm+6MtKXqtNNyKHzmooOCdmtqmWxD7FxoAdU1eVcizhtQ0gcnVaY6ethEyE/hnEzQxt1zu5Kog==} engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/heading@0.0.16': resolution: {integrity: sha512-jmsKnQm1ykpBzw4hCYHwBkt5pW2jScXffPeEH5ZRF5tZeF5b1pvlFTO9han7C0pCkZYo1kEvWiRtx69yfCIwuw==} engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/hr@0.0.12': resolution: {integrity: sha512-TwmOmBDibavUQpXBxpmZYi2Iks/yeZOzFYh+di9EltMSnEabH8dMZXrl+pxNXzCgZ2XE8HY7VmUL65Lenfu5PA==} engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/html@0.0.12': resolution: {integrity: sha512-KTShZesan+UsreU7PDUV90afrZwU5TLwYlALuCSU0OT+/U8lULNNbAUekg+tGwCnOfIKYtpDPKkAMRdYlqUznw==} engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/img@0.0.12': resolution: {integrity: sha512-sRCpEARNVTf3FQhZOC+JTvu5r6ubiYWkT0ucYXg8ctkyi4G8QG+jgYPiNUqVeTLA2STOfmPM/nrk1nb84y6CPQ==} engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/link@0.0.13': resolution: {integrity: sha512-lkWc/NjOcefRZMkQoSDDbuKBEBDES9aXnFEOuPH845wD3TxPwh+QTf0fStuzjoRLUZWpHnio4z7qGGRYusn/sw==} engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/markdown@0.0.18': resolution: {integrity: sha512-gSuYK5fsMbGk87jDebqQ6fa2fKcWlkf2Dkva8kMONqLgGCq8/0d+ZQYMEJsdidIeBo3kmsnHZPrwdFB4HgjUXg==} engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/preview@0.0.14': resolution: {integrity: sha512-aYK8q0IPkBXyMsbpMXgxazwHxYJxTrXrV95GFuu2HbEiIToMwSyUgb8HDFYwPqqfV03/jbwqlsXmFxsOd+VNaw==} engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + + '@react-email/render@2.0.6': + resolution: {integrity: sha512-xOzaYkH3jLZKqN5MqrTXYnmqBYUnZSVbkxdb5PGGmDcK6sKDVMliaDiSwfXajRC9JtSHTcGc2tmGLHWuCgVpog==} + engines: {node: '>=20.0.0'} peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^18.0 || ^19.0 || ^19.0.0-rc - '@react-email/render@2.0.4': - resolution: {integrity: sha512-kht2oTFQ1SwrLpd882ahTvUtNa9s53CERHstiTbzhm6aR2Hbykp/mQ4tpPvsBGkKAEvKRlDEoooh60Uk6nHK1g==} + '@react-email/render@2.1.0': + resolution: {integrity: sha512-F+zE3O6d6sW6Aj2UjvZAA17R7tJKM7kcq2mgV6k4HCT8jeLLFaVP2txMtH1lgqYFRMZ0Gxsd37q2PRyiXLXXxA==} engines: {node: '>=20.0.0'} peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc @@ -1348,30 +1471,33 @@ packages: '@react-email/row@0.0.13': resolution: {integrity: sha512-bYnOac40vIKCId7IkwuLAAsa3fKfSfqCvv6epJKmPE0JBuu5qI4FHFCl9o9dVpIIS08s/ub+Y/txoMt0dYziGw==} engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/section@0.0.17': resolution: {integrity: sha512-qNl65ye3W0Rd5udhdORzTV9ezjb+GFqQQSae03NDzXtmJq6sqVXNWNiVolAjvJNypim+zGXmv6J9TcV5aNtE/w==} engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc - '@react-email/tailwind@2.0.5': - resolution: {integrity: sha512-7Ey+kiWliJdxPMCLYsdDts8ffp4idlP//w4Ui3q/A5kokVaLSNKG8DOg/8qAuzWmRiGwNQVOKBk7PXNlK5W+sg==} + '@react-email/tailwind@2.0.7': + resolution: {integrity: sha512-kGw80weVFXikcnCXbigTGXGWQ0MRCSYNCudcdkWxebkWYd0FG6/NPoN3V1p/u68/4+NxZwYPVi2fhnp0x23HdA==} engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: - '@react-email/body': 0.2.1 - '@react-email/button': 0.2.1 - '@react-email/code-block': 0.2.1 - '@react-email/code-inline': 0.0.6 - '@react-email/container': 0.0.16 - '@react-email/heading': 0.0.16 - '@react-email/hr': 0.0.12 - '@react-email/img': 0.0.12 - '@react-email/link': 0.0.13 - '@react-email/preview': 0.0.14 - '@react-email/text': 0.1.6 + '@react-email/body': '>=0' + '@react-email/button': '>=0' + '@react-email/code-block': '>=0' + '@react-email/code-inline': '>=0' + '@react-email/container': '>=0' + '@react-email/heading': '>=0' + '@react-email/hr': '>=0' + '@react-email/img': '>=0' + '@react-email/link': '>=0' + '@react-email/preview': '>=0' + '@react-email/text': '>=0' react: ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: '@react-email/body': @@ -1398,6 +1524,7 @@ packages: '@react-email/text@0.1.6': resolution: {integrity: sha512-TYqkioRS45wTR5il3dYk/SbUjjEdhSwh9BtRNB99qNH1pXAwA45H7rAuxehiu8iJQJH0IyIr+6n62gBz9ezmsw==} engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc @@ -1419,141 +1546,141 @@ packages: rollup: optional: true - '@rollup/rollup-android-arm-eabi@4.63.5': - resolution: {integrity: sha512-J25QJU+B78T4FhhBsNpLJyVWOi31mwtpcMwywHmOKH65Q9IWGA81gPj+dnwlhU8wktVriYE+tFAaQgrnJRzAZg==} + '@rollup/rollup-android-arm-eabi@4.64.0': + resolution: {integrity: sha512-FLLpX941CD/7aBvX3GqLGBM9OvY5PGXTryPJkG5r5/OuBpZpV5k45Uo3RxAvqd35tsyc3hH1bhtz921IKzoIzg==} cpu: [arm] os: [android] - '@rollup/rollup-android-arm64@4.63.5': - resolution: {integrity: sha512-LDopB3zuZM5Ux9TT2luNEBJW/tYbGU2g1d+VpKk6I+gSKDb+/7sYE6M225gRQt4RbMX6MSwMsVR/phdjVUgRLg==} + '@rollup/rollup-android-arm64@4.64.0': + resolution: {integrity: sha512-iI32XM/jnDYJsvx5ai/za/7gWO9XoSeFFtU06/ZCaRgyilfJOjaopD3nOpk2WrTCGOyeDt4hleUiGOAnd5tHNg==} cpu: [arm64] os: [android] - '@rollup/rollup-darwin-arm64@4.63.5': - resolution: {integrity: sha512-wlJEERGfeuHeBavCL2qVnNacOK43NDoZM4sjkeRPymd04OAE9T1zBqDJgmZ+CIsPTYKwdzpUC8vmOw84dwY4Tg==} + '@rollup/rollup-darwin-arm64@4.64.0': + resolution: {integrity: sha512-dRBZlbt2sEtF8Mn4MODThze9fIPdDoOhVSlXMAKy5dQbOYK9pOOoPY4Fat3OTtr0+8zbJo5TTZwlsfxY/kDNJg==} cpu: [arm64] os: [darwin] - '@rollup/rollup-darwin-x64@4.63.5': - resolution: {integrity: sha512-4nJJGg5jbo2wwPP4JP+LfEBA3bvP8rU9CLuhp7jWvq9sxEyhjQFTFdrqi+/dHEin/pd8jpT0vcehIpnZtmEdcQ==} + '@rollup/rollup-darwin-x64@4.64.0': + resolution: {integrity: sha512-Y3whrJOMucfkhgNkvcM93M6j8C53iEAMmPMYD99C6G1kUy4ELigLWk9ar2/4aCBSRgsql0zdlOemLgW2m5GZgQ==} cpu: [x64] os: [darwin] - '@rollup/rollup-freebsd-arm64@4.63.5': - resolution: {integrity: sha512-DrZbyCDF1hneuO6jRbvZ2D7+PIBM6yIwYnJpg2vIk58T+wuFpiaGZrfUr59lDWw45bg+IrpTGLPiNi/Fk4w3Cg==} + '@rollup/rollup-freebsd-arm64@4.64.0': + resolution: {integrity: sha512-S6S1GEzPjFF6IONYJX3AKt/799+8RgBgCcwRnUo+Ep1IvBxUbw5M51iU0TDdK2NWWVnSvCQ8yi6Em0fGASMOng==} cpu: [arm64] os: [freebsd] - '@rollup/rollup-freebsd-x64@4.63.5': - resolution: {integrity: sha512-gqfUVMJMB3mehqywxp6hTBFfgtMQykZY19+cfiaYP0toIJLb/1DZRJHVkQQGP13W4TAwfZDWeg1qBcheTRioXQ==} + '@rollup/rollup-freebsd-x64@4.64.0': + resolution: {integrity: sha512-Ed1DPoFENkXj4IGzSB12SesV40iP6LxCCrbTIkSGIQBpOOxpBM+0kj62lmaeIHfMWTDoddqTOtqc7s8LYfn+Pw==} cpu: [x64] os: [freebsd] - '@rollup/rollup-linux-arm-gnueabihf@4.63.5': - resolution: {integrity: sha512-CFmhpvAwzSaWMlN3VN7UtmoTihlZNzoP0juQib5TQRnYUyDV8dXeWOp29sobWAT6gXl/hQgAClLlEiYozQG3OQ==} + '@rollup/rollup-linux-arm-gnueabihf@4.64.0': + resolution: {integrity: sha512-J/6xhutJPtFyIkmHdf4olOdHHKH5kOJNumojdWn+Elv5ICEYTVOa362Vw27TxsK0BLzb1le3EDlO3UbvFK1lnQ==} cpu: [arm] os: [linux] libc: [glibc] - '@rollup/rollup-linux-arm-musleabihf@4.63.5': - resolution: {integrity: sha512-Uc9H8eXCOayV6JLTH5bXKMId6qbhNHa818/BgYjm4jrlq3vZquC9cqyvHBw17xy5Mnj5f+I3gFK5JcEf3hSqrw==} + '@rollup/rollup-linux-arm-musleabihf@4.64.0': + resolution: {integrity: sha512-G4L/Nnx59d2LvDoV/P4S5lZ7oVLT6hodOHMacqjiq2LL2gts86KbkEBTYL53+fBt0odhf7XPIbIATGoYiJctxg==} cpu: [arm] os: [linux] libc: [musl] - '@rollup/rollup-linux-arm64-gnu@4.63.5': - resolution: {integrity: sha512-VcPr/szv/1BFw112Kt//fxulXt/JPqzzidU84iW68L2DdjnOO8QFUv2zTSYBEPHD6movBD4z+bbr5y60GYM7Jw==} + '@rollup/rollup-linux-arm64-gnu@4.64.0': + resolution: {integrity: sha512-zF16pvJDEUksGYRbfnXne7pdklAyj2XDmJeYKgP93oq0cUyX9Y8O7lUo1R+IY1GagP/YBvCEDCe7eEcAKpw//g==} cpu: [arm64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-arm64-musl@4.63.5': - resolution: {integrity: sha512-BnxtJ5/91BrIHYIkGrmjz/lbMhqEHt1dPFqIxIFR+jPn0xVc/oUSCtIT089zfp5ufwGDlYz2UC+Fe1SRBpYFbQ==} + '@rollup/rollup-linux-arm64-musl@4.64.0': + resolution: {integrity: sha512-nIggWn8n+w12+R5lsV+PYYoUY8vuObxPAQqJI7WES+9v0qspYuAHoZMfYM0yLxlipasT5BY9ht5W8t5c/rSCkg==} cpu: [arm64] os: [linux] libc: [musl] - '@rollup/rollup-linux-loong64-gnu@4.63.5': - resolution: {integrity: sha512-LrYcHZwF+fAMNKHYTOQ5osWM4AZF7YF6D+XtsjDyEvljtt11twc+zHVXBLNEjxVSUnKYsOhvVz4Z213eW02COQ==} + '@rollup/rollup-linux-loong64-gnu@4.64.0': + resolution: {integrity: sha512-WLOTiHPzszP0dxIMsHpFeDSOSB5I49Mj8K4+yKE3H1X/D0JRG5XZ5jrctJLMjnUc/LUzaKiwq6EHWNJ8pup8PA==} cpu: [loong64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-loong64-musl@4.63.5': - resolution: {integrity: sha512-nj7QKQePAAUpCpJHtg0pR0W/b92A9NO17JS3BAQmHDn/yhmkir2p8llrKY9TOhleKIaSzy1JhxS3T9FVld6coA==} + '@rollup/rollup-linux-loong64-musl@4.64.0': + resolution: {integrity: sha512-35RK9aRhh7g7Pmeu5gQ01aCvrhXzj9LoBE6xtUcEH1ns+nyTaSzxSpADmBQINu4QiTDGvmQYwo1j5hlg1rRXFw==} cpu: [loong64] os: [linux] libc: [musl] - '@rollup/rollup-linux-ppc64-gnu@4.63.5': - resolution: {integrity: sha512-5ylkX6dWMeBKge9nTU+Rxfb+ZfaCIJ9lRqIFaK0eAMcWp7OJbYnLveLgXmm0VrvuLKb8qIK+mHyH0qu88RM+iA==} + '@rollup/rollup-linux-ppc64-gnu@4.64.0': + resolution: {integrity: sha512-RqTfnpoTjuWfPAmqK5p2edt2AMCDJWm6oTn7sKfmyx+6tRga0ip05SJ977o6udTcIiDeF/CJMUfkaN4gtWt6Aw==} cpu: [ppc64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-ppc64-musl@4.63.5': - resolution: {integrity: sha512-oHK4ZHYFDKjZviK34I+NwgfbGxgI7ztrNxj2hPTSSNFgeq1a/lEd7dHV2fdGAuTH4Iym3RHJg+vAbWaWG4B7Zg==} + '@rollup/rollup-linux-ppc64-musl@4.64.0': + resolution: {integrity: sha512-RQjZPigjxFvFZDijG6rjlSXxhRaTf9zLYVpGK+lVcga64oMqP1GbgVqCb46eHTTceSIgp89Ja0y5aPe3GFBYHg==} cpu: [ppc64] os: [linux] libc: [musl] - '@rollup/rollup-linux-riscv64-gnu@4.63.5': - resolution: {integrity: sha512-UcetmHZ6XOXuUByiKZyQmb55ZPr0LABr3Ec/HB9wKZn6CEAFWZkE+hsJErJ9hbPBC7nI0dKuELx7CoV6IM7TMg==} + '@rollup/rollup-linux-riscv64-gnu@4.64.0': + resolution: {integrity: sha512-tjL58mgQcTRnDRpeky+Sth1MKQeYi2Ct6r3Luq9E5aTSjnM/i/JbozqZkKqp9thM5+oDSZxo+05f9kdQedfTGA==} cpu: [riscv64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-riscv64-musl@4.63.5': - resolution: {integrity: sha512-C5CmDPQBtvjVo8cgQsBs+w6WB0JLkiixhgi6hVLV11hERWdn/p0XcPU2OUcZzac9BPOFq7SbaHFa8r3SWEysCQ==} + '@rollup/rollup-linux-riscv64-musl@4.64.0': + resolution: {integrity: sha512-ictoRyVaoZ8C67myHef+8hhCa6IplXJJ/xUCkVCxziV4zxMZh+jvhkhGsPBLqj1S72fnqD0QH5zL4lcaa4X7DQ==} cpu: [riscv64] os: [linux] libc: [musl] - '@rollup/rollup-linux-s390x-gnu@4.63.5': - resolution: {integrity: sha512-lHVQHJFKsuuxLMi3MQO9XVL8Tje3JR82CzB+QDKC5NWBcsIWuwsn9uIM5e3lBhI+fF1/s63qnyYqsg65+8rV/w==} + '@rollup/rollup-linux-s390x-gnu@4.64.0': + resolution: {integrity: sha512-8aVvZ2hfr9RLFzBQkQNmYeHLDja8fLeQAmLt757zdKN/B+8L809KlLJKctQjS22wZotptV8G6o7fVPuAs8S1tQ==} cpu: [s390x] os: [linux] libc: [glibc] - '@rollup/rollup-linux-x64-gnu@4.63.5': - resolution: {integrity: sha512-3W9bTFcQNJn71cSJVM9RKIiZOy8DO/XLDii8Uv/Pm6WKqDRj7JV3ZfuXIEfyuy5LXpIzAbB/1M4Ukp9GKNa7nA==} + '@rollup/rollup-linux-x64-gnu@4.64.0': + resolution: {integrity: sha512-2dEF8GAcDKwshUfydD+GhosppNyBzhVUHkdFF3CXd3FpOkzj/RZzF5aoEtzklujklT5qFVRU2GK/cFeLaTNKVg==} cpu: [x64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-x64-musl@4.63.5': - resolution: {integrity: sha512-VDC7rRJlee/scpki96GZ27Omf6yU87s1YXwVTpjE5841faVlDYYT565rgfmoR1U0sqL7z5ivQSDjcsF6VRXyBA==} + '@rollup/rollup-linux-x64-musl@4.64.0': + resolution: {integrity: sha512-3XHTvoo2hKkh0MiOfS7SkhHjMaaWF7P4F3drocZZaho/Z8w7h/jpnuBHm91BOQLfvrKHs1yR+zir4E8RI3XzTQ==} cpu: [x64] os: [linux] libc: [musl] - '@rollup/rollup-openbsd-x64@4.63.5': - resolution: {integrity: sha512-z86Ok2p4pTdv5xqCKZsTooO7yBEiaJR/HzU3Wx8RmWsPoLppnMKROhJusQob8B3IE1ghC343kUW9rC2r+Wf3ig==} + '@rollup/rollup-openbsd-x64@4.64.0': + resolution: {integrity: sha512-HKtkzodL4a3KFWDXDoO61cRynAWTR3LnAE0oGtfG9lcNFpNuVYUFOByxDgNqEvic9rvaQbAj+SFwTEWDptRaIw==} cpu: [x64] os: [openbsd] - '@rollup/rollup-openharmony-arm64@4.63.5': - resolution: {integrity: sha512-IzQmj+xXwQFGhMAMKMQVXkMwMZN3TqkJgAE0nSsqvVwWWciP4AIPMmWRqOQ2GfX7TUDZr+xqGFcBS36CRPGw0g==} + '@rollup/rollup-openharmony-arm64@4.64.0': + resolution: {integrity: sha512-JBsxqiY16uyyCujtoC3s7zL+SrYyNElptD7K4V+KUoJ2V8/XG3hPWpheK+SuPwNh1cikJvIZmIdsrYXvD6PLJw==} cpu: [arm64] os: [openharmony] - '@rollup/rollup-win32-arm64-msvc@4.63.5': - resolution: {integrity: sha512-F6qpTaPc9bwBH85kjy0/BLmLSW1uv7AoOXCoRIkg2arlgCYlWYcAbiMkvZuAcaWk9TpCRG//okznLAqLGshkMw==} + '@rollup/rollup-win32-arm64-msvc@4.64.0': + resolution: {integrity: sha512-S5Qrijh37qhnUYQ0ay8WN3LuWeKjow7wTUyCOPMZtnXiBXYXdsZETI6ZLfYnQAKovltckPLBFdNeD61P2rJc7g==} cpu: [arm64] os: [win32] - '@rollup/rollup-win32-ia32-msvc@4.63.5': - resolution: {integrity: sha512-igoDsTFhhwECBeGbUuLeIk7t8Y1apa+cs6mDWpx2EZ0ch7oEQgzHbFUXN9euoHekCAQzXdXApAGkV6jznS7tWw==} + '@rollup/rollup-win32-ia32-msvc@4.64.0': + resolution: {integrity: sha512-zbZ6iMmLFEsCtTdc2/y1F/cDFVNNCPF7Ae8PCT/wdPE//hGwYp6r+MyCLMFX+kOeIe09Pz6ASfnnfpSTN1FJQQ==} cpu: [ia32] os: [win32] - '@rollup/rollup-win32-x64-gnu@4.63.5': - resolution: {integrity: sha512-U3teMeMbXFmaM5D+OTJpsOXd+wV/qftIeYF9kBKL4v73641qyJmoXFtA28DQLsnmlyayEsTe72xpLHrArq6vHw==} + '@rollup/rollup-win32-x64-gnu@4.64.0': + resolution: {integrity: sha512-L6Hw69oNpuYaOr1zCG30v0/DqKYCSRwodPGwIcVZ/6f8SOzq9rxtL/xNVI5MO4wjli+Ko0ErK9uHJL3mNPt8wQ==} cpu: [x64] os: [win32] - '@rollup/rollup-win32-x64-msvc@4.63.5': - resolution: {integrity: sha512-ypfC34F3RKXvCXBglGqGMsUSMKlgwd1HX9AOAlx9RoZZ6GaI42YHVeKpzg3JG+wpBUJYTG+NNZhqbDWL8tBZkw==} + '@rollup/rollup-win32-x64-msvc@4.64.0': + resolution: {integrity: sha512-nl+CTWIgOGszQmUz/kanfgrfFZ4qiwxKIfT/ryl3N3Ewk5tqizmYtNXiqb7Bu/fDtNgu1kyxdFiAUJNur3iysg==} cpu: [x64] os: [win32] @@ -1563,56 +1690,59 @@ packages: '@selderee/plugin-htmlparser2@0.11.0': resolution: {integrity: sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==} - '@sentry/browser-utils@11.0.0': - resolution: {integrity: sha512-ZvGyfDoVeSuaAF78vSeYOFcibUCpQfYlUVpqE+B0RANzoSCBg3JimRa+cDYB/rq36PjRwINk4u2viK2JdO320w==} + '@sentry/browser-utils@11.4.0': + resolution: {integrity: sha512-RPQefmLDslO8KsRiSuXNXcReAhJy0rEVi2pbez5HpEn2adipuhgebwDMulac6RYoIUa8liFcBueru0ancW1vhA==} engines: {node: '>=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0'} - '@sentry/browser@11.0.0': - resolution: {integrity: sha512-a09jUpx4OBebmY0XLYXLhfvkCIken8xoncjmOmbPIa3ibUHI6ZJVyh3jAzMu56LY9xfJRV5bOfp92yf41Yr6dQ==} + '@sentry/browser@11.4.0': + resolution: {integrity: sha512-jhq5t6nw8KdBq8i3Uyq5vBCHXehtEM+F5f/OoVxuPS2TtSbGizGjqzhAi24EmTNs4DRlQgYpKPdTOBDTMv49HA==} engines: {node: '>=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0'} - '@sentry/bundler-plugins@11.0.0': - resolution: {integrity: sha512-Oe4/8CbsYrqdjTt91JFMBQza59IWUfSwPrlHGbbJT+qVCyebX2bykxv3mfa6Ty/Gmt8+gva/DI6Kjcyb9EgXBg==} + '@sentry/bundler-plugins@11.4.0': + resolution: {integrity: sha512-THgOMIaA0AMVF4TiCIbugK2jlLYCBIhRF0gclrj7Fte3RMZytkXeH8XeoaR6C6MJTyLCNkEOEccwX9Jj2xOc8g==} engines: {node: '>=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0'} peerDependencies: + '@babel/core': ^7.18.5 rollup: '>=3.2.0' webpack: '>=5.1.0' peerDependenciesMeta: + '@babel/core': + optional: true rollup: optional: true webpack: optional: true - '@sentry/conventions@0.23.0': - resolution: {integrity: sha512-+Euyo7CNVecoOusgUnq27oV97oshifgceKfnGnuKfXrPUVWA/cisvii3E1rlvy2yBrKvl9K1gCEo1Jqog5zjEQ==} + '@sentry/conventions@0.25.0': + resolution: {integrity: sha512-7SouCAX3qx+AzLsxb6gbl3dKVWNhp2wFjdYQ/T90sGwqPPQUoZVA9YwsWwvx07hqcCiSqFW9mUAzyUftASnuhQ==} engines: {node: '>=14'} - '@sentry/core@11.0.0': - resolution: {integrity: sha512-Sdmg21POKmNwZDz8pH/2FtilWeryh3yxhRxbfA1ph8SnX2DkXoqX6G+uXyjCSYagW2pefVklmSNeWHmUlSqioA==} + '@sentry/core@11.4.0': + resolution: {integrity: sha512-kAhK8XVJuzu1IKx9uSu04OISxTU9BcIyDVOWbY/ATUCYr5Z6L7GRBtOGOlYCbxEuPtdZCw5TrbphnBsyO9fuTA==} engines: {node: '>=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0'} - '@sentry/feedback@11.0.0': - resolution: {integrity: sha512-ZdUE6qSfMH4zturLHxg8idfI8cA+JdfrisqUQ6hE6MKaIAAZysNIPvvFysE2WQDjFbj3puS1iZm0jg0ym7kA5g==} + '@sentry/feedback@11.4.0': + resolution: {integrity: sha512-vGcTGvNMgaL5J+LCwK+HVpubU/4/3TRAexVaJi0VcZmr9LnoGiZlWAQunjvWNsabvvkXs120WbXYc8nHysRNzw==} engines: {node: '>=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0'} - '@sentry/nextjs@11.0.0': - resolution: {integrity: sha512-ZiJrzm0hKf9plrRklaLU1QEGpXwYCPe18AzMEhfSNkorP3qJdi5X9xnZV52ap6zB4ypPru88aXUQtDGBdpl4cQ==} + '@sentry/nextjs@11.4.0': + resolution: {integrity: sha512-MrEb0n9KsLfxjb+nX+zluPQH7ojKt2HqWkBU6CCi7pI5/tBZOXPTuQ74vkuyOEza5IhCmHxwevEdFIcBFPWJ4Q==} engines: {node: '>=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0'} peerDependencies: next: ^14.0 || ^15.0.0-rc.0 || ^16.0.0-0 - '@sentry/node@11.0.0': - resolution: {integrity: sha512-ZHk2wYvnKPfaq05YHnva/a6EzZDLTG0EStcWnQdjSae/OxluwCfOfMqwlsKxaY8KDUkyqokTCUPqJITZpUH1Jw==} + '@sentry/node@11.4.0': + resolution: {integrity: sha512-fW587LsZnsuGt1V++59xFCtZ/7jGO3GQq1F+l/Jx5DS18QApXQ99vlfS9H5bxuWixfi5/22ea9M8XjFpkkuzQg==} engines: {node: '>=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0'} - '@sentry/opentelemetry@11.0.0': - resolution: {integrity: sha512-m2EtgppTtN6MxW0dKA45aZMI/xoFZYi2I2cvTIenFGTGLfxyjk6+YCbg0Jf/wdUWoZjR0k6kBBBIbv+zpuvTOA==} + '@sentry/opentelemetry@11.4.0': + resolution: {integrity: sha512-GGDmV+VIGU1p3KGtgR32wrRJejSfEuafbEcSUK7Ww5oxDMYJyHXRSbFjVvGMoAG1ezTl0U6WtMUoWmalsfYHZg==} engines: {node: '>=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0'} peerDependencies: '@opentelemetry/api': ^1.9.0 - '@sentry/react@11.0.0': - resolution: {integrity: sha512-YMF4t9659byD3rYZ9zjE5vdL3NUVFBEQfHzbMJf4kow7h7eyDdQi8GN4K1qsOFBim5C/7/ry0dkP1r4p7qtEWA==} + '@sentry/react@11.4.0': + resolution: {integrity: sha512-8HfNapqjif93dcQ2E6rH/TUyTOSaRe7EOfIKyjcXdkktK2DISbvAHdzVT2ctV9DQ8zBxuUaCzzHcF7s18EYf2w==} engines: {node: '>=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0'} peerDependencies: react: 17.x || 18.x || 19.x @@ -1621,163 +1751,159 @@ packages: react-router: optional: true - '@sentry/replay-canvas@11.0.0': - resolution: {integrity: sha512-cWAx4Fc+T36w4SwN0Ee35xVLrx+nt2kk2An3gKPqgRU0HwxA1w14fKAxcXfKlJcy/K7nnNMgerznvWKgBObenw==} + '@sentry/replay-canvas@11.4.0': + resolution: {integrity: sha512-zfsf90ckrOl/+Z4D4kg4MNY46V4jzUFSZolm+RcrdobS4qikE4xBMvDy1bKZHk+LKfj+K6dxjgZQbasgzHcZ6w==} engines: {node: '>=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0'} - '@sentry/replay@11.0.0': - resolution: {integrity: sha512-4/36Iw288SaWEwLjhpLRjCjL2UmSNEyU0TvLrAlzRAoYdksneEe7iQTSe7WD257buSgop0m+lauGRHQf7fD9qg==} + '@sentry/replay@11.4.0': + resolution: {integrity: sha512-DdgAGaFLOyixQEWGuw8kKcGAVZV8RVVX2kMVV5WqSOMI9if7lhvXdhV+3L0JnZRX7X/6RBqYxYH/C1E0hK43qg==} engines: {node: '>=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0'} - '@sentry/server-runtime-injection@11.0.0': - resolution: {integrity: sha512-AE39xdGbi8WmcB9LtITuvJkp+sYxkPAR+3lRG4MYrJLVQS+YeYfn/AaZdEXOhnOagdpozsDbpEneGJn6vD8p6Q==} + '@sentry/server-runtime-injection@11.4.0': + resolution: {integrity: sha512-8IaiqIJuJySztjxdC0UXP3lHlBAr8HsI8kUMEDV7REYhgmz2+B4BmKyyvaZfeasJlAvzkvSOLZ/Xco7kPikZdQ==} engines: {node: '>=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0'} - '@sentry/server-utils@11.0.0': - resolution: {integrity: sha512-XlKDtLoNqOhI+TgCNywan/tFy0yMRzw5+8mdp96KNHtF/YwO9s6c7qZcBG0x8+XDyC0EoNXDlBPJOMEZo5lD4Q==} + '@sentry/server-utils@11.4.0': + resolution: {integrity: sha512-GA4RvjRXyKFawD7Zi5AlgHPvm9Q8d9VeWer6g9qj4mDavi+VuNFhRmhgsv1qU1l3hlUeMYCpbKhpDOaOH/TcoQ==} engines: {node: '>=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0'} - '@sentry/vercel-edge@11.0.0': - resolution: {integrity: sha512-tcDbhQygLnTeU9KNBjO4xYiVt5LIx7zsV7ywKznun6hQc+oWnJYbWrEJd+4ERSN5n9S368rX7Q7CDAmb8ioj+g==} + '@sentry/vercel-edge@11.4.0': + resolution: {integrity: sha512-0C56mHahJLpbIOD7eQxUWtDs+IU8qWLJO0ZsFH2/bIWEIldvEeLAucEyfBmTcEwYW6/Uen/HN0T0SBt/8co1fA==} engines: {node: '>=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0'} - '@smithy/core@3.24.7': - resolution: {integrity: sha512-KoUi4M1f3BG6kzN1FnCwL7oyFptTbyBJKjR6yhSib+JHRdUmM1o+VwsFtJ66NZCkCzVfJMWRHJNo0R0jznp0Pg==} + '@smithy/core@3.35.1': + resolution: {integrity: sha512-i4YPS4B6ts7bjn7UwLnGjiZdprOvHvgGobFZsYK3GIY3E5hIqtj0rReU69BcTpGp+fvtraSNXeG1l+jtJvF55w==} engines: {node: '>=18.0.0'} - '@smithy/credential-provider-imds@4.3.9': - resolution: {integrity: sha512-ZlfJ/4Fa3jYb+3eaohPfG9utX9HmdhFNcFtpoGAhUhdynAOmGXtmigbi7eEiONKM+ykHw8RwKuDEb85Lx7t7fA==} + '@smithy/credential-provider-imds@4.5.2': + resolution: {integrity: sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==} engines: {node: '>=18.0.0'} - '@smithy/fetch-http-handler@5.4.7': - resolution: {integrity: sha512-NslaM2ir0N2hisDmzXLstPaVINZheh8SokyOC++kzFPloZucL2R7Y7bS57mSzx/1Fc/fqmn7twjkeezTTrV0EA==} + '@smithy/fetch-http-handler@5.8.0': + resolution: {integrity: sha512-ycSJu3tFAQ4v04CBB0agqFMVsSQ1iG3yw+SpgxRqKfaURpQD4CZ8Wn0zPMmSnOuTpTh65Vz+EA0rMrw089wvkA==} engines: {node: '>=18.0.0'} - '@smithy/is-array-buffer@2.2.0': - resolution: {integrity: sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==} - engines: {node: '>=14.0.0'} - - '@smithy/node-http-handler@4.7.8': - resolution: {integrity: sha512-f+DbsWUwSbtMu1a/j8Y93KiU1SRg9nyzfjereqn1BJ33QOTUXxdlYvVXMhAYl1vuR1Kmna5aIJe09KSIfyFNYw==} + '@smithy/node-http-handler@4.12.1': + resolution: {integrity: sha512-ThMkboGeONWXAelq9FvGsuJC4rOi+qyC4/zhUF58xYpxUg5sQKx2VXZYJmtNjr4dSuBJ1HeJXETQILCz3wOHvw==} engines: {node: '>=18.0.0'} - '@smithy/signature-v4@5.4.7': - resolution: {integrity: sha512-LwQZazFayImv+IOm0S0enoLeUJwmAlhGC5O6YCcLWezyu08dF46GOxPOq35OpBIHkgd7OvNvBStIFwVNyrvoBw==} + '@smithy/signature-v4@5.7.4': + resolution: {integrity: sha512-tHy0K0VtqNd5Y7Y41h0a0Lhh0L1GzC08dTWg0F7vRJWFtTENg7IZikf3wQkanYIRdb7ngoIPMTmqgUi401fEeQ==} engines: {node: '>=18.0.0'} - '@smithy/types@4.14.4': - resolution: {integrity: sha512-B2S9+UGm1+/pHkcx3ZoLVX1a+pmSk8rqxRR+ZsNqZaJ5q9FWX9AFGQVM4qG5+OBeQUZVy99HY8HqW8gK/wgXzQ==} + '@smithy/types@4.19.0': + resolution: {integrity: sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q==} engines: {node: '>=18.0.0'} - '@smithy/util-buffer-from@2.2.0': - resolution: {integrity: sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==} - engines: {node: '>=14.0.0'} - - '@smithy/util-utf8@2.3.0': - resolution: {integrity: sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==} - engines: {node: '>=14.0.0'} - '@stablelib/base64@1.0.1': resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==} '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - '@supabase/auth-js@2.98.0': - resolution: {integrity: sha512-GBH361T0peHU91AQNzOlIrjUZw9TZbB9YDRiyFgk/3Kvr3/Z1NWUZ2athWTfHhwNNi8IrW00foyFxQD9IO/Trg==} - engines: {node: '>=20.0.0'} + '@supabase/auth-js@2.117.2': + resolution: {integrity: sha512-Z3WnGwrphYZubrLbxp5Iv0tLA9A5GhvKzJ/ZXglxqwd2QEH2R4dXRltjVJ8sIn4xEW2BSFIGT0yALVJIgZeDYw==} + engines: {node: '>=22.0.0'} - '@supabase/functions-js@2.98.0': - resolution: {integrity: sha512-N/xEyiNU5Org+d+PNCpv+TWniAXRzxIURxDYsS/m2I/sfAB/HcM9aM2Dmf5edj5oWb9GxID1OBaZ8NMmPXL+Lg==} - engines: {node: '>=20.0.0'} + '@supabase/functions-js@2.117.2': + resolution: {integrity: sha512-6DT4ZIjmZxa9ANKaBIrjc86AyKrX8M376ynvNeOBBVad3eP7x3UHqWfDR2ynFkHGzCuOpr1A+kObmnahxuwuog==} + engines: {node: '>=22.0.0'} - '@supabase/postgrest-js@2.98.0': - resolution: {integrity: sha512-v6e9WeZuJijzUut8HyXu6gMqWFepIbaeaMIm1uKzei4yLg9bC9OtEW9O14LE/9ezqNbSAnSLO5GtOLFdm7Bpkg==} - engines: {node: '>=20.0.0'} + '@supabase/phoenix@0.4.5': + resolution: {integrity: sha512-aAn9H9ovVyeApKy11OWOrrOGq8DV68yWeH4ud2lN9fzn4aO8Zb5GLL9m1pUg9nLqIcT+ZDfAcsZe0E/nqdv2lw==} - '@supabase/realtime-js@2.98.0': - resolution: {integrity: sha512-rOWt28uGyFipWOSd+n0WVMr9kUXiWaa7J4hvyLCIHjRFqWm1z9CaaKAoYyfYMC1Exn3WT8WePCgiVhlAtWC2yw==} - engines: {node: '>=20.0.0'} + '@supabase/postgrest-js@2.117.2': + resolution: {integrity: sha512-V1Qhn+M8xzJqCasOxHZ2KG19Fj39PxU7weEAmOK65/KTsWCRXboB4hcQwFvxRvM7ZikeP9IZWNvQkfjvSxzYFw==} + engines: {node: '>=22.0.0'} + + '@supabase/realtime-js@2.117.2': + resolution: {integrity: sha512-lYXSAIg3eAKA58riUED6Vb+TCzF8jtx18uOoiCaVJ7ZMre6FWBpwB1MPeW+B6vykJT/hPdiuqwcSB7ILMS21ew==} + engines: {node: '>=22.0.0'} '@supabase/ssr@0.9.0': resolution: {integrity: sha512-UFY6otYV3yqCgV+AyHj80vNkTvbf1Gas2LW4dpbQ4ap6p6v3eB2oaDfcI99jsuJzwVBCFU4BJI+oDYyhNk1z0Q==} peerDependencies: '@supabase/supabase-js': ^2.97.0 - '@supabase/storage-js@2.98.0': - resolution: {integrity: sha512-tzr2mG+v7ILSAZSfZMSL9OPyIH4z1ikgQ8EcQTKfMRz4EwmlFt3UnJaGzSOxyvF5b+fc9So7qdSUWTqGgeLokQ==} - engines: {node: '>=20.0.0'} + '@supabase/storage-js@2.117.2': + resolution: {integrity: sha512-8gAJoVaxZa/War2kFRfJMxGk4M190Q7lJ70BofwGlRxU/u9pIodd6XyTvmXkxOKXWuIYtkDaYPZrIAUt1T/7FQ==} + engines: {node: '>=22.0.0'} - '@supabase/supabase-js@2.98.0': - resolution: {integrity: sha512-Ohc97CtInLwZyiSASz7tT9/Abm/vqnIbO9REp+PivVUII8UZsuI3bngRQnYgJdFoOIwvaEII1fX1qy8x0CyNiw==} - engines: {node: '>=20.0.0'} + '@supabase/supabase-js@2.117.2': + resolution: {integrity: sha512-eSG2VKnHR+Clp1PmidZ1/weJ8PJwoybjva3L2GgKqFG4YDS1Iqmc61psKGZP5xw6OMT2O7ZorPR42PY6q1BOXg==} + engines: {node: '>=22.0.0'} + peerDependencies: + '@opentelemetry/api': '>=1.0.0' + peerDependenciesMeta: + '@opentelemetry/api': + optional: true - '@swc/helpers@0.5.15': - resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} + '@swc/helpers@0.5.23': + resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==} - '@tailwindcss/node@4.2.1': - resolution: {integrity: sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==} + '@tailwindcss/node@4.3.3': + resolution: {integrity: sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==} - '@tailwindcss/oxide-android-arm64@4.2.1': - resolution: {integrity: sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg==} + '@tailwindcss/oxide-android-arm64@4.3.3': + resolution: {integrity: sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==} engines: {node: '>= 20'} cpu: [arm64] os: [android] - '@tailwindcss/oxide-darwin-arm64@4.2.1': - resolution: {integrity: sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw==} + '@tailwindcss/oxide-darwin-arm64@4.3.3': + resolution: {integrity: sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==} engines: {node: '>= 20'} cpu: [arm64] os: [darwin] - '@tailwindcss/oxide-darwin-x64@4.2.1': - resolution: {integrity: sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw==} + '@tailwindcss/oxide-darwin-x64@4.3.3': + resolution: {integrity: sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==} engines: {node: '>= 20'} cpu: [x64] os: [darwin] - '@tailwindcss/oxide-freebsd-x64@4.2.1': - resolution: {integrity: sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA==} + '@tailwindcss/oxide-freebsd-x64@4.3.3': + resolution: {integrity: sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==} engines: {node: '>= 20'} cpu: [x64] os: [freebsd] - '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1': - resolution: {integrity: sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw==} + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3': + resolution: {integrity: sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==} engines: {node: '>= 20'} cpu: [arm] os: [linux] - '@tailwindcss/oxide-linux-arm64-gnu@4.2.1': - resolution: {integrity: sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ==} + '@tailwindcss/oxide-linux-arm64-gnu@4.3.3': + resolution: {integrity: sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] libc: [glibc] - '@tailwindcss/oxide-linux-arm64-musl@4.2.1': - resolution: {integrity: sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==} + '@tailwindcss/oxide-linux-arm64-musl@4.3.3': + resolution: {integrity: sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] libc: [musl] - '@tailwindcss/oxide-linux-x64-gnu@4.2.1': - resolution: {integrity: sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==} + '@tailwindcss/oxide-linux-x64-gnu@4.3.3': + resolution: {integrity: sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==} engines: {node: '>= 20'} cpu: [x64] os: [linux] libc: [glibc] - '@tailwindcss/oxide-linux-x64-musl@4.2.1': - resolution: {integrity: sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==} + '@tailwindcss/oxide-linux-x64-musl@4.3.3': + resolution: {integrity: sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==} engines: {node: '>= 20'} cpu: [x64] os: [linux] libc: [musl] - '@tailwindcss/oxide-wasm32-wasi@4.2.1': - resolution: {integrity: sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==} + '@tailwindcss/oxide-wasm32-wasi@4.3.3': + resolution: {integrity: sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==} engines: {node: '>=14.0.0'} cpu: [wasm32] bundledDependencies: @@ -1788,30 +1914,30 @@ packages: - '@emnapi/wasi-threads' - tslib - '@tailwindcss/oxide-win32-arm64-msvc@4.2.1': - resolution: {integrity: sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA==} + '@tailwindcss/oxide-win32-arm64-msvc@4.3.3': + resolution: {integrity: sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==} engines: {node: '>= 20'} cpu: [arm64] os: [win32] - '@tailwindcss/oxide-win32-x64-msvc@4.2.1': - resolution: {integrity: sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ==} + '@tailwindcss/oxide-win32-x64-msvc@4.3.3': + resolution: {integrity: sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==} engines: {node: '>= 20'} cpu: [x64] os: [win32] - '@tailwindcss/oxide@4.2.1': - resolution: {integrity: sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw==} + '@tailwindcss/oxide@4.3.3': + resolution: {integrity: sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==} engines: {node: '>= 20'} - '@tailwindcss/postcss@4.2.1': - resolution: {integrity: sha512-OEwGIBnXnj7zJeonOh6ZG9woofIjGrd2BORfvE5p9USYKDCZoQmfqLcfNiRWoJlRWLdNPn2IgVZuWAOM4iTYMw==} + '@tailwindcss/postcss@4.3.3': + resolution: {integrity: sha512-JTSZZGQi1AyKirbLN3azmjVzef92tcX7h+iSqPdaeStyFpGpDlKvvpxeOE8njhbUanbRwr3z8DyzhICWnMtQeg==} - '@tanstack/query-core@5.101.0': - resolution: {integrity: sha512-cQetA74EB+seWySv1TTKr828TnP0u39m6LykwDXIo84SNortpDkp30TMEjkqtYCNP9c40uT/iwl6MLiufEt0Ow==} + '@tanstack/query-core@5.104.1': + resolution: {integrity: sha512-TiRghcrGkUTE+M4JBxvpPSpBhxvIzQairw+cksrHYGr+2uwC77yW1SY9nYxnYRYOppvucf8rEg6b5byoz4rTpw==} - '@tanstack/react-query@5.101.0': - resolution: {integrity: sha512-rLlJXSpkqfizLWgkR5+eLeIk0MvTx/meEIR7LRjxic+qxiQP8zVjq7BqQkiCMNLQBlLfuOLqqr6KO5GtrDlmSg==} + '@tanstack/react-query@5.104.1': + resolution: {integrity: sha512-Zz70EgjahNI7aCz8BZxM3vUGU44ycxEAzMuCUjLeA7SVhhOkg3ZgPjPJPvHjs6Rmky/dtmnu1WzjGHNHmhCoZQ==} peerDependencies: react: ^18 || ^19 @@ -1826,213 +1952,266 @@ packages: resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==} engines: {node: '>=12'} - '@tybys/wasm-util@0.10.1': - resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + '@tybys/wasm-util@0.10.4': + resolution: {integrity: sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==} '@types/bcryptjs@3.0.0': resolution: {integrity: sha512-WRZOuCuaz8UcZZE4R5HXTco2goQSI2XxjGY3hbM/xDvwmqFWd4ivooImsMx65OKM6CtNKbnZ5YL+YwAwK7c1dg==} deprecated: This is a stub types definition. bcryptjs provides its own type definitions, so you do not need this installed. - '@types/estree@1.0.8': - resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/d3-array@3.0.3': + resolution: {integrity: sha512-Reoy+pKnvsksN0lQUlcH6dOGjRZ/3WRwXR//m+/8lt1BXeI4xyaUZoqULNjyXXRuh0Mj4LNpkCvhUpQlY3X5xQ==} + + '@types/d3-color@3.1.0': + resolution: {integrity: sha512-HKuicPHJuvPgCD+np6Se9MQvS6OCbJmOjGvylzMJRlDwUXjKTTXs6Pwgk79O09Vj/ho3u1ofXnhFOaEWWPrlwA==} + + '@types/d3-delaunay@6.0.1': + resolution: {integrity: sha512-tLxQ2sfT0p6sxdG75c6f/ekqxjyYR0+LwPrsO1mbC9YDBzPJhs2HbJJRrn8Ez1DBoHRo2yx7YEATI+8V1nGMnQ==} + + '@types/d3-format@3.0.1': + resolution: {integrity: sha512-5KY70ifCCzorkLuIkDe0Z9YTf9RR2CjBX1iaJG+rgM/cPP+sO+q9YdQ9WdhQcgPj1EQiJ2/0+yUkkziTG6Lubg==} + + '@types/d3-geo@3.1.0': + resolution: {integrity: sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==} + + '@types/d3-interpolate@3.0.1': + resolution: {integrity: sha512-jx5leotSeac3jr0RePOH1KdR9rISG91QIE4Q2PYTu4OymLTZfA3SrnURSLzKH48HmXVUru50b8nje4E79oQSQw==} + + '@types/d3-path@3.1.1': + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + + '@types/d3-scale@4.0.2': + resolution: {integrity: sha512-Yk4htunhPAwN0XGlIwArRomOjdoBFXC3+kCxK2Ubg7I9shQlVSJy/pG/Ht5ASN+gdMIalpk8TJ5xV74jFsetLA==} + + '@types/d3-shape@3.1.7': + resolution: {integrity: sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==} + + '@types/d3-time-format@2.1.0': + resolution: {integrity: sha512-/myT3I7EwlukNOX2xVdMzb8FRgNzRMpsZddwst9Ld/VFe6LyJyRp0s32l/V9XoUzk+Gqu56F/oGk6507+8BxrA==} + + '@types/d3-time@3.0.0': + resolution: {integrity: sha512-sZLCdHvBUcNby1cB6Fd3ZBrABbjz3v1Vm90nysCQ6Vt7vd6e/h9Lt7SiJUoEX0l4Dzc7P5llKyhqSi1ycSf1Hg==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/geojson@7946.0.16': + resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} '@types/json5@0.0.29': resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} - '@types/node@25.3.5': - resolution: {integrity: sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA==} + '@types/lodash@4.17.25': + resolution: {integrity: sha512-+K1NIO8I+F9/wNulfVvu23QYd0Pe9/OCqRrim4NoYIf1VoEDL90Ve4ClzpyqBLc7NpGGWRvYNCKZ1BE/Jpf8dQ==} + + '@types/node@24.19.1': + resolution: {integrity: sha512-aS3/DG0oM05K0RIXXP+hKjinGG5IgSSVGzswZxW3O0sS3pH4/fycXundUC9XsszgKCk4gHXylTEK6hyFxVxnoQ==} - '@types/pg@8.20.0': - resolution: {integrity: sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==} + '@types/node@25.9.9': + resolution: {integrity: sha512-b4e2xxj/yMeT2hNlD6x7zNFiQK2Dlmkb9CQ6v3/1G2RezKN4YS2lcEv8lzGm1mhs4f2lM4yNAMI/zUDBZBBt7w==} - '@types/phoenix@1.6.7': - resolution: {integrity: sha512-oN9ive//QSBkf19rfDv45M7eZPi0eEXylht2OLEXicu5b4KoQ1OzXIw+xDSGWxSxe1JmepRR/ZH283vsu518/Q==} + '@types/pg@8.23.1': + resolution: {integrity: sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==} '@types/qrcode@1.5.6': resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==} - '@types/react-dom@19.2.3': - resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + '@types/react-dom@19.3.0': + resolution: {integrity: sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==} peerDependencies: - '@types/react': ^19.2.0 + '@types/react': ^19.3.0 - '@types/react@19.2.14': - resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} - - '@types/stylis@4.2.7': - resolution: {integrity: sha512-VgDNokpBoKF+wrdvhAAfS55OMQpL6QRglwTwNC3kIgBrzZxA4WsFj+2eLfEA/uMUDzBcEhYmjSbwQakn/i3ajA==} + '@types/react@19.3.0': + resolution: {integrity: sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==} '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} - '@types/ws@8.18.1': - resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} - - '@typescript-eslint/eslint-plugin@8.56.1': - resolution: {integrity: sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A==} + '@typescript-eslint/eslint-plugin@8.71.0': + resolution: {integrity: sha512-pqcS9c1HxZTHt7End4nXqd0s5lJrrFzrgCkKFJrsbUnaL6M3+6oBFZaslg6Gjsl3argl2DDRFROnXARaZ2e4Nw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.56.1 + '@typescript-eslint/parser': ^8.71.0 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.0.0' + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/parser@8.56.1': - resolution: {integrity: sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==} + '@typescript-eslint/parser@8.71.0': + resolution: {integrity: sha512-CG4nPk1f2zc8yw4pALqHsFYH2hdo+h1T9daSp21+Hnxi9LOE3GT9hAfTKJCBXVNM2GmYs1eMEP615wPoeOgk3A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.0.0' + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/project-service@8.56.1': - resolution: {integrity: sha512-TAdqQTzHNNvlVFfR+hu2PDJrURiwKsUvxFn1M0h95BB8ah5jejas08jUWG4dBA68jDMI988IvtfdAI53JzEHOQ==} + '@typescript-eslint/project-service@8.71.0': + resolution: {integrity: sha512-aABjw5rjBacYONVPaPiWOCjJu0vEF4a25iQuodlmQYL1trtLZ0X/y+2Vzl3BKI1odM4LnwLE1oUDXYp1wzx1TQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - typescript: '>=4.8.4 <6.0.0' + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/scope-manager@8.56.1': - resolution: {integrity: sha512-YAi4VDKcIZp0O4tz/haYKhmIDZFEUPOreKbfdAN3SzUDMcPhJ8QI99xQXqX+HoUVq8cs85eRKnD+rne2UAnj2w==} + '@typescript-eslint/scope-manager@8.71.0': + resolution: {integrity: sha512-gWF0BhUcnjZxSpLE8ngS/59n2SB0J3YqRxvX1+2aoRJk9hNtHSLOV+TcarFiOr5ipXm3yc1QrI4c9YZc8zyCxw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/tsconfig-utils@8.56.1': - resolution: {integrity: sha512-qOtCYzKEeyr3aR9f28mPJqBty7+DBqsdd63eO0yyDwc6vgThj2UjWfJIcsFeSucYydqcuudMOprZ+x1SpF3ZuQ==} + '@typescript-eslint/tsconfig-utils@8.71.0': + resolution: {integrity: sha512-Z1UlWHADEK2Mlb9NpWfDeSjqoZ5EyrOv4R3eQpbkzqn/EwaIdOpXXupEA1+0ZIOSJSZZDBHG0BrQyN8zUG6Pwg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - typescript: '>=4.8.4 <6.0.0' + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/type-utils@8.56.1': - resolution: {integrity: sha512-yB/7dxi7MgTtGhZdaHCemf7PuwrHMenHjmzgUW1aJpO+bBU43OycnM3Wn+DdvDO/8zzA9HlhaJ0AUGuvri4oGg==} + '@typescript-eslint/type-utils@8.71.0': + resolution: {integrity: sha512-i8uO1qbdxeKgRnS5sCRt6On3/nfo2d2DwQe3Yvjx543zLy7r8ySqRuPPiIIXAhS03U0v5NfAFx+rUgxFzKKwNw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.0.0' + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/types@8.56.1': - resolution: {integrity: sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw==} + '@typescript-eslint/types@8.71.0': + resolution: {integrity: sha512-cJ4OoxPGWvFnBTnSZyaU+qJzGTqPTGJY+gDchj6cRyLRdmIdt4rcsE4twj+zPfrNiWuVi38wijHzShL++Z9atQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@8.56.1': - resolution: {integrity: sha512-qzUL1qgalIvKWAf9C1HpvBjif+Vm6rcT5wZd4VoMb9+Km3iS3Cv9DY6dMRMDtPnwRAFyAi7YXJpTIEXLvdfPxg==} + '@typescript-eslint/typescript-estree@8.71.0': + resolution: {integrity: sha512-PEEF4G5sLLWAS5BpPrUvms4ySZkiBQQZM4z+3ReI46axK5Vqr/vXBQatJQIZZOYdGyPUAKTtsrWzpqKuU+3DEw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - typescript: '>=4.8.4 <6.0.0' + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/utils@8.56.1': - resolution: {integrity: sha512-HPAVNIME3tABJ61siYlHzSWCGtOoeP2RTIaHXFMPqjrQKCGB9OgUVdiNgH7TJS2JNIQ5qQ4RsAUDuGaGme/KOA==} + '@typescript-eslint/utils@8.71.0': + resolution: {integrity: sha512-pKR/tEMVrXZG23UFKUn5BQf3zfmfk7KQceI2cGzywZ5nxM5Eu3hEJU1utjWzydtzBbcJAQhHN8iPCxobHpPcZQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.0.0' + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/visitor-keys@8.56.1': - resolution: {integrity: sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw==} + '@typescript-eslint/visitor-keys@8.71.0': + resolution: {integrity: sha512-8eQ9R218XORK+KLosnf4bu/QsUXvUyVwTbArg7/0NMB1Pu87OJKvj4nhFblkYE8gQV73mW1dx1ptlPCkwRGa7A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@unrs/resolver-binding-android-arm-eabi@1.11.1': - resolution: {integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==} + '@unrs/resolver-binding-android-arm-eabi@1.12.2': + resolution: {integrity: sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w==} cpu: [arm] os: [android] - '@unrs/resolver-binding-android-arm64@1.11.1': - resolution: {integrity: sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==} + '@unrs/resolver-binding-android-arm64@1.12.2': + resolution: {integrity: sha512-YGCRZv/9GLhwmz6mYDeTsm/92BAyR28l6c2ReweVW5pWgfsitWLY8upvfRlGdoyD8HjeTHSYJWyZGD4KJA/nFQ==} cpu: [arm64] os: [android] - '@unrs/resolver-binding-darwin-arm64@1.11.1': - resolution: {integrity: sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==} + '@unrs/resolver-binding-darwin-arm64@1.12.2': + resolution: {integrity: sha512-u9DiNT1auQMO20A9SyTuG3wUgQWB9Z7KjAg0uFuCDR1FsAY8A0CG2S6JpHS1xwm/w1G08bjXZDcyOCjv1WAm2w==} cpu: [arm64] os: [darwin] - '@unrs/resolver-binding-darwin-x64@1.11.1': - resolution: {integrity: sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==} + '@unrs/resolver-binding-darwin-x64@1.12.2': + resolution: {integrity: sha512-f7rPLi/T1HVKZu/u6t87lroib16n8vrSzcyxI7lg4BGO9UF26KhQL44sd9eOUgrTYhvRXtWOIZT5PejdPyJfUA==} cpu: [x64] os: [darwin] - '@unrs/resolver-binding-freebsd-x64@1.11.1': - resolution: {integrity: sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==} + '@unrs/resolver-binding-freebsd-x64@1.12.2': + resolution: {integrity: sha512-BpcOjWCJub6nRZUS2zA20pmLvjtqAtGejETaIyRLiZiQf++cbrjltLA5NN/xaXfqeOBOSlMFbemIl5/S5tljmg==} cpu: [x64] os: [freebsd] - '@unrs/resolver-binding-linux-arm-gnueabihf@1.11.1': - resolution: {integrity: sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==} + '@unrs/resolver-binding-linux-arm-gnueabihf@1.12.2': + resolution: {integrity: sha512-vZTDvdSISZjJx66OzJqtsOhzifbqRjbmI1Mnu49fQDwog5GtDI4QidRiEAYbZCRj9C8YZEW+3ZjqsyS9GR4k2A==} cpu: [arm] os: [linux] - '@unrs/resolver-binding-linux-arm-musleabihf@1.11.1': - resolution: {integrity: sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==} + '@unrs/resolver-binding-linux-arm-musleabihf@1.12.2': + resolution: {integrity: sha512-BiPI+IrIlwcW4nLLMM21+B1dFPzd55yAVgVGrdgDjNef+ch03GdxrcyaIz8X9SsQirh/kCQ7mviyWlMxdh2D7g==} cpu: [arm] os: [linux] - '@unrs/resolver-binding-linux-arm64-gnu@1.11.1': - resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} + '@unrs/resolver-binding-linux-arm64-gnu@1.12.2': + resolution: {integrity: sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==} cpu: [arm64] os: [linux] libc: [glibc] - '@unrs/resolver-binding-linux-arm64-musl@1.11.1': - resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} + '@unrs/resolver-binding-linux-arm64-musl@1.12.2': + resolution: {integrity: sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==} cpu: [arm64] os: [linux] libc: [musl] - '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': - resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} + '@unrs/resolver-binding-linux-loong64-gnu@1.12.2': + resolution: {integrity: sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@unrs/resolver-binding-linux-loong64-musl@1.12.2': + resolution: {integrity: sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@unrs/resolver-binding-linux-ppc64-gnu@1.12.2': + resolution: {integrity: sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==} cpu: [ppc64] os: [linux] libc: [glibc] - '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': - resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} + '@unrs/resolver-binding-linux-riscv64-gnu@1.12.2': + resolution: {integrity: sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==} cpu: [riscv64] os: [linux] libc: [glibc] - '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': - resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} + '@unrs/resolver-binding-linux-riscv64-musl@1.12.2': + resolution: {integrity: sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==} cpu: [riscv64] os: [linux] libc: [musl] - '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': - resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} + '@unrs/resolver-binding-linux-s390x-gnu@1.12.2': + resolution: {integrity: sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==} cpu: [s390x] os: [linux] libc: [glibc] - '@unrs/resolver-binding-linux-x64-gnu@1.11.1': - resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} + '@unrs/resolver-binding-linux-x64-gnu@1.12.2': + resolution: {integrity: sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==} cpu: [x64] os: [linux] libc: [glibc] - '@unrs/resolver-binding-linux-x64-musl@1.11.1': - resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} + '@unrs/resolver-binding-linux-x64-musl@1.12.2': + resolution: {integrity: sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==} cpu: [x64] os: [linux] libc: [musl] - '@unrs/resolver-binding-wasm32-wasi@1.11.1': - resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} + '@unrs/resolver-binding-openharmony-arm64@1.12.2': + resolution: {integrity: sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==} + cpu: [arm64] + os: [openharmony] + + '@unrs/resolver-binding-wasm32-wasi@1.12.2': + resolution: {integrity: sha512-tYFDIkMxSflfEc/h92ZWNsZlHSwgimbNHSO3PL2JWQHfCuC2q316jMyYU9TIWZsFK2bQwyK5VAdYgn8ygPj69A==} engines: {node: '>=14.0.0'} cpu: [wasm32] - '@unrs/resolver-binding-win32-arm64-msvc@1.11.1': - resolution: {integrity: sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==} + '@unrs/resolver-binding-win32-arm64-msvc@1.12.2': + resolution: {integrity: sha512-qzNyg3xL0VPQmCaUh+N5jSitce6k+uCBfMDesWRnlULOZaqUkaJ0ybdT+UqlAWJoQjuqfIU/0Ptx9bteN4D82g==} cpu: [arm64] os: [win32] - '@unrs/resolver-binding-win32-ia32-msvc@1.11.1': - resolution: {integrity: sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==} + '@unrs/resolver-binding-win32-ia32-msvc@1.12.2': + resolution: {integrity: sha512-WD9sY00OfpHVGfsnHZoA8jVT+esS/Bg8z8jzxp5BnDCjjwsuKsPQrzswwpFy4J1AUJbXPRfkpcX0mXrzeXW79g==} cpu: [ia32] os: [win32] - '@unrs/resolver-binding-win32-x64-msvc@1.11.1': - resolution: {integrity: sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==} + '@unrs/resolver-binding-win32-x64-msvc@1.12.2': + resolution: {integrity: sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA==} cpu: [x64] os: [win32] @@ -2040,32 +2219,97 @@ packages: resolution: {integrity: sha512-7qJHGxpQgQr9/vmeS1PktEwvNAF7TI4iJDi8Pu2CFZ9YUGHZH4fOP5TfYlZ4aVxfopnELiE4BS4FBjyK7V1/xQ==} engines: {node: '>=16.0.0'} - '@upstash/qstash@2.9.0': - resolution: {integrity: sha512-RFvWB98ot5SXUZFIV/Av6hYdS+yu700kg+azUaJqV/fqgylUrWkYnCTOE4DJgdMHUEb0l7tH2Xhl70Zd4Q4zHw==} + '@upstash/qstash@2.12.0': + resolution: {integrity: sha512-LRFuMAqdDlVtCnDRLS00DM4lsE6JeP1A696lUcuUdWEXM9Sb1lhKYSuK2gHkG5VBxreR4QJNs909AGabAfSRdg==} + + '@upstash/ratelimit@2.2.0': + resolution: {integrity: sha512-gSydXspG6TJKTh71/gGGv4IGDjD9rXglQfm2jAue2kh4bcfWtEAsG1NW4OqzZd/hubJPojsbOU6AMdS6Xqh0tQ==} + peerDependencies: + '@upstash/redis': ^1.38.2 + + '@upstash/redis@1.39.0': + resolution: {integrity: sha512-VwkpYTdxSfjSulQS5h16NHtg/1UEheBfvez2AeGKinAujOoGXhXCcohQRzsQ/d2g/XzbjzTVGhG8Ds8uOI0Mxg==} + + '@visx/curve@4.0.1-alpha.0': + resolution: {integrity: sha512-jRu61Uz274pV1zyioXmboyrLutYbnKsgjj4njSGCnhdXj5GkZvZbg+ThDb6oOzoAnJOBRLz4rzPlWvNJOzuVMg==} + + '@visx/event@4.0.1-alpha.0': + resolution: {integrity: sha512-EQqCMSv/s8NbFjo+hz3FKsvvYfP+2QslsFJ/24/O5l/W+7UC6J6aAvO0ujVwrTwdYbuQ+vhxKi1xdPdKR/qj1g==} + + '@visx/grid@4.0.1-alpha.0': + resolution: {integrity: sha512-rycutGmTHO+znNdPumheWMglm7YfpffvRwUkVy5zy4WoORIuKTMkDxwnOzHG2xMxU3EE/YCd37xFV5AxA30yeg==} + peerDependencies: + react: ^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0 + + '@visx/group@4.0.1-alpha.0': + resolution: {integrity: sha512-V19l7iQ7jccBv8kao/EByuI6o4xtxzzLV9nqVI1hRvmdzTVsuLpqlwzYCZUXJaTVvUWf8s4D2SQFjGkj/Nw+0w==} + peerDependencies: + react: ^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0 + + '@visx/point@4.0.1-alpha.0': + resolution: {integrity: sha512-ijTfr/Nx09f03vIj9nyTr3z4Xth4Y75427UaogJh6dnIRLMEFHQOwNu791sbfiNj0a+ZXuaE32h0vKrFe4/8Qg==} + + '@visx/responsive@4.0.1-alpha.0': + resolution: {integrity: sha512-o+1zGywQZY0+yOx3Iw87wc4bbPJRr/HnIukTwfOz4UVyj9pB1OQNVHB7OORO1+LBHJceWpB31co/ZV9KHncKrA==} + peerDependencies: + react: ^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0 + + '@visx/scale@4.0.1-alpha.0': + resolution: {integrity: sha512-nzjeE87vFSAXGWFiiNfBpNLAf0Q8Qmf6syvKLjqNi4kGZkdhbUll3E/59YsgWXmjM8+llPLWzGsP+JPvo5eq1A==} + + '@visx/shape@4.0.1-alpha.0': + resolution: {integrity: sha512-62QeiVNmPlterQGwhkEDcbq7M0MqY0lBsK5QKXtM9ZoPZWkuGV3aykA3+Xu20B2FAvyJq4LqJzBc7Sxr+EAdbA==} + peerDependencies: + react: ^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0 + + '@visx/vendor@4.0.0-alpha.0': + resolution: {integrity: sha512-6I+MuqXBcv9jnlcVowHoHKSdk9gXTWkHLKyqBwRWg7LY6A3Ei8SHfubpqGV5rBUSppxMq2RszPJUS6w+H0YgmQ==} + + '@vitest/expect@3.2.7': + resolution: {integrity: sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==} - '@upstash/ratelimit@2.0.8': - resolution: {integrity: sha512-YSTMBJ1YIxsoPkUMX/P4DDks/xV5YYCswWMamU8ZIfK9ly6ppjRnVOyBhMDXBmzjODm4UQKcxsJPvaeFAijp5w==} + '@vitest/mocker@3.2.7': + resolution: {integrity: sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==} peerDependencies: - '@upstash/redis': ^1.34.3 + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@3.2.7': + resolution: {integrity: sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==} - '@upstash/redis@1.38.0': - resolution: {integrity: sha512-wu+dZBptlLy0+MCUEoHmzrY/TnmgDey3+c7EbIGwrLqAvkP8yi5MWZHYGIFtAygmL4Bkz2TdFu+eU0vFPncIcg==} + '@vitest/runner@3.2.7': + resolution: {integrity: sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==} - '@yr/monotone-cubic-spline@1.0.3': - resolution: {integrity: sha512-FQXkOta0XBSUPHndIKON2Y9JeQz5ZeMqLYZVVK93FliNBFm7LNMIZmY6FrMEB9XPcDbE2bekMbZD6kzDkxwYjA==} + '@vitest/snapshot@3.2.7': + resolution: {integrity: sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==} + + '@vitest/spy@3.2.7': + resolution: {integrity: sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==} + + '@vitest/utils@3.2.7': + resolution: {integrity: sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==} acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 - acorn@8.16.0: - resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + acorn@8.18.0: + resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} engines: {node: '>=0.4.0'} hasBin: true - ajv@6.14.0: - resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==} + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} ajv@8.20.0: resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} @@ -2078,11 +2322,12 @@ packages: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} - anynum@1.0.0: - resolution: {integrity: sha512-xjR9/zBVnUOP6ztMIIgShjsxui80nQUQH+5xJnvrYLs+90bF25/KJqaAi8mk+B4RDtX1Nspi6fmp4YTEts8SfA==} + apex-commons@0.8.1: + resolution: {integrity: sha512-F7fqJVh0smeTu1d86uuRSNNZvaM/l11GNQMbu6oAiSvuXp+K8KCufg0oZTy2VQBEEipRLAdFqkHnphlVpTG0Gw==} - apexcharts@5.10.3: - resolution: {integrity: sha512-wwvkSLsodNOc/rHo5MJsn3GPM4Krc5Gs0zKX4Lfzq4LohcTbyKylYUGEqJFmXXxGR7yLbZQz31sB5RTqT5mv1g==} + apexcharts@7.8.0: + resolution: {integrity: sha512-UxzR7IDXs3HDaOoxTsdn7L2dw9TdYp347uiU7WvjKuzCQqODmO7l63rxMmLwtbYrZPd+0Vd7v5VINzJgPyqmIw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -2095,8 +2340,8 @@ packages: resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} engines: {node: '>= 0.4'} - array-includes@3.1.9: - resolution: {integrity: sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==} + array-includes@3.2.0: + resolution: {integrity: sha512-VXY5eFRarnXcYxwBjJzPmEhH55+rmP79/+ueDhi0F+TuqfHCItagIHqxeUZrmgrOPa31QTh9H85DjX3FfJ0FTg==} engines: {node: '>= 0.4'} array.prototype.findlast@1.2.5: @@ -2123,6 +2368,10 @@ packages: resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} engines: {node: '>= 0.4'} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + ast-types-flow@0.0.8: resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==} @@ -2141,12 +2390,12 @@ packages: resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==} engines: {node: '>= 6.0.0'} - axe-core@4.11.1: - resolution: {integrity: sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A==} + axe-core@4.13.0: + resolution: {integrity: sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==} engines: {node: '>=4'} - axios@1.13.6: - resolution: {integrity: sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==} + axios@1.20.0: + resolution: {integrity: sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==} axobject-query@4.1.0: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} @@ -2162,8 +2411,8 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} - baseline-browser-mapping@2.10.0: - resolution: {integrity: sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==} + baseline-browser-mapping@2.11.27: + resolution: {integrity: sha512-ElY12DaROGuan+lMmZ8Cvo/ZUbXPe7Enc/9VU/b1T3Kp4dwytRcNdR8DoSJN5SNJT/CuvcCA0DHDVmMOCePdRQ==} engines: {node: '>=6.0.0'} hasBin: true @@ -2241,25 +2490,25 @@ packages: zod: optional: true - better-result@2.9.2: - resolution: {integrity: sha512-WIFoBPCdnTOdk9inkE1ZRvCZ4P0CpSkAiLlchC65N7n9DcjZ3NhqkBOlafzpOVnO8ixyi37kicmSJ3ENhPZl7Q==} + better-result@2.10.0: + resolution: {integrity: sha512-oQhh0y1qo2/ZKdAAEvHZAqKKiHOFU5k/bW96fE2ScgQOVkJRiHwB+nOS1SgFsYqRlxMDWvefXi9Q3px7QvgNDw==} bowser@2.14.1: resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} - brace-expansion@1.1.12: - resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} + brace-expansion@1.1.21: + resolution: {integrity: sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==} - brace-expansion@5.0.4: - resolution: {integrity: sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==} - engines: {node: 18 || 20 || >=22} + brace-expansion@5.0.12: + resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==} + engines: {node: 20 || >=22} braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} - browserslist@4.28.1: - resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} + browserslist@4.29.3: + resolution: {integrity: sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true @@ -2271,12 +2520,16 @@ packages: magicast: optional: true + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} - call-bind@1.0.8: - resolution: {integrity: sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==} + call-bind@1.0.9: + resolution: {integrity: sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==} engines: {node: '>= 0.4'} call-bound@1.0.4: @@ -2291,24 +2544,28 @@ packages: resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} engines: {node: '>=6'} - camelize@1.0.1: - resolution: {integrity: sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==} + caniuse-lite@1.0.30001814: + resolution: {integrity: sha512-/Uaf1lAzr59XcMpW0o96WoEfr+VXK2OX4U9AgFoiSHsVJ4HppnIFUjtYzsyDH2+tgANaQb2/oxYGwCPapN1FpA==} - caniuse-lite@1.0.30001777: - resolution: {integrity: sha512-tmN+fJxroPndC74efCdp12j+0rk0RHwV5Jwa1zWaFVyw2ZxAuPeG8ZgWC3Wz7uSjT3qMRQ5XHZ4COgQmsCMJAQ==} + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} - chart.js@4.5.1: - resolution: {integrity: sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==} - engines: {pnpm: '>=8'} + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} chokidar@5.0.0: resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} engines: {node: '>= 20.19.0'} + classnames@2.5.1: + resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==} + client-only@0.0.1: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} @@ -2339,6 +2596,9 @@ packages: confbox@0.2.4: resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} + confbox@0.3.1: + resolution: {integrity: sha512-cKUSoKa8YxFZZSmraVi7onONx3amu77ngK3kGpsYHDH7drPwCRkQE1RYMPlLRrMtnciRj274XNRxcHxnKmDSnA==} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} @@ -2353,18 +2613,56 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} - crypto-js@4.2.0: - resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==} + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} - css-color-keywords@1.0.0: - resolution: {integrity: sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==} - engines: {node: '>=4'} + d3-array@3.2.1: + resolution: {integrity: sha512-gUY/qeHq/yNqqoCKNq4vtpFLdoCdvyNpWoC/KNjhGbhDuQpAM9sIQQKkXSNpXa9h5KySs/gzm7R88WkUutgwWQ==} + engines: {node: '>=12'} - css-to-react-native@3.2.0: - resolution: {integrity: sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==} + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} - csstype@3.2.3: - resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-delaunay@6.0.2: + resolution: {integrity: sha512-IMLNldruDQScrcfT+MWnazhHbDJhcRJyOEBAJfwQnHle1RPh6WDuLvxNArUju2VSMSUuKlY5BGHRJ2cYyoFLQQ==} + engines: {node: '>=12'} + + d3-format@3.1.0: + resolution: {integrity: sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==} + engines: {node: '>=12'} + + d3-geo@3.1.0: + resolution: {integrity: sha512-JEo5HxXDdDYXCaWdwLRt79y7giK8SbhZJbFWXqbRTolCHFI5jRqteLzCsq51NKbUoX0PjBVSohxrx+NoOUujYA==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} damerau-levenshtein@1.0.8: resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} @@ -2402,6 +2700,10 @@ packages: resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} engines: {node: '>=0.10.0'} + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -2424,6 +2726,9 @@ packages: defu@6.1.7: resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + delaunator@5.1.0: + resolution: {integrity: sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==} + delayed-stream@1.0.0: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} @@ -2473,8 +2778,11 @@ packages: effect@3.20.0: resolution: {integrity: sha512-qMLfDJscrNG8p/aw+IkT9W7fgj50Z4wG5bLBy0Txsxz8iUHjDIkOgO3SV0WZfnQbNG2VJYb0b+rDLMrhM4+Krw==} - electron-to-chromium@1.5.307: - resolution: {integrity: sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg==} + electron-to-chromium@1.5.444: + resolution: {integrity: sha512-5ss/uJfoDYDHT0lfJzT6FbcskIzROIOPf0BbbFkGcvDzoJU7i//9GDrwwIHQVmIsrAGiF3ihpADBRIsrEFt1rQ==} + + elkjs@0.11.1: + resolution: {integrity: sha512-zxxR9k+rx5ktMwT/FwyLdPCrq7xN6e4VGGHH8hA01vVYKjTFik7nHOxBnAYtrgYUB1RpAiLvA1/U2YraWxyKKg==} emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -2486,8 +2794,8 @@ packages: resolution: {integrity: sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==} engines: {node: '>=14'} - enhanced-resolve@5.20.0: - resolution: {integrity: sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==} + enhanced-resolve@5.26.0: + resolution: {integrity: sha512-9vhedylFonb2YGogzUKX6+Ja72gOJbN1QHAqdrvqLwhdl/QWbKopzoUC9EbQNVsAns/bx/4uyqalrQAoy1IByw==} engines: {node: '>=10.13.0'} entities@4.5.0: @@ -2498,8 +2806,12 @@ packages: resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - es-abstract@1.24.1: - resolution: {integrity: sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==} + es-abstract-get@1.0.0: + resolution: {integrity: sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==} + engines: {node: '>= 0.4'} + + es-abstract@1.24.2: + resolution: {integrity: sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==} engines: {node: '>= 0.4'} es-define-property@1.0.1: @@ -2510,12 +2822,15 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} - es-iterator-helpers@1.2.2: - resolution: {integrity: sha512-BrUQ0cPTB/IwXj23HtwHjS9n7O4h9FX94b4xc5zlTHxeLgTAdzYUDyy6KdExAl9lbN5rtfe44xpjpmj9grxs5w==} + es-iterator-helpers@1.4.0: + resolution: {integrity: sha512-c/A0P0oxkACDc+cKWw8evLXK83oBKgn0qPOqCYT4x9uolpCIJAcYvJC9QYKNDRPsTeGyCrQ326jrvgZWdCdK5Q==} engines: {node: '>= 0.4'} - es-object-atoms@1.1.1: - resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} engines: {node: '>= 0.4'} es-set-tostringtag@2.1.0: @@ -2526,12 +2841,12 @@ packages: resolution: {integrity: sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==} engines: {node: '>= 0.4'} - es-to-primitive@1.3.0: - resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} + es-to-primitive@1.3.4: + resolution: {integrity: sha512-yPDz7wqpg1/mmHLmS3tcfTfbw5f1eryXvyghYBffGdERwe+mV7ZcWzTR8LR17Kvqt3qfPurjlonmnq3MKXIOXw==} engines: {node: '>= 0.4'} - esbuild@0.28.1: - resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} engines: {node: '>=18'} hasBin: true @@ -2543,8 +2858,8 @@ packages: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} - eslint-config-next@16.1.6: - resolution: {integrity: sha512-vKq40io2B0XtkkNDYyleATwblNt8xuh3FWp8SpSz3pt7P01OkBFlKsJZ2mWt5WsCySlDQLckb1zMY9yE9Qy0LA==} + eslint-config-next@16.3.8: + resolution: {integrity: sha512-81vovwMe6NGnoFsl0KUJWzlS+y239i3fdsxs49gSEX3pDQW2cuKy+fxUPXNkQqqx6OCPheYPtBlSufc9aZ0k+w==} peerDependencies: eslint: '>=9.0.0' typescript: '>=3.3.1' @@ -2552,8 +2867,8 @@ packages: typescript: optional: true - eslint-import-resolver-node@0.3.9: - resolution: {integrity: sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==} + eslint-import-resolver-node@0.3.10: + resolution: {integrity: sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ==} eslint-import-resolver-typescript@3.10.1: resolution: {integrity: sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ==} @@ -2568,8 +2883,8 @@ packages: eslint-plugin-import-x: optional: true - eslint-module-utils@2.12.1: - resolution: {integrity: sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==} + eslint-module-utils@2.14.0: + resolution: {integrity: sha512-W2WCRZ9Dqntd+2u8jJcVMV2PKulc6RdLgUUoh/yQr3uB6lo/ZOeGx11sv60/8S4QFFKNslAlWhr9u0Ef7ZW6Ig==} engines: {node: '>=4'} peerDependencies: '@typescript-eslint/parser': '*' @@ -2605,11 +2920,11 @@ packages: peerDependencies: eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9 - eslint-plugin-react-hooks@7.0.1: - resolution: {integrity: sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==} + eslint-plugin-react-hooks@7.1.1: + resolution: {integrity: sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==} engines: {node: '>=18'} peerDependencies: - eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0 eslint-plugin-react@7.37.5: resolution: {integrity: sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==} @@ -2633,9 +2948,10 @@ packages: resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - eslint@9.39.4: - resolution: {integrity: sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==} + eslint@9.39.5: + resolution: {integrity: sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. hasBin: true peerDependencies: jiti: '*' @@ -2662,17 +2978,27 @@ packages: estree-walker@2.0.2: resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} - exsolve@1.0.8: - resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + exsolve@1.1.3: + resolution: {integrity: sha512-kuzL9zherLQoqDeQaS55AiiIg1kb4KYXaSusTtgZTPlvKP2gGCI82LNmBNh5RjDVUXIcmgx+/Mx7vPwRzBbhJA==} fast-check@3.23.2: resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==} engines: {node: '>=8.0.0'} + fast-decode-uri-component@1.0.1: + resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -2686,21 +3012,17 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-querystring@1.1.2: + resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==} + fast-sha256@1.3.0: resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==} - fast-uri@3.1.2: - resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} + fast-uri@3.1.8: + resolution: {integrity: sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==} - fast-xml-builder@1.2.0: - resolution: {integrity: sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==} - - fast-xml-parser@5.7.3: - resolution: {integrity: sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg==} - hasBin: true - - fastq@1.20.1: - resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + fastq@1.20.3: + resolution: {integrity: sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==} fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} @@ -2722,6 +3044,10 @@ packages: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} + find-my-way@9.7.0: + resolution: {integrity: sha512-f2JHn75x2JlwUwLenZypgczR7YWMb/uO9BvUXtus+JMgkbIkLADd38cI4EiV+OQqrGo1Zlq6V8wnqMJ8e62wUQ==} + engines: {node: '>=20'} + find-up@4.1.0: resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} engines: {node: '>=8'} @@ -2734,11 +3060,11 @@ packages: resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} engines: {node: '>=16'} - flatted@3.3.4: - resolution: {integrity: sha512-3+mMldrTAPdta5kjX2G2J7iX4zxtnwpdA8Tr2ZSjkyPSanvbZAcy6flmtnXbEybHrDcU9641lxrMfFuUxVz9vA==} + flatted@3.4.4: + resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==} - follow-redirects@1.15.11: - resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} + follow-redirects@1.16.1: + resolution: {integrity: sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg==} engines: {node: '>=4.0'} peerDependencies: debug: '*' @@ -2754,12 +3080,12 @@ packages: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} - form-data@4.0.5: - resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} engines: {node: '>= 6'} - framer-motion@12.38.0: - resolution: {integrity: sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==} + framer-motion@12.43.0: + resolution: {integrity: sha512-1eaL3RvR/kAlbG7UYcpMptEyzPoENO0c6w7ZnB3/hh2vSAz/6uGAFn6fdoqTBguNstf3MsFhJHsD/0DHiclG+g==} peerDependencies: '@emotion/is-prop-valid': '*' react: ^18.0.0 || ^19.0.0 @@ -2780,8 +3106,8 @@ packages: function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} - function.prototype.name@1.1.8: - resolution: {integrity: sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==} + function.prototype.name@1.2.0: + resolution: {integrity: sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew==} engines: {node: '>= 0.4'} functions-have-names@1.2.3: @@ -2817,11 +3143,11 @@ packages: resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} engines: {node: '>= 0.4'} - get-tsconfig@4.13.6: - resolution: {integrity: sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==} + get-tsconfig@4.14.3: + resolution: {integrity: sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA==} - giget@3.3.0: - resolution: {integrity: sha512-gzi2D96p+AMfDcmJHGDj3KJ9NRiwvlFAU5yfa3ROwWZmFUjX4P43x3BcyRaOMMLto1vUo7C+86+MFhYTl6Ryiw==} + giget@3.3.1: + resolution: {integrity: sha512-r+mvuDjrjMpsdw46Kmeydb8bdHm7wOKw8wNBtTndkjbPjgAp5oUJUxRE76wZFknxIPokfWvep2qSXK37aXE6zg==} hasBin: true glob-parent@5.1.2: @@ -2855,8 +3181,8 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - grammex@3.1.12: - resolution: {integrity: sha512-6ufJOsSA7LcQehIJNCO7HIBykfM7DXQual0Ny780/DEcJIpBlHRvcqEBWGPYd7hrXL2GJ3oJI1MIhaXjWmLQOQ==} + grammex@3.1.13: + resolution: {integrity: sha512-LnPnhOBLEJEVKS8WFDVaA397L9Kq55Q9oSITJiVLHVdhAclfUkWzQv74KhvZHKL2Q09Pb1XdsrOsZ4LfTFFTEg==} graphmatch@1.1.1: resolution: {integrity: sha512-5ykVn/EXM1hF0XCaWh05VbYvEiOL2lY1kBxZtaYsyvjp7cmWOU1XsAdfQBwClraEofXDT197lFbXOEVMHpvQOg==} @@ -2884,8 +3210,8 @@ packages: resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} engines: {node: '>= 0.4'} - hasown@2.0.2: - resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} engines: {node: '>= 0.4'} hermes-estree@0.25.1: @@ -2894,37 +3220,41 @@ packages: hermes-parser@0.25.1: resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} - hono@4.12.25: - resolution: {integrity: sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ==} + hono@4.13.13: + resolution: {integrity: sha512-CQ46U0ZkAGmbT/4UxdzzGJpacP2IeKgY4a5/tOI9AABbpOMfK739wfDXmv1usCk+3RkKj1hQy4/fjhiwa2xlrA==} engines: {node: '>=16.9.0'} html-to-text@9.0.5: resolution: {integrity: sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==} engines: {node: '>=14'} + html5parser@3.0.0: + resolution: {integrity: sha512-iNpSopa+4YHX50UOk825tBy7MghmXHo/ZpLskBYN0kAr1xhH8GlIMk5bLRXcZlfP3AnLUcSuFMu8C4MdOUxA8A==} + htmlparser2@8.0.2: resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==} - http-status-codes@2.3.0: - resolution: {integrity: sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==} + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} iceberg-js@0.8.1: resolution: {integrity: sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==} engines: {node: '>=20.0.0'} - iconify-icon@3.0.2: - resolution: {integrity: sha512-DYPAumiUeUeT/GHT8x2wrAVKn1FqZJqFH0Y5pBefapWRreV1BBvqBVMb0020YQ2njmbR59r/IathL2d2OrDrxA==} + iconify-icon@3.0.3: + resolution: {integrity: sha512-n4ZIChz3N75KqFSWLt9hHGzbrqGQeeoFxCE+nND4WFFa88x8AW8GvmnVZaVNYErhhiZkxJhBfVKMIGrF1wt3hg==} - iconv-lite@0.7.2: - resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} + iconv-lite@0.7.3: + resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} engines: {node: '>=0.10.0'} ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} - ignore@7.0.5: - resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + ignore@7.0.12: + resolution: {integrity: sha512-/8UvqAPU9DGTI9k4mxtf49U37Isfwr8Uts96+SBHIkFxPJnHS0Ew4f00sM4Scd8V8EjM0jUNtVDdv1kPdt35lg==} engines: {node: '>= 4'} import-fresh@3.3.1: @@ -2939,6 +3269,10 @@ packages: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + is-array-buffer@3.0.5: resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} engines: {node: '>= 0.4'} @@ -2962,8 +3296,8 @@ packages: resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} engines: {node: '>= 0.4'} - is-core-module@2.16.1: - resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} + is-core-module@2.17.0: + resolution: {integrity: sha512-J/vG0zBCbIKOQFfufSwyXdMrsohyJIUNkrnmo6WZGzoM7tr/lsbfW5b2BvisL6zsyMzK9UxV9L6c7AoFbyXHOA==} engines: {node: '>= 0.4'} is-data-view@1.0.2: @@ -2974,6 +3308,10 @@ packages: resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} engines: {node: '>= 0.4'} + is-document.all@1.0.0: + resolution: {integrity: sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==} + engines: {node: '>= 0.4'} + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -3062,21 +3400,24 @@ packages: resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} engines: {node: '>= 0.4'} - jiti@2.6.1: - resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true jose@5.10.0: resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==} - jose@6.2.2: - resolution: {integrity: sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==} + jose@6.2.12: + resolution: {integrity: sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==} js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - js-yaml@4.1.1: - resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + + js-yaml@4.3.2: + resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==} hasBin: true jsesc@3.1.0: @@ -3112,8 +3453,8 @@ packages: keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} - kysely@0.29.2: - resolution: {integrity: sha512-s6WVJyEZrbm6jhBpiKHsGHyePMrVQKJ85wZCFCr9W4QHv6WTjWIrdvTmO9hDEA3bNK0xkrE2DqrHsXMLWuZpQg==} + kysely@0.29.6: + resolution: {integrity: sha512-hHaB8C/rfzDDtr/t8YZwxAuPJTT0zHyaPoVzcXwDYhYNAgH/4sIfVhi/XLLIY+bL/FqaIJnjATDbi8ObSELmxg==} engines: {node: '>=22.0.0'} language-subtag-registry@0.3.23: @@ -3130,78 +3471,78 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} - lightningcss-android-arm64@1.31.1: - resolution: {integrity: sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==} + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [android] - lightningcss-darwin-arm64@1.31.1: - resolution: {integrity: sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==} + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [darwin] - lightningcss-darwin-x64@1.31.1: - resolution: {integrity: sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==} + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [darwin] - lightningcss-freebsd-x64@1.31.1: - resolution: {integrity: sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==} + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [freebsd] - lightningcss-linux-arm-gnueabihf@1.31.1: - resolution: {integrity: sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==} + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} engines: {node: '>= 12.0.0'} cpu: [arm] os: [linux] - lightningcss-linux-arm64-gnu@1.31.1: - resolution: {integrity: sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==} + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] libc: [glibc] - lightningcss-linux-arm64-musl@1.31.1: - resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==} + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] libc: [musl] - lightningcss-linux-x64-gnu@1.31.1: - resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==} + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] libc: [glibc] - lightningcss-linux-x64-musl@1.31.1: - resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==} + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] libc: [musl] - lightningcss-win32-arm64-msvc@1.31.1: - resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==} + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [win32] - lightningcss-win32-x64-msvc@1.31.1: - resolution: {integrity: sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==} + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [win32] - lightningcss@1.31.1: - resolution: {integrity: sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==} + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} engines: {node: '>= 12.0.0'} locate-path@5.0.0: @@ -3215,6 +3556,9 @@ packages: lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + long@5.3.2: resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} @@ -3222,6 +3566,9 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + lru-cache@11.5.3: resolution: {integrity: sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==} engines: {node: 20 || >=22} @@ -3229,8 +3576,8 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} - lru.min@1.1.4: - resolution: {integrity: sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==} + lru.min@1.1.5: + resolution: {integrity: sha512-5J9ysMYUpYIg9RF2vJpy9SinEmSviFSe0GyPpCQ4L5QSkLAgeLXlTAOu2ZwWUU5m+0SBl6gUU1R1ZQB3aKypfA==} engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'} lucide-react@0.577.0: @@ -3241,6 +3588,9 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + magic-string@1.4.2: + resolution: {integrity: sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==} + marked@15.0.12: resolution: {integrity: sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==} engines: {node: '>= 18'} @@ -3266,8 +3616,8 @@ packages: resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} engines: {node: '>= 0.6'} - minimatch@10.2.4: - resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==} + minimatch@10.2.6: + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} engines: {node: 18 || 20 || >=22} minimatch@3.1.5: @@ -3280,11 +3630,11 @@ packages: resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} - motion-dom@12.38.0: - resolution: {integrity: sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==} + motion-dom@12.43.0: + resolution: {integrity: sha512-azKON4d9S65PEoFUiQTMTgPheEmzf2QngdRc50AKfJp9Q9mmcBVw22c8eMq9k8kxOFHdL7+WZY7N/5F/lwiDag==} - motion-utils@12.36.0: - resolution: {integrity: sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==} + motion-utils@12.39.0: + resolution: {integrity: sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==} ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -3297,13 +3647,13 @@ packages: resolution: {integrity: sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==} engines: {node: '>=8.0.0'} - nanoid@3.3.11: - resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + nanoid@3.3.19: + resolution: {integrity: sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true - nanostores@1.3.0: - resolution: {integrity: sha512-XPUa/jz+P1oJvN9VBxw4L9MtdFfaH3DAryqPssqhb2kXjmb9npz0dly6rCsgFWOPr4Yg9mTfM3MDZgZZ+7A3lA==} + nanostores@1.5.4: + resolution: {integrity: sha512-4UXY7OAJEK0Y6V3SudBU7cXjAzTDc189R78dDOh0Xbm19gpKYto6WizSg58LJf12lTsRxKSLGyE117OiLpM4DQ==} engines: {node: ^20.0.0 || >=22.0.0} napi-postinstall@0.3.4: @@ -3318,30 +3668,14 @@ packages: resolution: {integrity: sha512-iGBUfFB7yPczHHtA8dksKTJ9E8TESNTAx1UQWW6TzMF280vo9jdPYpLUXrMN1BCkPdHFdNG3fxOt2CUad8KhAw==} engines: {node: '>=18'} - next-auth@5.0.0-beta.30: - resolution: {integrity: sha512-+c51gquM3F6nMVmoAusRJ7RIoY0K4Ts9HCCwyy/BRoe4mp3msZpOzYMyb5LAYc1wSo74PMQkGDcaghIO7W6Xjg==} - peerDependencies: - '@simplewebauthn/browser': ^9.0.1 - '@simplewebauthn/server': ^9.0.2 - next: ^14.0.0-0 || ^15.0.0 || ^16.0.0 - nodemailer: ^7.0.7 - react: ^18.2.0 || ^19.0.0 - peerDependenciesMeta: - '@simplewebauthn/browser': - optional: true - '@simplewebauthn/server': - optional: true - nodemailer: - optional: true - next-themes@0.4.6: resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==} peerDependencies: react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc - next@16.1.6: - resolution: {integrity: sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==} + next@16.3.8: + resolution: {integrity: sha512-U7QEZaTini6wKrb8A8hqLLqYQyCetegKjCpJOyxk642vWoMoU1x5PyZCJFvgYgiptA8xc5j/9xYlZFO7w9Sjmw==} engines: {node: '>=20.9.0'} hasBin: true peerDependencies: @@ -3361,8 +3695,8 @@ packages: sass: optional: true - node-exports-info@1.6.0: - resolution: {integrity: sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==} + node-exports-info@1.6.2: + resolution: {integrity: sha512-kXs9Go0cah0qHVV2v389IXQLdLCeE1xfFtjOAF+iobu0OIoG1pje8At2vMHyaPMiPMnG/LWP50twML21eMcAag==} engines: {node: '>= 0.4'} node-fetch@2.7.0: @@ -3374,11 +3708,9 @@ packages: encoding: optional: true - node-releases@2.0.36: - resolution: {integrity: sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==} - - oauth4webapi@3.8.5: - resolution: {integrity: sha512-A8jmyUckVhRJj5lspguklcl90Ydqk61H3dcU0oLhH3Yv13KpAliKTt5hknpGGPZSSfOwGyraNEFmofDYH+1kSg==} + node-releases@2.0.57: + resolution: {integrity: sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw==} + engines: {node: '>=18'} object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} @@ -3412,19 +3744,19 @@ packages: resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} engines: {node: '>= 0.4'} - ohash@2.0.11: - resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} + ohash@2.0.12: + resolution: {integrity: sha512-65S/5gk9YSsaRjcyf7Nfa6h/d3E8/1gslpXfI4W7Dxn/oap8IKRuNT5VXkLQ1YFKIEg4apRY4Pj6aiwFzrDdmw==} optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} - own-keys@1.0.1: - resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} + own-keys@1.0.2: + resolution: {integrity: sha512-19YVAg7T+WTrxggPukVq7DjTv6+PJ867TmhCvBsYwmbFCsZd344rq2Ld1p0wo8f8Qrrhgp82c6FJRqdXWtSEhg==} engines: {node: '>= 0.4'} - oxc-parser@0.143.0: - resolution: {integrity: sha512-ov0NzaDCOInknS7mP1cwKdJERt3utPW8ldjtdUXQ8Ty0GEFD08wk422vCUN0d7pST6kqtV7dxoI9w1Zi0l/9TA==} + oxc-parser@0.152.0: + resolution: {integrity: sha512-ZztsZgaCWmPeKYMdoTT5e0oV/oxD1NeLWNR3kAtr/G707cUPYaaozRlVDHLCGnszHJRszN9mJwmPNpqgwloC0A==} engines: {node: ^20.19.0 || >=22.12.0} p-limit@2.3.0: @@ -3458,10 +3790,6 @@ packages: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} - path-expression-matcher@1.5.0: - resolution: {integrity: sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==} - engines: {node: '>=14.0.0'} - path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} @@ -3476,17 +3804,21 @@ packages: pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + peberminta@0.9.0: resolution: {integrity: sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==} perfect-debounce@2.1.0: resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==} - pg-cloudflare@1.4.0: - resolution: {integrity: sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==} + pg-cloudflare@1.4.1: + resolution: {integrity: sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==} - pg-connection-string@2.13.0: - resolution: {integrity: sha512-EMnU9E2fSULdsbErBbMaXJvFeD9B4+nPcM3f+4lsiCR0BHLPrLVjv3DbyM2hgQQviKJaTWIRRTjKjWlHg3p2ig==} + pg-connection-string@2.14.1: + resolution: {integrity: sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA==} pg-int8@1.0.1: resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} @@ -3497,15 +3829,15 @@ packages: peerDependencies: pg: '>=8.0' - pg-protocol@1.14.0: - resolution: {integrity: sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA==} + pg-protocol@1.16.1: + resolution: {integrity: sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg==} pg-types@2.2.0: resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} engines: {node: '>=4'} - pg@8.21.0: - resolution: {integrity: sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA==} + pg@8.23.1: + resolution: {integrity: sha512-aL96AHANtWjPLDOLqnhx+ngp9+UK7ETEU8VJrDCGvsSSi/mGLcWYsS6Herg7lmaBJe4uwrfqsa7gTEFaSizDoQ==} engines: {node: '>= 16.0.0'} peerDependencies: pg-native: '>=3.0.1' @@ -3519,19 +3851,19 @@ packages: picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - picomatch@2.3.1: - resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} engines: {node: '>=8.6'} - picomatch@4.0.3: - resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} engines: {node: '>=12'} - pingram@1.0.1: - resolution: {integrity: sha512-uFgmGvjCtdqp7+7gB1TNaec8GQw6HKNdJQmvygDFy37g6ecvRFaOzX0po7T51hrphLV5+KCZaaEA4ixgCX7b+A==} + pingram@1.0.36: + resolution: {integrity: sha512-ldZzf6m7UvGg0+Kg6WiXpwKRdEaCzQxrlNdWCGkw8ErxsCDJWxaGF17omgZtlhcONk6SYL4AE2F1d9E2JwArIA==} - pkg-types@2.3.1: - resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==} + pkg-types@2.3.3: + resolution: {integrity: sha512-j/lCFdcppV0JxWpCEITdbDltBxPP6cHT+yNJ6Go2OgoSA9518X847X9z0p6LtA4Nc16+eQzCZjRrWanTGvHJ5w==} pngjs@5.0.0: resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} @@ -3541,22 +3873,15 @@ packages: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} - postal-mime@2.7.3: - resolution: {integrity: sha512-MjhXadAJaWgYzevi46+3kLak8y6gbg0ku14O1gO/LNOuay8dO+1PtcSGvAdgDR0DoIsSaiIA8y/Ddw6MnrO0Tw==} - - postcss-value-parser@4.2.0: - resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} - - postcss@8.4.31: - resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} - engines: {node: ^10 || ^12 || >=14} + postal-mime@2.7.6: + resolution: {integrity: sha512-UUlyE2KlxmvwMGq060onF/VWU3zD6dVW31FwokQ5v26jWd35fbs2MoTFzh+jXnPwAyV2MULMKXcBInGOl5TO6Q==} - postcss@8.4.49: - resolution: {integrity: sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==} + postcss@8.5.23: + resolution: {integrity: sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==} engines: {node: ^10 || ^12 || >=14} - postcss@8.5.8: - resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} + postcss@8.5.28: + resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} engines: {node: ^10 || ^12 || >=14} postgres-array@2.0.0: @@ -3583,8 +3908,8 @@ packages: resolution: {integrity: sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==} engines: {node: '>=12'} - posthog-js@1.434.15: - resolution: {integrity: sha512-ZfBkNrQiFr1Q43G3dR0USzTRdfx+6gKPebyoWDmKnLUhG3yRgYjLH914J6ifEDRnH+RaGOx7rsWKxKcvaSJXHg==} + posthog-js@1.435.8: + resolution: {integrity: sha512-rUrv0kKhD72Wfu++pBlupvrpt1Z60uTAgNUChAH232a05bbRBzV0P4JqbuZEAaaUXumhuB3a8r+iFMzm/2l1Ow==} peerDependencies: '@types/react': '>=16.8.0' react: '>=16.8.0' @@ -3594,14 +3919,6 @@ packages: react: optional: true - preact-render-to-string@6.5.11: - resolution: {integrity: sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw==} - peerDependencies: - preact: '>=10' - - preact@10.24.3: - resolution: {integrity: sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==} - preact@10.29.8: resolution: {integrity: sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q==} peerDependencies: @@ -3614,13 +3931,13 @@ packages: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} - prettier@3.8.1: - resolution: {integrity: sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==} + prettier@3.9.9: + resolution: {integrity: sha512-Z/CJHIkdujO/OtN7nXUii0Rf3VT5SRuhjBA82Xvu2XhBUgX3nhP67T0LHceBdQLex7OOFGTox+Q5Yg8Jk2Qivg==} engines: {node: '>=14'} hasBin: true - prisma@7.8.0: - resolution: {integrity: sha512-yfN4yrw7HV9kEJhoy1+jgah0jafEIQsf7uWouSsM8MvJtlubsk+kM7AIBWZ8+GJl74Yj3c+nbYqBkMOxtsZ3Lw==} + prisma@7.10.0: + resolution: {integrity: sha512-o0ornyJOWgygVAzGCpr8PdXV8EJLHyVGDDUr/voBQt8Azzw8cYTByzzPGcA/m4tCkPcnJA8raEOv2CslsKhPEw==} engines: {node: ^20.19 || ^22.12 || >=24.0} hasBin: true peerDependencies: @@ -3642,8 +3959,9 @@ packages: proper-lockfile@4.1.2: resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} - proxy-from-env@1.1.0: - resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + proxy-from-env@2.1.0: + resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} + engines: {node: '>=10'} punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} @@ -3663,29 +3981,29 @@ packages: queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} - rc9@3.0.1: - resolution: {integrity: sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ==} + rc9@3.1.0: + resolution: {integrity: sha512-ufjkNVzbRHKcCOmTahZkmVsyc3W+MSk3jY03m+a7tGHkIsdVMG9l10/3HvFbWkkKzY5VFp3pkRsIo/UYgmFL7Q==} - react-apexcharts@2.1.0: - resolution: {integrity: sha512-xrmeTKRKHh3cvvLc8SasqFjlOgIqGpyHc81qjnRtcjUM0Fu7qEjgVRWGPokGFjqhwRZVgEym8zmuEyYr5LMYIg==} + react-apexcharts@2.1.1: + resolution: {integrity: sha512-aV25n+1ZdAzRG0KhODVC48K8WbhMKBAr3yWSeaaBqM1ySn22kSSKblsNCUvlwegsT+cdMLuOrK9xGZb+IK+OkQ==} peerDependencies: apexcharts: '>=5.10.1' react: '>=16.8.0' - react-dom@19.2.4: - resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==} + react-dom@19.3.0: + resolution: {integrity: sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==} peerDependencies: - react: ^19.2.4 + react: ^19.3.0 react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} - react@19.2.4: - resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} + react@19.3.0: + resolution: {integrity: sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==} engines: {node: '>=0.10.0'} - readdirp@5.0.0: - resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} + readdirp@5.1.1: + resolution: {integrity: sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==} engines: {node: '>= 20.19.0'} reflect.getprototypeof@1.0.10: @@ -3710,8 +4028,8 @@ packages: require-main-filename@2.0.0: resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} - resend@6.9.3: - resolution: {integrity: sha512-GRXjH9XZBJA+daH7bBVDuTShr22iWCxXA8P7t495G4dM/RC+d+3gHBK/6bz9K6Vpcq11zRQKmD+B+jECwQlyGQ==} + resend@6.32.0: + resolution: {integrity: sha512-bZZUeKlpKINFT+zGG2iIzfDewkyEHRaE7Xk1haseVz3Gfw6ekkizN6KdKgZSMjt5ywE8vMwr3wTdenlF8P475Q==} engines: {node: '>=20'} peerDependencies: '@react-email/render': '*' @@ -3726,15 +4044,14 @@ packages: resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} - resolve@1.22.11: - resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==} + resolve@2.0.0-next.7: + resolution: {integrity: sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ==} engines: {node: '>= 0.4'} hasBin: true - resolve@2.0.0-next.6: - resolution: {integrity: sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA==} - engines: {node: '>= 0.4'} - hasBin: true + ret@0.5.0: + resolution: {integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==} + engines: {node: '>=10'} retry@0.12.0: resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} @@ -3744,8 +4061,11 @@ packages: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} - rollup@4.63.5: - resolution: {integrity: sha512-KRWwmNLlPw5M7HcdYfm15oBv9n9LPtjzpzCIxS/phwqvPyxHSoKX6Y2YU3pxSPfy0CLquVgsx/j/hBi6OvH1Nw==} + robust-predicates@3.0.3: + resolution: {integrity: sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==} + + rollup@4.64.0: + resolution: {integrity: sha512-gjA/kFeDfffII8COoPsvLPvE16hWDTEc1+0QKQNKUkuV8iJjqsC4xXs8/FzuL0v9EV39kuxTbAgcFoTRyy2TGQ==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true @@ -3755,8 +4075,8 @@ packages: run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} - safe-array-concat@1.1.3: - resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} + safe-array-concat@1.1.4: + resolution: {integrity: sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==} engines: {node: '>=0.4'} safe-push-apply@1.0.0: @@ -3767,11 +4087,15 @@ packages: resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} engines: {node: '>= 0.4'} + safe-regex2@5.1.1: + resolution: {integrity: sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==} + hasBin: true + safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} - scheduler@0.27.0: - resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + scheduler@0.28.0: + resolution: {integrity: sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==} selderee@0.11.0: resolution: {integrity: sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==} @@ -3780,13 +4104,13 @@ packages: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true - semver@7.7.4: - resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} engines: {node: '>=10'} hasBin: true - sentry@0.44.1: - resolution: {integrity: sha512-LK+RJ2PFsJKM+P0D/zYC+M5gFB1ytfVONU9DVUqJu67YFlwnj3wGJkUzBhCS8R4iUqRdRTAYbFbRKTAe03rW/Q==} + sentry@0.45.0: + resolution: {integrity: sha512-kgmEbLlC22lL7uneeUm9Sjz65rp5cvwYOMTxXbZCmBum4sbVre/lLT3zTV3kwXHJ9/BM8ZS0/GhZe9Cl1t6WyA==} engines: {node: '>=20.0'} hasBin: true @@ -3796,8 +4120,8 @@ packages: set-blocking@2.0.0: resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} - set-cookie-parser@3.1.0: - resolution: {integrity: sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==} + set-cookie-parser@3.1.2: + resolution: {integrity: sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==} set-function-length@1.2.2: resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} @@ -3811,12 +4135,14 @@ packages: resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} engines: {node: '>= 0.4'} - shallowequal@1.1.0: - resolution: {integrity: sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==} - - sharp@0.34.5: - resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + sharp@0.35.5: + resolution: {integrity: sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==} + engines: {node: '>=20.9.0'} + peerDependencies: + '@types/node': '*' + peerDependenciesMeta: + '@types/node': + optional: true shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} @@ -3826,8 +4152,8 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} - side-channel-list@1.0.0: - resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} engines: {node: '>= 0.4'} side-channel-map@1.0.1: @@ -3838,10 +4164,13 @@ packages: resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} engines: {node: '>= 0.4'} - side-channel@1.1.0: - resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} engines: {node: '>= 0.4'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@3.0.7: resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} @@ -3849,8 +4178,8 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} - source-map-js@1.2.1: - resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + source-map-js@1.2.2: + resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==} engines: {node: '>=0.10.0'} split2@4.2.0: @@ -3864,12 +4193,15 @@ packages: stable-hash@0.0.5: resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + stacktrace-parser@0.1.11: resolution: {integrity: sha512-WjlahMgHmCJpqzU8bIBy4qtsZdU9lRlcZE3Lvyej6t4tuOuv1vk57OW3MBrj6hXBFx/nNoC9MPMTcr5YA7NQbg==} engines: {node: '>=6'} - standardwebhooks@1.0.0: - resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==} + standardwebhooks@1.1.1: + resolution: {integrity: sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ==} std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} @@ -3886,19 +4218,19 @@ packages: resolution: {integrity: sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==} engines: {node: '>= 0.4'} - string.prototype.matchall@4.0.12: - resolution: {integrity: sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==} + string.prototype.matchall@4.1.0: + resolution: {integrity: sha512-tHNHTxInrYLCga9O9YGxWA3G9/nnzQw8UGAyqGx3Ar1pSTTzIuM4woFSq4SowkXCjJIwq5sIiQvEfRI9tCH1qQ==} engines: {node: '>= 0.4'} string.prototype.repeat@1.0.0: resolution: {integrity: sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==} - string.prototype.trim@1.2.10: - resolution: {integrity: sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==} + string.prototype.trim@1.2.11: + resolution: {integrity: sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w==} engines: {node: '>= 0.4'} - string.prototype.trimend@1.0.9: - resolution: {integrity: sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==} + string.prototype.trimend@1.0.10: + resolution: {integrity: sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw==} engines: {node: '>= 0.4'} string.prototype.trimstart@1.0.8: @@ -3917,18 +4249,24 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} - strnum@2.4.0: - resolution: {integrity: sha512-sHrVyWWdq28RbhjuJdZsA1SnGRJV6NiXbk6AXBxDOsgAcA+lmpUZCYjOdLBxkXMwis6RRe7dlZt4VlIWFVzkmg==} + strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} - styled-components@6.3.11: - resolution: {integrity: sha512-opzgceGlQ5rdZdGwf9ddLW7EM2F4L7tgsgLn6fFzQ2JgE5EVQ4HZwNkcgB1p8WfOBx1GEZP3fa66ajJmtXhSrA==} + styled-components@6.5.3: + resolution: {integrity: sha512-vAX79sfpmUerP9fsTTxoTrBDE0RuO4ahjInyWYoohNgqrdg63Ms4q6FJ/o2Fyity82NU3cujOT8Ewl9TThBdwg==} engines: {node: '>= 16'} peerDependencies: + css-to-react-native: '>= 3.2.0' react: '>= 16.8.0' react-dom: '>= 16.8.0' + react-native: '>= 0.68.0' peerDependenciesMeta: + css-to-react-native: + optional: true react-dom: optional: true + react-native: + optional: true styled-jsx@5.1.6: resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} @@ -3958,20 +4296,35 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} - svix@1.84.1: - resolution: {integrity: sha512-K8DPPSZaW/XqXiz1kEyzSHYgmGLnhB43nQCMeKjWGCUpLIpAMMM8kx3rVVOSm6Bo6EHyK1RQLPT4R06skM/MlQ==} - - tailwindcss@4.2.1: - resolution: {integrity: sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==} + tailwindcss@4.3.3: + resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==} - tapable@2.3.0: - resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} + tapable@2.3.3: + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} engines: {node: '>=6'} - tinyglobby@0.2.15: - resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + + tinyspy@4.0.6: + resolution: {integrity: sha512-u8KszXvGfU68hVcZpRHKG28T0krMuv2G5nDhiHaMLen/gIuFEgIJhaJuO69qjnXg5paSrbPMFfx3brNuN8eVSg==} + engines: {node: '>=14.0.0'} + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -3979,8 +4332,8 @@ packages: tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} - ts-api-utils@2.4.0: - resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==} + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} engines: {node: '>=18.12'} peerDependencies: typescript: '>=4.8.4' @@ -3991,8 +4344,8 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - tsx@4.22.4: - resolution: {integrity: sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==} + tsx@4.23.15: + resolution: {integrity: sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==} engines: {node: '>=18.0.0'} hasBin: true @@ -4012,20 +4365,20 @@ packages: resolution: {integrity: sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==} engines: {node: '>= 0.4'} - typed-array-byte-offset@1.0.4: - resolution: {integrity: sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==} + typed-array-byte-offset@1.0.5: + resolution: {integrity: sha512-0FHJvLPqZ7KJzp17O13jfsAjsqazgrxBu2zEK95PmUz8lv2+GjRuxUInCr2Rk9Dms3ihN21zJ929ZO43yJ95QQ==} engines: {node: '>= 0.4'} - typed-array-length@1.0.7: - resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==} + typed-array-length@1.0.8: + resolution: {integrity: sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==} engines: {node: '>= 0.4'} - typescript-eslint@8.56.1: - resolution: {integrity: sha512-U4lM6pjmBX7J5wk4szltF7I1cGBHXZopnAXCMXb3+fZ3B/0Z3hq3wS/CCUB2NZBNAExK92mCU2tEohWuwVMsDQ==} + typescript-eslint@8.71.0: + resolution: {integrity: sha512-fBdHYiqQ14RW6mOMXD14Svn82ZsCYAoQSzGRzyEjR59S5A2Krh/l7fGTOQ7iCr8gGy/mHVXtEF7s5fgjEdV0Pw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.0.0' + typescript: '>=4.8.4 <6.1.0' typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} @@ -4039,14 +4392,14 @@ packages: uncrypto@0.1.3: resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==} - undici-types@7.18.2: - resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + undici-types@7.24.6: + resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} - unrs-resolver@1.11.1: - resolution: {integrity: sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==} + unrs-resolver@1.12.2: + resolution: {integrity: sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==} - update-browserslist-db@1.2.3: - resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + update-browserslist-db@1.3.3: + resolution: {integrity: sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==} hasBin: true peerDependencies: browserslist: '>= 4.21.0' @@ -4054,18 +4407,87 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} - uuid@10.0.0: - resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==} - hasBin: true - - valibot@1.2.0: - resolution: {integrity: sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==} + valibot@1.4.2: + resolution: {integrity: sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg==} peerDependencies: typescript: '>=5' peerDependenciesMeta: typescript: optional: true + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + + vite@7.3.6: + resolution: {integrity: sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@3.2.7: + resolution: {integrity: sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.7 + '@vitest/ui': 3.2.7 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + web-vitals@6.2.1: resolution: {integrity: sha512-rLcLXA2sx6+9dE88NHFubwTtGxpK4yYBLj6qHPdFoCaLr0cXGb4efOqtKLlm4loGA4OEKHIQKMKzZkKyOh5ctw==} @@ -4093,8 +4515,8 @@ packages: which-module@2.0.1: resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} - which-typed-array@1.1.20: - resolution: {integrity: sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==} + which-typed-array@1.1.24: + resolution: {integrity: sha512-wk4Mf4pR5mRP7eYuuTBCIQ9d0ud2Fv2jRLQpfgnRjbOxAFHmjKFValgTpitVKzJJS8ajnYQV2Du1SZ8j6b/EUQ==} engines: {node: '>= 0.4'} which@2.0.2: @@ -4102,6 +4524,11 @@ packages: engines: {node: '>= 8'} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} @@ -4110,22 +4537,6 @@ packages: resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} engines: {node: '>=8'} - ws@8.19.0: - resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==} - engines: {node: '>=10.0.0'} - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: '>=5.0.2' - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: - optional: true - - xml-naming@0.1.0: - resolution: {integrity: sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==} - engines: {node: '>=16.0.0'} - xtend@4.0.2: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} @@ -4157,438 +4568,376 @@ packages: peerDependencies: zod: ^3.25.0 || ^4.0.0 - zod@4.4.3: - resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + zod@4.6.5: + resolution: {integrity: sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==} snapshots: - '@alloc/quick-lru@5.2.0': {} + '@alloc/quick-lru@5.3.0': {} - '@auth/core@0.41.0': + '@aws-sdk/checksums@3.1001.1': dependencies: - '@panva/hkdf': 1.2.1 - jose: 6.2.2 - oauth4webapi: 3.8.5 - preact: 10.24.3 - preact-render-to-string: 6.5.11(preact@10.24.3) - - '@aws-crypto/crc32@5.2.0': - dependencies: - '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.12 + '@aws-sdk/core': 3.978.1 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.35.1 + '@smithy/types': 4.19.0 tslib: 2.8.1 - '@aws-crypto/crc32c@5.2.0': - dependencies: - '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.12 + '@aws-sdk/client-s3@3.1146.0': + dependencies: + '@aws-sdk/checksums': 3.1001.1 + '@aws-sdk/core': 3.978.1 + '@aws-sdk/credential-provider-node': 3.972.84 + '@aws-sdk/middleware-sdk-s3': 3.972.77 + '@aws-sdk/signature-v4-multi-region': 3.996.47 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.35.1 + '@smithy/fetch-http-handler': 5.8.0 + '@smithy/node-http-handler': 4.12.1 + '@smithy/types': 4.19.0 tslib: 2.8.1 - '@aws-crypto/sha1-browser@5.2.0': + '@aws-sdk/core@3.978.1': dependencies: - '@aws-crypto/supports-web-crypto': 5.2.0 - '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.12 - '@aws-sdk/util-locate-window': 3.965.7 - '@smithy/util-utf8': 2.3.0 - tslib: 2.8.1 - - '@aws-crypto/sha256-browser@5.2.0': - dependencies: - '@aws-crypto/sha256-js': 5.2.0 - '@aws-crypto/supports-web-crypto': 5.2.0 - '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.12 - '@aws-sdk/util-locate-window': 3.965.7 - '@smithy/util-utf8': 2.3.0 - tslib: 2.8.1 - - '@aws-crypto/sha256-js@5.2.0': - dependencies: - '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.12 - tslib: 2.8.1 - - '@aws-crypto/supports-web-crypto@5.2.0': - dependencies: - tslib: 2.8.1 - - '@aws-crypto/util@5.2.0': - dependencies: - '@aws-sdk/types': 3.973.12 - '@smithy/util-utf8': 2.3.0 - tslib: 2.8.1 - - '@aws-sdk/checksums@3.1000.5': - dependencies: - '@aws-crypto/crc32': 5.2.0 - '@aws-crypto/crc32c': 5.2.0 - '@aws-crypto/util': 5.2.0 - '@aws-sdk/core': 3.974.20 - '@aws-sdk/types': 3.973.12 - '@smithy/core': 3.24.7 - '@smithy/types': 4.14.4 - tslib: 2.8.1 - - '@aws-sdk/client-s3@3.1068.0': - dependencies: - '@aws-crypto/sha1-browser': 5.2.0 - '@aws-crypto/sha256-browser': 5.2.0 - '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.974.20 - '@aws-sdk/credential-provider-node': 3.972.55 - '@aws-sdk/middleware-flexible-checksums': 3.974.30 - '@aws-sdk/middleware-sdk-s3': 3.972.51 - '@aws-sdk/signature-v4-multi-region': 3.996.34 - '@aws-sdk/types': 3.973.12 - '@smithy/core': 3.24.7 - '@smithy/fetch-http-handler': 5.4.7 - '@smithy/node-http-handler': 4.7.8 - '@smithy/types': 4.14.4 - tslib: 2.8.1 - - '@aws-sdk/core@3.974.20': - dependencies: - '@aws-sdk/types': 3.973.12 - '@aws-sdk/xml-builder': 3.972.29 - '@aws/lambda-invoke-store': 0.2.4 - '@smithy/core': 3.24.7 - '@smithy/signature-v4': 5.4.7 - '@smithy/types': 4.14.4 + '@aws-sdk/types': 3.974.6 + '@aws-sdk/xml-builder': 3.972.41 + '@aws/lambda-invoke-store': 0.3.0 + '@smithy/core': 3.35.1 + '@smithy/signature-v4': 5.7.4 + '@smithy/types': 4.19.0 bowser: 2.14.1 tslib: 2.8.1 - '@aws-sdk/credential-provider-env@3.972.46': + '@aws-sdk/credential-provider-env@3.972.72': dependencies: - '@aws-sdk/core': 3.974.20 - '@aws-sdk/types': 3.973.12 - '@smithy/core': 3.24.7 - '@smithy/types': 4.14.4 + '@aws-sdk/core': 3.978.1 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.35.1 + '@smithy/types': 4.19.0 tslib: 2.8.1 - '@aws-sdk/credential-provider-http@3.972.48': + '@aws-sdk/credential-provider-http@3.972.74': dependencies: - '@aws-sdk/core': 3.974.20 - '@aws-sdk/types': 3.973.12 - '@smithy/core': 3.24.7 - '@smithy/fetch-http-handler': 5.4.7 - '@smithy/node-http-handler': 4.7.8 - '@smithy/types': 4.14.4 + '@aws-sdk/core': 3.978.1 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.35.1 + '@smithy/fetch-http-handler': 5.8.0 + '@smithy/node-http-handler': 4.12.1 + '@smithy/types': 4.19.0 tslib: 2.8.1 - '@aws-sdk/credential-provider-ini@3.972.53': - dependencies: - '@aws-sdk/core': 3.974.20 - '@aws-sdk/credential-provider-env': 3.972.46 - '@aws-sdk/credential-provider-http': 3.972.48 - '@aws-sdk/credential-provider-login': 3.972.52 - '@aws-sdk/credential-provider-process': 3.972.46 - '@aws-sdk/credential-provider-sso': 3.972.52 - '@aws-sdk/credential-provider-web-identity': 3.972.52 - '@aws-sdk/nested-clients': 3.997.20 - '@aws-sdk/types': 3.973.12 - '@smithy/core': 3.24.7 - '@smithy/credential-provider-imds': 4.3.9 - '@smithy/types': 4.14.4 + '@aws-sdk/credential-provider-ini@3.973.17': + dependencies: + '@aws-sdk/core': 3.978.1 + '@aws-sdk/credential-provider-env': 3.972.72 + '@aws-sdk/credential-provider-http': 3.972.74 + '@aws-sdk/credential-provider-login': 3.972.79 + '@aws-sdk/credential-provider-process': 3.972.72 + '@aws-sdk/credential-provider-sso': 3.973.16 + '@aws-sdk/credential-provider-web-identity': 3.972.78 + '@aws-sdk/nested-clients': 3.997.46 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.35.1 + '@smithy/credential-provider-imds': 4.5.2 + '@smithy/types': 4.19.0 tslib: 2.8.1 - '@aws-sdk/credential-provider-login@3.972.52': + '@aws-sdk/credential-provider-login@3.972.79': dependencies: - '@aws-sdk/core': 3.974.20 - '@aws-sdk/nested-clients': 3.997.20 - '@aws-sdk/types': 3.973.12 - '@smithy/core': 3.24.7 - '@smithy/types': 4.14.4 + '@aws-sdk/core': 3.978.1 + '@aws-sdk/nested-clients': 3.997.46 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.35.1 + '@smithy/types': 4.19.0 tslib: 2.8.1 - '@aws-sdk/credential-provider-node@3.972.55': - dependencies: - '@aws-sdk/credential-provider-env': 3.972.46 - '@aws-sdk/credential-provider-http': 3.972.48 - '@aws-sdk/credential-provider-ini': 3.972.53 - '@aws-sdk/credential-provider-process': 3.972.46 - '@aws-sdk/credential-provider-sso': 3.972.52 - '@aws-sdk/credential-provider-web-identity': 3.972.52 - '@aws-sdk/types': 3.973.12 - '@smithy/core': 3.24.7 - '@smithy/credential-provider-imds': 4.3.9 - '@smithy/types': 4.14.4 + '@aws-sdk/credential-provider-node@3.972.84': + dependencies: + '@aws-sdk/credential-provider-env': 3.972.72 + '@aws-sdk/credential-provider-http': 3.972.74 + '@aws-sdk/credential-provider-ini': 3.973.17 + '@aws-sdk/credential-provider-process': 3.972.72 + '@aws-sdk/credential-provider-sso': 3.973.16 + '@aws-sdk/credential-provider-web-identity': 3.972.78 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.35.1 + '@smithy/credential-provider-imds': 4.5.2 + '@smithy/types': 4.19.0 tslib: 2.8.1 - '@aws-sdk/credential-provider-process@3.972.46': + '@aws-sdk/credential-provider-process@3.972.72': dependencies: - '@aws-sdk/core': 3.974.20 - '@aws-sdk/types': 3.973.12 - '@smithy/core': 3.24.7 - '@smithy/types': 4.14.4 + '@aws-sdk/core': 3.978.1 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.35.1 + '@smithy/types': 4.19.0 tslib: 2.8.1 - '@aws-sdk/credential-provider-sso@3.972.52': + '@aws-sdk/credential-provider-sso@3.973.16': dependencies: - '@aws-sdk/core': 3.974.20 - '@aws-sdk/nested-clients': 3.997.20 - '@aws-sdk/token-providers': 3.1066.0 - '@aws-sdk/types': 3.973.12 - '@smithy/core': 3.24.7 - '@smithy/types': 4.14.4 + '@aws-sdk/core': 3.978.1 + '@aws-sdk/nested-clients': 3.997.46 + '@aws-sdk/token-providers': 3.1138.0 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.35.1 + '@smithy/types': 4.19.0 tslib: 2.8.1 - '@aws-sdk/credential-provider-web-identity@3.972.52': + '@aws-sdk/credential-provider-web-identity@3.972.78': dependencies: - '@aws-sdk/core': 3.974.20 - '@aws-sdk/nested-clients': 3.997.20 - '@aws-sdk/types': 3.973.12 - '@smithy/core': 3.24.7 - '@smithy/types': 4.14.4 + '@aws-sdk/core': 3.978.1 + '@aws-sdk/nested-clients': 3.997.46 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.35.1 + '@smithy/types': 4.19.0 tslib: 2.8.1 - '@aws-sdk/middleware-flexible-checksums@3.974.30': + '@aws-sdk/middleware-sdk-s3@3.972.77': dependencies: - '@aws-sdk/checksums': 3.1000.5 + '@aws-sdk/core': 3.978.1 + '@aws-sdk/signature-v4-multi-region': 3.996.47 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.35.1 + '@smithy/types': 4.19.0 tslib: 2.8.1 - '@aws-sdk/middleware-sdk-s3@3.972.51': + '@aws-sdk/nested-clients@3.997.46': dependencies: - '@aws-sdk/core': 3.974.20 - '@aws-sdk/signature-v4-multi-region': 3.996.34 - '@aws-sdk/types': 3.973.12 - '@smithy/core': 3.24.7 - '@smithy/types': 4.14.4 + '@aws-sdk/core': 3.978.1 + '@aws-sdk/signature-v4-multi-region': 3.996.47 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.35.1 + '@smithy/fetch-http-handler': 5.8.0 + '@smithy/node-http-handler': 4.12.1 + '@smithy/types': 4.19.0 tslib: 2.8.1 - '@aws-sdk/nested-clients@3.997.20': - dependencies: - '@aws-crypto/sha256-browser': 5.2.0 - '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.974.20 - '@aws-sdk/signature-v4-multi-region': 3.996.34 - '@aws-sdk/types': 3.973.12 - '@smithy/core': 3.24.7 - '@smithy/fetch-http-handler': 5.4.7 - '@smithy/node-http-handler': 4.7.8 - '@smithy/types': 4.14.4 - tslib: 2.8.1 - - '@aws-sdk/s3-request-presigner@3.1068.0': + '@aws-sdk/s3-request-presigner@3.1146.0': dependencies: - '@aws-sdk/core': 3.974.20 - '@aws-sdk/signature-v4-multi-region': 3.996.34 - '@aws-sdk/types': 3.973.12 - '@smithy/core': 3.24.7 - '@smithy/types': 4.14.4 + '@aws-sdk/core': 3.978.1 + '@aws-sdk/signature-v4-multi-region': 3.996.47 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.35.1 + '@smithy/types': 4.19.0 tslib: 2.8.1 - '@aws-sdk/signature-v4-multi-region@3.996.34': + '@aws-sdk/signature-v4-multi-region@3.996.47': dependencies: - '@aws-sdk/types': 3.973.12 - '@smithy/signature-v4': 5.4.7 - '@smithy/types': 4.14.4 + '@aws-sdk/types': 3.974.6 + '@smithy/signature-v4': 5.7.4 + '@smithy/types': 4.19.0 tslib: 2.8.1 - '@aws-sdk/token-providers@3.1066.0': + '@aws-sdk/token-providers@3.1138.0': dependencies: - '@aws-sdk/core': 3.974.20 - '@aws-sdk/nested-clients': 3.997.20 - '@aws-sdk/types': 3.973.12 - '@smithy/core': 3.24.7 - '@smithy/types': 4.14.4 + '@aws-sdk/core': 3.978.1 + '@aws-sdk/nested-clients': 3.997.46 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.35.1 + '@smithy/types': 4.19.0 tslib: 2.8.1 - '@aws-sdk/types@3.973.12': + '@aws-sdk/types@3.974.6': dependencies: - '@smithy/types': 4.14.4 + '@smithy/types': 4.19.0 tslib: 2.8.1 - '@aws-sdk/util-locate-window@3.965.7': + '@aws-sdk/xml-builder@3.972.41': dependencies: + '@smithy/types': 4.19.0 tslib: 2.8.1 - '@aws-sdk/xml-builder@3.972.29': - dependencies: - '@smithy/types': 4.14.4 - fast-xml-parser: 5.7.3 - tslib: 2.8.1 + '@aws/lambda-invoke-store@0.3.0': {} - '@aws/lambda-invoke-store@0.2.4': {} - - '@babel/code-frame@7.29.0': + '@babel/code-frame@7.29.7': dependencies: - '@babel/helper-validator-identifier': 7.28.5 + '@babel/helper-validator-identifier': 7.29.7 js-tokens: 4.0.0 picocolors: 1.1.1 - '@babel/compat-data@7.29.0': {} + '@babel/compat-data@7.29.7': {} - '@babel/core@7.29.0(supports-color@8.1.1)': + '@babel/core@7.29.7': dependencies: - '@babel/code-frame': 7.29.0 - '@babel/generator': 7.29.1 - '@babel/helper-compilation-targets': 7.28.6 - '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0)(supports-color@8.1.1) - '@babel/helpers': 7.28.6 - '@babel/parser': 7.29.0 - '@babel/template': 7.28.6 - '@babel/traverse': 7.29.0(supports-color@8.1.1) - '@babel/types': 7.29.0 + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.9 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3 gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 transitivePeerDependencies: - supports-color - '@babel/generator@7.29.1': + '@babel/generator@7.29.8': dependencies: - '@babel/parser': 7.29.0 - '@babel/types': 7.29.0 + '@babel/parser': 7.29.9 + '@babel/types': 7.29.8 '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 - '@babel/helper-compilation-targets@7.28.6': + '@babel/helper-compilation-targets@7.29.7': dependencies: - '@babel/compat-data': 7.29.0 - '@babel/helper-validator-option': 7.27.1 - browserslist: 4.28.1 + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.29.3 lru-cache: 5.1.1 semver: 6.3.1 - '@babel/helper-globals@7.28.0': {} + '@babel/helper-globals@7.29.7': {} - '@babel/helper-module-imports@7.28.6(supports-color@8.1.1)': + '@babel/helper-module-imports@7.29.7': dependencies: - '@babel/traverse': 7.29.0(supports-color@8.1.1) - '@babel/types': 7.29.0 + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 transitivePeerDependencies: - supports-color - '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)(supports-color@8.1.1)': + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0(supports-color@8.1.1) - '@babel/helper-module-imports': 7.28.6(supports-color@8.1.1) - '@babel/helper-validator-identifier': 7.28.5 - '@babel/traverse': 7.29.0(supports-color@8.1.1) + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.8 transitivePeerDependencies: - supports-color - '@babel/helper-string-parser@7.27.1': {} + '@babel/helper-string-parser@7.29.7': {} - '@babel/helper-validator-identifier@7.28.5': {} + '@babel/helper-validator-identifier@7.29.7': {} - '@babel/helper-validator-option@7.27.1': {} + '@babel/helper-validator-option@7.29.7': {} - '@babel/helpers@7.28.6': + '@babel/helpers@7.29.7': dependencies: - '@babel/template': 7.28.6 - '@babel/types': 7.29.0 + '@babel/template': 7.29.7 + '@babel/types': 7.29.8 - '@babel/parser@7.29.0': + '@babel/parser@7.29.9': dependencies: - '@babel/types': 7.29.0 + '@babel/types': 7.29.8 - '@babel/template@7.28.6': + '@babel/template@7.29.7': dependencies: - '@babel/code-frame': 7.29.0 - '@babel/parser': 7.29.0 - '@babel/types': 7.29.0 + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.9 + '@babel/types': 7.29.8 - '@babel/traverse@7.29.0(supports-color@8.1.1)': + '@babel/traverse@7.29.8': dependencies: - '@babel/code-frame': 7.29.0 - '@babel/generator': 7.29.1 - '@babel/helper-globals': 7.28.0 - '@babel/parser': 7.29.0 - '@babel/template': 7.28.6 - '@babel/types': 7.29.0 - debug: 4.4.3(supports-color@8.1.1) + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.9 + '@babel/template': 7.29.7 + '@babel/types': 7.29.8 + debug: 4.4.3 transitivePeerDependencies: - supports-color - '@babel/types@7.29.0': + '@babel/types@7.29.8': dependencies: - '@babel/helper-string-parser': 7.27.1 - '@babel/helper-validator-identifier': 7.28.5 + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 - '@better-auth/core@1.6.18(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.3.0)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.3.0)': + '@better-auth/core@1.6.18(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.3.0)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4)': dependencies: '@better-auth/utils': 0.4.1 '@better-fetch/fetch': 1.3.0 - '@opentelemetry/semantic-conventions': 1.41.1 + '@opentelemetry/semantic-conventions': 1.43.0 '@standard-schema/spec': 1.1.0 - better-call: 1.3.6(zod@4.4.3) - jose: 6.2.2 - kysely: 0.29.2 - nanostores: 1.3.0 - zod: 4.4.3 + better-call: 1.3.6(zod@4.6.5) + jose: 6.2.12 + kysely: 0.29.6 + nanostores: 1.5.4 + zod: 4.6.5 optionalDependencies: '@opentelemetry/api': 1.9.1 - '@better-auth/drizzle-adapter@1.6.18(@better-auth/core@1.6.18(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.3.0)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1)': + '@better-auth/drizzle-adapter@1.6.18(@better-auth/core@1.6.18(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.3.0)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4))(@better-auth/utils@0.4.1)': dependencies: - '@better-auth/core': 1.6.18(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.3.0)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.3.0) + '@better-auth/core': 1.6.18(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.3.0)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4) '@better-auth/utils': 0.4.1 - '@better-auth/kysely-adapter@1.6.18(@better-auth/core@1.6.18(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.3.0)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1)(kysely@0.29.2)': + '@better-auth/kysely-adapter@1.6.18(@better-auth/core@1.6.18(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.3.0)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4))(@better-auth/utils@0.4.1)(kysely@0.29.6)': dependencies: - '@better-auth/core': 1.6.18(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.3.0)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.3.0) + '@better-auth/core': 1.6.18(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.3.0)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4) '@better-auth/utils': 0.4.1 optionalDependencies: - kysely: 0.29.2 + kysely: 0.29.6 - '@better-auth/memory-adapter@1.6.18(@better-auth/core@1.6.18(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.3.0)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1)': + '@better-auth/memory-adapter@1.6.18(@better-auth/core@1.6.18(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.3.0)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4))(@better-auth/utils@0.4.1)': dependencies: - '@better-auth/core': 1.6.18(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.3.0)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.3.0) + '@better-auth/core': 1.6.18(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.3.0)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4) '@better-auth/utils': 0.4.1 - '@better-auth/mongo-adapter@1.6.18(@better-auth/core@1.6.18(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.3.0)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1)': + '@better-auth/mongo-adapter@1.6.18(@better-auth/core@1.6.18(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.3.0)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4))(@better-auth/utils@0.4.1)': dependencies: - '@better-auth/core': 1.6.18(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.3.0)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.3.0) + '@better-auth/core': 1.6.18(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.3.0)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4) '@better-auth/utils': 0.4.1 - '@better-auth/prisma-adapter@1.6.18(@better-auth/core@1.6.18(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.3.0)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1)(@prisma/client@7.8.0(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3))(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))': + '@better-auth/prisma-adapter@1.6.18(@better-auth/core@1.6.18(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.3.0)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4))(@better-auth/utils@0.4.1)(@prisma/client@7.10.0(prisma@7.10.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@5.9.3))(typescript@5.9.3))(prisma@7.10.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@5.9.3))': dependencies: - '@better-auth/core': 1.6.18(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.3.0)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.3.0) + '@better-auth/core': 1.6.18(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.3.0)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4) '@better-auth/utils': 0.4.1 optionalDependencies: - '@prisma/client': 7.8.0(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3) - prisma: 7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3) + '@prisma/client': 7.10.0(prisma@7.10.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@5.9.3))(typescript@5.9.3) + prisma: 7.10.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@5.9.3) - '@better-auth/telemetry@1.6.18(@better-auth/core@1.6.18(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.3.0)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.3.0)': + '@better-auth/telemetry@1.6.18(@better-auth/core@1.6.18(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.3.0)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4))(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.3.0)': dependencies: - '@better-auth/core': 1.6.18(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.3.0)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.3.0) + '@better-auth/core': 1.6.18(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.3.0)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4) '@better-auth/utils': 0.4.1 '@better-fetch/fetch': 1.3.0 '@better-auth/utils@0.4.1': dependencies: - '@noble/hashes': 2.2.0 + '@noble/hashes': 2.4.0 + + '@better-auth/utils@0.4.2': + dependencies: + '@noble/hashes': 2.4.0 '@better-fetch/fetch@1.3.0': {} - '@electric-sql/pglite-socket@0.1.1(@electric-sql/pglite@0.4.1)': + '@better-fetch/fetch@1.3.2': {} + + '@electric-sql/pglite-socket@0.1.3(@electric-sql/pglite@0.4.3)': dependencies: - '@electric-sql/pglite': 0.4.1 + '@electric-sql/pglite': 0.4.3 - '@electric-sql/pglite-tools@0.3.1(@electric-sql/pglite@0.4.1)': + '@electric-sql/pglite-tools@0.3.3(@electric-sql/pglite@0.4.3)': dependencies: - '@electric-sql/pglite': 0.4.1 + '@electric-sql/pglite': 0.4.3 + + '@electric-sql/pglite@0.4.3': {} - '@electric-sql/pglite@0.4.1': {} + '@emnapi/core@1.10.0': + dependencies: + '@emnapi/wasi-threads': 1.2.1 + tslib: 2.8.1 + optional: true - '@emnapi/core@1.8.1': + '@emnapi/runtime@1.10.0': dependencies: - '@emnapi/wasi-threads': 1.1.0 tslib: 2.8.1 optional: true - '@emnapi/runtime@1.8.1': + '@emnapi/runtime@1.11.3': dependencies: tslib: 2.8.1 optional: true - '@emnapi/wasi-threads@1.1.0': + '@emnapi/wasi-threads@1.2.1': dependencies: tslib: 2.8.1 optional: true @@ -4599,89 +4948,92 @@ snapshots: '@emotion/memoize@0.9.0': {} - '@emotion/unitless@0.10.0': {} - - '@esbuild/aix-ppc64@0.28.1': + '@esbuild/aix-ppc64@0.28.2': optional: true - '@esbuild/android-arm64@0.28.1': + '@esbuild/android-arm64@0.28.2': optional: true - '@esbuild/android-arm@0.28.1': + '@esbuild/android-arm@0.28.2': optional: true - '@esbuild/android-x64@0.28.1': + '@esbuild/android-x64@0.28.2': optional: true - '@esbuild/darwin-arm64@0.28.1': + '@esbuild/darwin-arm64@0.28.2': optional: true - '@esbuild/darwin-x64@0.28.1': + '@esbuild/darwin-x64@0.28.2': optional: true - '@esbuild/freebsd-arm64@0.28.1': + '@esbuild/freebsd-arm64@0.28.2': optional: true - '@esbuild/freebsd-x64@0.28.1': + '@esbuild/freebsd-x64@0.28.2': optional: true - '@esbuild/linux-arm64@0.28.1': + '@esbuild/linux-arm64@0.28.2': optional: true - '@esbuild/linux-arm@0.28.1': + '@esbuild/linux-arm@0.28.2': optional: true - '@esbuild/linux-ia32@0.28.1': + '@esbuild/linux-ia32@0.28.2': optional: true - '@esbuild/linux-loong64@0.28.1': + '@esbuild/linux-loong64@0.28.2': optional: true - '@esbuild/linux-mips64el@0.28.1': + '@esbuild/linux-mips64el@0.28.2': optional: true - '@esbuild/linux-ppc64@0.28.1': + '@esbuild/linux-ppc64@0.28.2': optional: true - '@esbuild/linux-riscv64@0.28.1': + '@esbuild/linux-riscv64@0.28.2': optional: true - '@esbuild/linux-s390x@0.28.1': + '@esbuild/linux-s390x@0.28.2': optional: true - '@esbuild/linux-x64@0.28.1': + '@esbuild/linux-x64@0.28.2': optional: true - '@esbuild/netbsd-arm64@0.28.1': + '@esbuild/netbsd-arm64@0.28.2': optional: true - '@esbuild/netbsd-x64@0.28.1': + '@esbuild/netbsd-x64@0.28.2': optional: true - '@esbuild/openbsd-arm64@0.28.1': + '@esbuild/openbsd-arm64@0.28.2': optional: true - '@esbuild/openbsd-x64@0.28.1': + '@esbuild/openbsd-x64@0.28.2': optional: true - '@esbuild/openharmony-arm64@0.28.1': + '@esbuild/openharmony-arm64@0.28.2': optional: true - '@esbuild/sunos-x64@0.28.1': + '@esbuild/sunos-x64@0.28.2': optional: true - '@esbuild/win32-arm64@0.28.1': + '@esbuild/win32-arm64@0.28.2': optional: true - '@esbuild/win32-ia32@0.28.1': + '@esbuild/win32-ia32@0.28.2': optional: true - '@esbuild/win32-x64@0.28.1': + '@esbuild/win32-x64@0.28.2': optional: true - '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@2.6.1))': + '@eslint-community/eslint-utils@4.10.1(eslint@9.39.5(jiti@2.7.0))': + dependencies: + eslint: 9.39.5(jiti@2.7.0) + eslint-visitor-keys: 3.4.3 + + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.5(jiti@2.7.0))': dependencies: - eslint: 9.39.4(jiti@2.6.1) + eslint: 9.39.5(jiti@2.7.0) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} @@ -4689,7 +5041,7 @@ snapshots: '@eslint/config-array@0.21.2': dependencies: '@eslint/object-schema': 2.1.7 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3 minimatch: 3.1.5 transitivePeerDependencies: - supports-color @@ -4702,21 +5054,21 @@ snapshots: dependencies: '@types/json-schema': 7.0.15 - '@eslint/eslintrc@3.3.5': + '@eslint/eslintrc@3.3.7': dependencies: - ajv: 6.14.0 - debug: 4.4.3(supports-color@8.1.1) + ajv: 6.15.0 + debug: 4.4.3 espree: 10.4.0 globals: 14.0.0 ignore: 5.3.2 import-fresh: 3.3.1 - js-yaml: 4.1.1 + js-yaml: 4.3.2 minimatch: 3.1.5 strip-json-comments: 3.1.1 transitivePeerDependencies: - supports-color - '@eslint/js@9.39.4': {} + '@eslint/js@9.39.5': {} '@eslint/object-schema@2.1.7': {} @@ -4725,135 +5077,151 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 - '@hono/node-server@1.19.11(hono@4.12.25)': + '@hono/node-server@1.19.17(hono@4.13.13)': dependencies: - hono: 4.12.25 + hono: 4.13.13 - '@humanfs/core@0.19.1': {} + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 - '@humanfs/node@0.16.7': + '@humanfs/node@0.16.8': dependencies: - '@humanfs/core': 0.19.1 + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 '@humanwhocodes/retry': 0.4.3 + '@humanfs/types@0.15.0': {} + '@humanwhocodes/module-importer@1.0.1': {} '@humanwhocodes/retry@0.4.3': {} - '@iconify-icon/react@3.0.3(react@19.2.4)': + '@iconify-icon/react@3.0.4(react@19.3.0)': dependencies: - iconify-icon: 3.0.2 - react: 19.2.4 + iconify-icon: 3.0.3 + react: 19.3.0 '@iconify/types@2.0.0': {} '@img/colour@1.1.0': optional: true - '@img/sharp-darwin-arm64@0.34.5': + '@img/sharp-darwin-arm64@0.35.5': optionalDependencies: - '@img/sharp-libvips-darwin-arm64': 1.2.4 + '@img/sharp-libvips-darwin-arm64': 1.3.4 optional: true - '@img/sharp-darwin-x64@0.34.5': + '@img/sharp-darwin-x64@0.35.5': optionalDependencies: - '@img/sharp-libvips-darwin-x64': 1.2.4 + '@img/sharp-libvips-darwin-x64': 1.3.4 optional: true - '@img/sharp-libvips-darwin-arm64@1.2.4': + '@img/sharp-freebsd-wasm32@0.35.5': + dependencies: + '@img/sharp-wasm32': 0.35.5 optional: true - '@img/sharp-libvips-darwin-x64@1.2.4': + '@img/sharp-libvips-darwin-arm64@1.3.4': optional: true - '@img/sharp-libvips-linux-arm64@1.2.4': + '@img/sharp-libvips-darwin-x64@1.3.4': optional: true - '@img/sharp-libvips-linux-arm@1.2.4': + '@img/sharp-libvips-linux-arm64@1.3.4': optional: true - '@img/sharp-libvips-linux-ppc64@1.2.4': + '@img/sharp-libvips-linux-arm@1.3.4': optional: true - '@img/sharp-libvips-linux-riscv64@1.2.4': + '@img/sharp-libvips-linux-ppc64@1.3.4': optional: true - '@img/sharp-libvips-linux-s390x@1.2.4': + '@img/sharp-libvips-linux-riscv64@1.3.4': optional: true - '@img/sharp-libvips-linux-x64@1.2.4': + '@img/sharp-libvips-linux-s390x@1.3.4': optional: true - '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + '@img/sharp-libvips-linux-x64@1.3.4': optional: true - '@img/sharp-libvips-linuxmusl-x64@1.2.4': + '@img/sharp-libvips-linuxmusl-arm64@1.3.4': optional: true - '@img/sharp-linux-arm64@0.34.5': + '@img/sharp-libvips-linuxmusl-x64@1.3.4': + optional: true + + '@img/sharp-linux-arm64@0.35.5': optionalDependencies: - '@img/sharp-libvips-linux-arm64': 1.2.4 + '@img/sharp-libvips-linux-arm64': 1.3.4 optional: true - '@img/sharp-linux-arm@0.34.5': + '@img/sharp-linux-arm@0.35.5': optionalDependencies: - '@img/sharp-libvips-linux-arm': 1.2.4 + '@img/sharp-libvips-linux-arm': 1.3.4 optional: true - '@img/sharp-linux-ppc64@0.34.5': + '@img/sharp-linux-ppc64@0.35.5': optionalDependencies: - '@img/sharp-libvips-linux-ppc64': 1.2.4 + '@img/sharp-libvips-linux-ppc64': 1.3.4 optional: true - '@img/sharp-linux-riscv64@0.34.5': + '@img/sharp-linux-riscv64@0.35.5': optionalDependencies: - '@img/sharp-libvips-linux-riscv64': 1.2.4 + '@img/sharp-libvips-linux-riscv64': 1.3.4 optional: true - '@img/sharp-linux-s390x@0.34.5': + '@img/sharp-linux-s390x@0.35.5': optionalDependencies: - '@img/sharp-libvips-linux-s390x': 1.2.4 + '@img/sharp-libvips-linux-s390x': 1.3.4 optional: true - '@img/sharp-linux-x64@0.34.5': + '@img/sharp-linux-x64@0.35.5': optionalDependencies: - '@img/sharp-libvips-linux-x64': 1.2.4 + '@img/sharp-libvips-linux-x64': 1.3.4 optional: true - '@img/sharp-linuxmusl-arm64@0.34.5': + '@img/sharp-linuxmusl-arm64@0.35.5': optionalDependencies: - '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.4 optional: true - '@img/sharp-linuxmusl-x64@0.34.5': + '@img/sharp-linuxmusl-x64@0.35.5': optionalDependencies: - '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + '@img/sharp-libvips-linuxmusl-x64': 1.3.4 optional: true - '@img/sharp-wasm32@0.34.5': + '@img/sharp-wasm32@0.35.5': dependencies: - '@emnapi/runtime': 1.8.1 + '@emnapi/runtime': 1.11.3 optional: true - '@img/sharp-win32-arm64@0.34.5': + '@img/sharp-webcontainers-wasm32@0.35.5': + dependencies: + '@img/sharp-wasm32': 0.35.5 optional: true - '@img/sharp-win32-ia32@0.34.5': + '@img/sharp-win32-arm64@0.35.5': optional: true - '@img/sharp-win32-x64@0.34.5': + '@img/sharp-win32-ia32@0.35.5': + optional: true + + '@img/sharp-win32-x64@0.35.5': optional: true '@infobip-api/sdk@0.3.2': dependencies: - axios: 1.13.6 - form-data: 4.0.5 + axios: 1.20.0 + form-data: 4.0.6 transitivePeerDependencies: - debug + - supports-color '@jridgewell/gen-mapping@0.3.13': dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/sourcemap-codec': 1.6.0 '@jridgewell/trace-mapping': 0.3.31 '@jridgewell/remapping@2.3.5': @@ -4863,60 +5231,59 @@ snapshots: '@jridgewell/resolve-uri@3.1.2': {} - '@jridgewell/sourcemap-codec@1.5.5': {} + '@jridgewell/sourcemap-codec@1.6.0': {} '@jridgewell/trace-mapping@0.3.31': dependencies: '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.5 - - '@kurkle/color@0.3.4': {} + '@jridgewell/sourcemap-codec': 1.6.0 '@napi-rs/lzma-linux-x64-gnu@1.5.1': optional: true - '@napi-rs/wasm-runtime@0.2.12': + '@napi-rs/wasm-runtime@1.2.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: - '@emnapi/core': 1.8.1 - '@emnapi/runtime': 1.8.1 - '@tybys/wasm-util': 0.10.1 + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@tybys/wasm-util': 0.10.4 optional: true - '@next/env@16.1.6': {} + '@next/env@16.3.8': {} - '@next/eslint-plugin-next@16.1.6': + '@next/eslint-plugin-next@16.3.8(eslint@9.39.5(jiti@2.7.0))': dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.5(jiti@2.7.0)) fast-glob: 3.3.1 + transitivePeerDependencies: + - eslint - '@next/swc-darwin-arm64@16.1.6': + '@next/swc-darwin-arm64@16.3.8': optional: true - '@next/swc-darwin-x64@16.1.6': + '@next/swc-darwin-x64@16.3.8': optional: true - '@next/swc-linux-arm64-gnu@16.1.6': + '@next/swc-linux-arm64-gnu@16.3.8': optional: true - '@next/swc-linux-arm64-musl@16.1.6': + '@next/swc-linux-arm64-musl@16.3.8': optional: true - '@next/swc-linux-x64-gnu@16.1.6': + '@next/swc-linux-x64-gnu@16.3.8': optional: true - '@next/swc-linux-x64-musl@16.1.6': + '@next/swc-linux-x64-musl@16.3.8': optional: true - '@next/swc-win32-arm64-msvc@16.1.6': + '@next/swc-win32-arm64-msvc@16.3.8': optional: true - '@next/swc-win32-x64-msvc@16.1.6': + '@next/swc-win32-x64-msvc@16.3.8': optional: true - '@noble/ciphers@2.2.0': {} + '@noble/ciphers@2.4.0': {} - '@noble/hashes@2.2.0': {} - - '@nodable/entities@2.2.0': {} + '@noble/hashes@2.4.0': {} '@nodelib/fs.scandir@2.1.5': dependencies: @@ -4928,105 +5295,103 @@ snapshots: '@nodelib/fs.walk@1.2.8': dependencies: '@nodelib/fs.scandir': 2.1.5 - fastq: 1.20.1 + fastq: 1.20.3 '@nolyfill/is-core-module@1.0.39': {} '@opentelemetry/api@1.9.1': {} - '@opentelemetry/semantic-conventions@1.41.1': {} + '@opentelemetry/semantic-conventions@1.43.0': {} - '@oxc-parser/binding-android-arm-eabi@0.143.0': + '@oxc-parser/binding-android-arm-eabi@0.152.0': optional: true - '@oxc-parser/binding-android-arm64@0.143.0': + '@oxc-parser/binding-android-arm64@0.152.0': optional: true - '@oxc-parser/binding-darwin-arm64@0.143.0': + '@oxc-parser/binding-darwin-arm64@0.152.0': optional: true - '@oxc-parser/binding-darwin-x64@0.143.0': + '@oxc-parser/binding-darwin-x64@0.152.0': optional: true - '@oxc-parser/binding-freebsd-x64@0.143.0': + '@oxc-parser/binding-freebsd-x64@0.152.0': optional: true - '@oxc-parser/binding-linux-arm-gnueabihf@0.143.0': + '@oxc-parser/binding-linux-arm-gnueabihf@0.152.0': optional: true - '@oxc-parser/binding-linux-arm-musleabihf@0.143.0': + '@oxc-parser/binding-linux-arm-musleabihf@0.152.0': optional: true - '@oxc-parser/binding-linux-arm64-gnu@0.143.0': + '@oxc-parser/binding-linux-arm64-gnu@0.152.0': optional: true - '@oxc-parser/binding-linux-arm64-musl@0.143.0': + '@oxc-parser/binding-linux-arm64-musl@0.152.0': optional: true - '@oxc-parser/binding-linux-ppc64-gnu@0.143.0': + '@oxc-parser/binding-linux-ppc64-gnu@0.152.0': optional: true - '@oxc-parser/binding-linux-riscv64-gnu@0.143.0': + '@oxc-parser/binding-linux-riscv64-gnu@0.152.0': optional: true - '@oxc-parser/binding-linux-riscv64-musl@0.143.0': + '@oxc-parser/binding-linux-riscv64-musl@0.152.0': optional: true - '@oxc-parser/binding-linux-s390x-gnu@0.143.0': + '@oxc-parser/binding-linux-s390x-gnu@0.152.0': optional: true - '@oxc-parser/binding-linux-x64-gnu@0.143.0': + '@oxc-parser/binding-linux-x64-gnu@0.152.0': optional: true - '@oxc-parser/binding-linux-x64-musl@0.143.0': + '@oxc-parser/binding-linux-x64-musl@0.152.0': optional: true - '@oxc-parser/binding-openharmony-arm64@0.143.0': + '@oxc-parser/binding-openharmony-arm64@0.152.0': optional: true - '@oxc-parser/binding-win32-arm64-msvc@0.143.0': + '@oxc-parser/binding-win32-arm64-msvc@0.152.0': optional: true - '@oxc-parser/binding-win32-ia32-msvc@0.143.0': + '@oxc-parser/binding-win32-ia32-msvc@0.152.0': optional: true - '@oxc-parser/binding-win32-x64-msvc@0.143.0': + '@oxc-parser/binding-win32-x64-msvc@0.152.0': optional: true - '@oxc-project/types@0.143.0': {} - - '@panva/hkdf@1.2.1': {} + '@oxc-project/types@0.152.0': {} - '@posthog/browser-common@0.9.0': + '@posthog/browser-common@0.9.1': dependencies: - '@posthog/core': 1.55.2 - '@posthog/types': 1.412.4 + '@posthog/core': 1.55.3 + '@posthog/types': 1.413.0 - '@posthog/core@1.55.2': + '@posthog/core@1.55.3': dependencies: - '@posthog/types': 1.412.4 + '@posthog/types': 1.413.0 - '@posthog/types@1.412.4': {} + '@posthog/types@1.413.0': {} - '@prisma/adapter-pg@7.8.0': + '@prisma/adapter-pg@7.10.0': dependencies: - '@prisma/driver-adapter-utils': 7.8.0 - '@types/pg': 8.20.0 - pg: 8.21.0 + '@prisma/driver-adapter-utils': 7.10.0 + '@types/pg': 8.23.1 + pg: 8.23.1 postgres-array: 3.0.4 transitivePeerDependencies: - pg-native - '@prisma/client-runtime-utils@7.8.0': {} + '@prisma/client-runtime-utils@7.10.0': {} - '@prisma/client@7.8.0(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3)': + '@prisma/client@7.10.0(prisma@7.10.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@5.9.3))(typescript@5.9.3)': dependencies: - '@prisma/client-runtime-utils': 7.8.0 + '@prisma/client-runtime-utils': 7.10.0 optionalDependencies: - prisma: 7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3) + prisma: 7.10.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@5.9.3) typescript: 5.9.3 - '@prisma/config@7.8.0': + '@prisma/config@7.10.0': dependencies: c12: 3.3.4 deepmerge-ts: 7.1.5 @@ -5035,351 +5400,367 @@ snapshots: transitivePeerDependencies: - magicast - '@prisma/debug@7.2.0': {} + '@prisma/debug@7.10.0': {} - '@prisma/debug@7.8.0': {} + '@prisma/debug@7.2.0': {} - '@prisma/dev@0.24.3(typescript@5.9.3)': + '@prisma/dev@0.24.17(typescript@5.9.3)': dependencies: - '@electric-sql/pglite': 0.4.1 - '@electric-sql/pglite-socket': 0.1.1(@electric-sql/pglite@0.4.1) - '@electric-sql/pglite-tools': 0.3.1(@electric-sql/pglite@0.4.1) - '@hono/node-server': 1.19.11(hono@4.12.25) + '@electric-sql/pglite': 0.4.3 + '@electric-sql/pglite-socket': 0.1.3(@electric-sql/pglite@0.4.3) + '@electric-sql/pglite-tools': 0.3.3(@electric-sql/pglite@0.4.3) '@prisma/get-platform': 7.2.0 '@prisma/query-plan-executor': 7.2.0 - '@prisma/streams-local': 0.1.2 + '@prisma/streams-local': 0.1.11 + find-my-way: 9.7.0 foreground-child: 3.3.1 get-port-please: 3.2.0 - hono: 4.12.25 - http-status-codes: 2.3.0 pathe: 2.0.3 proper-lockfile: 4.1.2 remeda: 2.33.4 std-env: 3.10.0 - valibot: 1.2.0(typescript@5.9.3) + valibot: 1.4.2(typescript@5.9.3) zeptomatch: 2.1.0 transitivePeerDependencies: - typescript - '@prisma/driver-adapter-utils@7.8.0': + '@prisma/driver-adapter-utils@7.10.0': dependencies: - '@prisma/debug': 7.8.0 + '@prisma/debug': 7.10.0 - '@prisma/engines-version@7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a': {} + '@prisma/engines-version@7.10.0-4.0edf323efd1d98336f3f0a68684b56f689b900d3': {} - '@prisma/engines@7.8.0': + '@prisma/engines@7.10.0': dependencies: - '@prisma/debug': 7.8.0 - '@prisma/engines-version': 7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a - '@prisma/fetch-engine': 7.8.0 - '@prisma/get-platform': 7.8.0 + '@prisma/debug': 7.10.0 + '@prisma/engines-version': 7.10.0-4.0edf323efd1d98336f3f0a68684b56f689b900d3 + '@prisma/fetch-engine': 7.10.0 + '@prisma/get-platform': 7.10.0 - '@prisma/fetch-engine@7.8.0': + '@prisma/fetch-engine@7.10.0': dependencies: - '@prisma/debug': 7.8.0 - '@prisma/engines-version': 7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a - '@prisma/get-platform': 7.8.0 + '@prisma/debug': 7.10.0 + '@prisma/engines-version': 7.10.0-4.0edf323efd1d98336f3f0a68684b56f689b900d3 + '@prisma/get-platform': 7.10.0 - '@prisma/get-platform@7.2.0': + '@prisma/get-platform@7.10.0': dependencies: - '@prisma/debug': 7.2.0 + '@prisma/debug': 7.10.0 - '@prisma/get-platform@7.8.0': + '@prisma/get-platform@7.2.0': dependencies: - '@prisma/debug': 7.8.0 + '@prisma/debug': 7.2.0 '@prisma/query-plan-executor@7.2.0': {} - '@prisma/streams-local@0.1.2': + '@prisma/streams-local@0.1.11': dependencies: ajv: 8.20.0 - better-result: 2.9.2 + better-result: 2.10.0 env-paths: 3.0.0 proper-lockfile: 4.1.2 - '@prisma/studio-core@0.27.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@radix-ui/react-toggle': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@types/react': 19.2.14 - chart.js: 4.5.1 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@prisma/studio-core@0.33.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + dependencies: + '@radix-ui/react-toggle': 1.1.10(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@types/react': 19.3.0 + '@visx/curve': 4.0.1-alpha.0 + '@visx/event': 4.0.1-alpha.0 + '@visx/grid': 4.0.1-alpha.0(react@19.3.0) + '@visx/group': 4.0.1-alpha.0(react@19.3.0) + '@visx/responsive': 4.0.1-alpha.0(react@19.3.0) + '@visx/scale': 4.0.1-alpha.0 + '@visx/shape': 4.0.1-alpha.0(react@19.3.0) + d3-array: 3.2.4 + d3-shape: 3.2.0 + elkjs: 0.11.1 + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) transitivePeerDependencies: - '@types/react-dom' '@radix-ui/primitive@1.1.3': {} - '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.14)(react@19.2.4)': + '@radix-ui/react-compose-refs@1.1.2(@types/react@19.3.0)(react@19.3.0)': dependencies: - react: 19.2.4 + react: 19.3.0 optionalDependencies: - '@types/react': 19.2.14 + '@types/react': 19.3.0 - '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': dependencies: - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@radix-ui/react-slot': 1.2.3(@types/react@19.3.0)(react@19.3.0) + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) - '@radix-ui/react-slot@1.2.3(@types/react@19.2.14)(react@19.2.4)': + '@radix-ui/react-slot@1.2.3(@types/react@19.3.0)(react@19.3.0)': dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.3.0)(react@19.3.0) + react: 19.3.0 optionalDependencies: - '@types/react': 19.2.14 + '@types/react': 19.3.0 - '@radix-ui/react-toggle@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@radix-ui/react-toggle@1.1.10(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.3.0)(react@19.3.0) + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) - '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.14)(react@19.2.4)': + '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.3.0)(react@19.3.0)': dependencies: - '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.3.0)(react@19.3.0) + react: 19.3.0 optionalDependencies: - '@types/react': 19.2.14 + '@types/react': 19.3.0 - '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.14)(react@19.2.4)': + '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.3.0)(react@19.3.0)': dependencies: - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.3.0)(react@19.3.0) + react: 19.3.0 optionalDependencies: - '@types/react': 19.2.14 + '@types/react': 19.3.0 - '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.14)(react@19.2.4)': + '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.3.0)(react@19.3.0)': dependencies: - react: 19.2.4 + react: 19.3.0 optionalDependencies: - '@types/react': 19.2.14 + '@types/react': 19.3.0 - '@react-email/body@0.2.1(react@19.2.4)': + '@react-email/body@0.3.0(react@19.3.0)': dependencies: - react: 19.2.4 + react: 19.3.0 - '@react-email/button@0.2.1(react@19.2.4)': + '@react-email/button@0.2.1(react@19.3.0)': dependencies: - react: 19.2.4 + react: 19.3.0 - '@react-email/code-block@0.2.1(react@19.2.4)': + '@react-email/code-block@0.2.1(react@19.3.0)': dependencies: prismjs: 1.30.0 - react: 19.2.4 - - '@react-email/code-inline@0.0.6(react@19.2.4)': - dependencies: - react: 19.2.4 - - '@react-email/column@0.0.14(react@19.2.4)': - dependencies: - react: 19.2.4 - - '@react-email/components@1.0.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@react-email/body': 0.2.1(react@19.2.4) - '@react-email/button': 0.2.1(react@19.2.4) - '@react-email/code-block': 0.2.1(react@19.2.4) - '@react-email/code-inline': 0.0.6(react@19.2.4) - '@react-email/column': 0.0.14(react@19.2.4) - '@react-email/container': 0.0.16(react@19.2.4) - '@react-email/font': 0.0.10(react@19.2.4) - '@react-email/head': 0.0.13(react@19.2.4) - '@react-email/heading': 0.0.16(react@19.2.4) - '@react-email/hr': 0.0.12(react@19.2.4) - '@react-email/html': 0.0.12(react@19.2.4) - '@react-email/img': 0.0.12(react@19.2.4) - '@react-email/link': 0.0.13(react@19.2.4) - '@react-email/markdown': 0.0.18(react@19.2.4) - '@react-email/preview': 0.0.14(react@19.2.4) - '@react-email/render': 2.0.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-email/row': 0.0.13(react@19.2.4) - '@react-email/section': 0.0.17(react@19.2.4) - '@react-email/tailwind': 2.0.5(@react-email/body@0.2.1(react@19.2.4))(@react-email/button@0.2.1(react@19.2.4))(@react-email/code-block@0.2.1(react@19.2.4))(@react-email/code-inline@0.0.6(react@19.2.4))(@react-email/container@0.0.16(react@19.2.4))(@react-email/heading@0.0.16(react@19.2.4))(@react-email/hr@0.0.12(react@19.2.4))(@react-email/img@0.0.12(react@19.2.4))(@react-email/link@0.0.13(react@19.2.4))(@react-email/preview@0.0.14(react@19.2.4))(@react-email/text@0.1.6(react@19.2.4))(react@19.2.4) - '@react-email/text': 0.1.6(react@19.2.4) - react: 19.2.4 + react: 19.3.0 + + '@react-email/code-inline@0.0.6(react@19.3.0)': + dependencies: + react: 19.3.0 + + '@react-email/column@0.0.14(react@19.3.0)': + dependencies: + react: 19.3.0 + + '@react-email/components@1.0.12(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + dependencies: + '@react-email/body': 0.3.0(react@19.3.0) + '@react-email/button': 0.2.1(react@19.3.0) + '@react-email/code-block': 0.2.1(react@19.3.0) + '@react-email/code-inline': 0.0.6(react@19.3.0) + '@react-email/column': 0.0.14(react@19.3.0) + '@react-email/container': 0.0.16(react@19.3.0) + '@react-email/font': 0.0.10(react@19.3.0) + '@react-email/head': 0.0.13(react@19.3.0) + '@react-email/heading': 0.0.16(react@19.3.0) + '@react-email/hr': 0.0.12(react@19.3.0) + '@react-email/html': 0.0.12(react@19.3.0) + '@react-email/img': 0.0.12(react@19.3.0) + '@react-email/link': 0.0.13(react@19.3.0) + '@react-email/markdown': 0.0.18(react@19.3.0) + '@react-email/preview': 0.0.14(react@19.3.0) + '@react-email/render': 2.0.6(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@react-email/row': 0.0.13(react@19.3.0) + '@react-email/section': 0.0.17(react@19.3.0) + '@react-email/tailwind': 2.0.7(@react-email/body@0.3.0(react@19.3.0))(@react-email/button@0.2.1(react@19.3.0))(@react-email/code-block@0.2.1(react@19.3.0))(@react-email/code-inline@0.0.6(react@19.3.0))(@react-email/container@0.0.16(react@19.3.0))(@react-email/heading@0.0.16(react@19.3.0))(@react-email/hr@0.0.12(react@19.3.0))(@react-email/img@0.0.12(react@19.3.0))(@react-email/link@0.0.13(react@19.3.0))(@react-email/preview@0.0.14(react@19.3.0))(@react-email/text@0.1.6(react@19.3.0))(react@19.3.0) + '@react-email/text': 0.1.6(react@19.3.0) + react: 19.3.0 transitivePeerDependencies: - react-dom - '@react-email/container@0.0.16(react@19.2.4)': + '@react-email/container@0.0.16(react@19.3.0)': dependencies: - react: 19.2.4 + react: 19.3.0 - '@react-email/font@0.0.10(react@19.2.4)': + '@react-email/font@0.0.10(react@19.3.0)': dependencies: - react: 19.2.4 + react: 19.3.0 - '@react-email/head@0.0.13(react@19.2.4)': + '@react-email/head@0.0.13(react@19.3.0)': dependencies: - react: 19.2.4 + react: 19.3.0 - '@react-email/heading@0.0.16(react@19.2.4)': + '@react-email/heading@0.0.16(react@19.3.0)': dependencies: - react: 19.2.4 + react: 19.3.0 - '@react-email/hr@0.0.12(react@19.2.4)': + '@react-email/hr@0.0.12(react@19.3.0)': dependencies: - react: 19.2.4 + react: 19.3.0 - '@react-email/html@0.0.12(react@19.2.4)': + '@react-email/html@0.0.12(react@19.3.0)': dependencies: - react: 19.2.4 + react: 19.3.0 - '@react-email/img@0.0.12(react@19.2.4)': + '@react-email/img@0.0.12(react@19.3.0)': dependencies: - react: 19.2.4 + react: 19.3.0 - '@react-email/link@0.0.13(react@19.2.4)': + '@react-email/link@0.0.13(react@19.3.0)': dependencies: - react: 19.2.4 + react: 19.3.0 - '@react-email/markdown@0.0.18(react@19.2.4)': + '@react-email/markdown@0.0.18(react@19.3.0)': dependencies: marked: 15.0.12 - react: 19.2.4 + react: 19.3.0 - '@react-email/preview@0.0.14(react@19.2.4)': + '@react-email/preview@0.0.14(react@19.3.0)': dependencies: - react: 19.2.4 + react: 19.3.0 - '@react-email/render@2.0.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@react-email/render@2.0.6(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': dependencies: html-to-text: 9.0.5 - prettier: 3.8.1 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + prettier: 3.9.9 + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) - '@react-email/row@0.0.13(react@19.2.4)': + '@react-email/render@2.1.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': dependencies: - react: 19.2.4 + entities: 4.5.0 + html-to-text: 9.0.5 + html5parser: 3.0.0 + prettier: 3.9.9 + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) - '@react-email/section@0.0.17(react@19.2.4)': + '@react-email/row@0.0.13(react@19.3.0)': dependencies: - react: 19.2.4 + react: 19.3.0 - '@react-email/tailwind@2.0.5(@react-email/body@0.2.1(react@19.2.4))(@react-email/button@0.2.1(react@19.2.4))(@react-email/code-block@0.2.1(react@19.2.4))(@react-email/code-inline@0.0.6(react@19.2.4))(@react-email/container@0.0.16(react@19.2.4))(@react-email/heading@0.0.16(react@19.2.4))(@react-email/hr@0.0.12(react@19.2.4))(@react-email/img@0.0.12(react@19.2.4))(@react-email/link@0.0.13(react@19.2.4))(@react-email/preview@0.0.14(react@19.2.4))(@react-email/text@0.1.6(react@19.2.4))(react@19.2.4)': + '@react-email/section@0.0.17(react@19.3.0)': dependencies: - '@react-email/text': 0.1.6(react@19.2.4) - react: 19.2.4 - tailwindcss: 4.2.1 + react: 19.3.0 + + '@react-email/tailwind@2.0.7(@react-email/body@0.3.0(react@19.3.0))(@react-email/button@0.2.1(react@19.3.0))(@react-email/code-block@0.2.1(react@19.3.0))(@react-email/code-inline@0.0.6(react@19.3.0))(@react-email/container@0.0.16(react@19.3.0))(@react-email/heading@0.0.16(react@19.3.0))(@react-email/hr@0.0.12(react@19.3.0))(@react-email/img@0.0.12(react@19.3.0))(@react-email/link@0.0.13(react@19.3.0))(@react-email/preview@0.0.14(react@19.3.0))(@react-email/text@0.1.6(react@19.3.0))(react@19.3.0)': + dependencies: + '@react-email/text': 0.1.6(react@19.3.0) + react: 19.3.0 + tailwindcss: 4.3.3 optionalDependencies: - '@react-email/body': 0.2.1(react@19.2.4) - '@react-email/button': 0.2.1(react@19.2.4) - '@react-email/code-block': 0.2.1(react@19.2.4) - '@react-email/code-inline': 0.0.6(react@19.2.4) - '@react-email/container': 0.0.16(react@19.2.4) - '@react-email/heading': 0.0.16(react@19.2.4) - '@react-email/hr': 0.0.12(react@19.2.4) - '@react-email/img': 0.0.12(react@19.2.4) - '@react-email/link': 0.0.13(react@19.2.4) - '@react-email/preview': 0.0.14(react@19.2.4) + '@react-email/body': 0.3.0(react@19.3.0) + '@react-email/button': 0.2.1(react@19.3.0) + '@react-email/code-block': 0.2.1(react@19.3.0) + '@react-email/code-inline': 0.0.6(react@19.3.0) + '@react-email/container': 0.0.16(react@19.3.0) + '@react-email/heading': 0.0.16(react@19.3.0) + '@react-email/hr': 0.0.12(react@19.3.0) + '@react-email/img': 0.0.12(react@19.3.0) + '@react-email/link': 0.0.13(react@19.3.0) + '@react-email/preview': 0.0.14(react@19.3.0) - '@react-email/text@0.1.6(react@19.2.4)': + '@react-email/text@0.1.6(react@19.3.0)': dependencies: - react: 19.2.4 + react: 19.3.0 - '@rollup/plugin-commonjs@28.0.1(rollup@4.63.5)': + '@rollup/plugin-commonjs@28.0.1(rollup@4.64.0)': dependencies: - '@rollup/pluginutils': 5.4.0(rollup@4.63.5) + '@rollup/pluginutils': 5.4.0(rollup@4.64.0) commondir: 1.0.1 estree-walker: 2.0.2 - fdir: 6.5.0(picomatch@4.0.3) + fdir: 6.5.0(picomatch@4.0.7) is-reference: 1.2.1 magic-string: 0.30.21 - picomatch: 4.0.3 + picomatch: 4.0.7 optionalDependencies: - rollup: 4.63.5 + rollup: 4.64.0 - '@rollup/pluginutils@5.4.0(rollup@4.63.5)': + '@rollup/pluginutils@5.4.0(rollup@4.64.0)': dependencies: - '@types/estree': 1.0.8 + '@types/estree': 1.0.9 estree-walker: 2.0.2 - picomatch: 4.0.3 + picomatch: 4.0.7 optionalDependencies: - rollup: 4.63.5 + rollup: 4.64.0 - '@rollup/rollup-android-arm-eabi@4.63.5': + '@rollup/rollup-android-arm-eabi@4.64.0': optional: true - '@rollup/rollup-android-arm64@4.63.5': + '@rollup/rollup-android-arm64@4.64.0': optional: true - '@rollup/rollup-darwin-arm64@4.63.5': + '@rollup/rollup-darwin-arm64@4.64.0': optional: true - '@rollup/rollup-darwin-x64@4.63.5': + '@rollup/rollup-darwin-x64@4.64.0': optional: true - '@rollup/rollup-freebsd-arm64@4.63.5': + '@rollup/rollup-freebsd-arm64@4.64.0': optional: true - '@rollup/rollup-freebsd-x64@4.63.5': + '@rollup/rollup-freebsd-x64@4.64.0': optional: true - '@rollup/rollup-linux-arm-gnueabihf@4.63.5': + '@rollup/rollup-linux-arm-gnueabihf@4.64.0': optional: true - '@rollup/rollup-linux-arm-musleabihf@4.63.5': + '@rollup/rollup-linux-arm-musleabihf@4.64.0': optional: true - '@rollup/rollup-linux-arm64-gnu@4.63.5': + '@rollup/rollup-linux-arm64-gnu@4.64.0': optional: true - '@rollup/rollup-linux-arm64-musl@4.63.5': + '@rollup/rollup-linux-arm64-musl@4.64.0': optional: true - '@rollup/rollup-linux-loong64-gnu@4.63.5': + '@rollup/rollup-linux-loong64-gnu@4.64.0': optional: true - '@rollup/rollup-linux-loong64-musl@4.63.5': + '@rollup/rollup-linux-loong64-musl@4.64.0': optional: true - '@rollup/rollup-linux-ppc64-gnu@4.63.5': + '@rollup/rollup-linux-ppc64-gnu@4.64.0': optional: true - '@rollup/rollup-linux-ppc64-musl@4.63.5': + '@rollup/rollup-linux-ppc64-musl@4.64.0': optional: true - '@rollup/rollup-linux-riscv64-gnu@4.63.5': + '@rollup/rollup-linux-riscv64-gnu@4.64.0': optional: true - '@rollup/rollup-linux-riscv64-musl@4.63.5': + '@rollup/rollup-linux-riscv64-musl@4.64.0': optional: true - '@rollup/rollup-linux-s390x-gnu@4.63.5': + '@rollup/rollup-linux-s390x-gnu@4.64.0': optional: true - '@rollup/rollup-linux-x64-gnu@4.63.5': + '@rollup/rollup-linux-x64-gnu@4.64.0': optional: true - '@rollup/rollup-linux-x64-musl@4.63.5': + '@rollup/rollup-linux-x64-musl@4.64.0': optional: true - '@rollup/rollup-openbsd-x64@4.63.5': + '@rollup/rollup-openbsd-x64@4.64.0': optional: true - '@rollup/rollup-openharmony-arm64@4.63.5': + '@rollup/rollup-openharmony-arm64@4.64.0': optional: true - '@rollup/rollup-win32-arm64-msvc@4.63.5': + '@rollup/rollup-win32-arm64-msvc@4.64.0': optional: true - '@rollup/rollup-win32-ia32-msvc@4.63.5': + '@rollup/rollup-win32-ia32-msvc@4.64.0': optional: true - '@rollup/rollup-win32-x64-gnu@4.63.5': + '@rollup/rollup-win32-x64-gnu@4.64.0': optional: true - '@rollup/rollup-win32-x64-msvc@4.63.5': + '@rollup/rollup-win32-x64-msvc@4.64.0': optional: true '@rtsao/scc@1.1.0': {} @@ -5389,303 +5770,286 @@ snapshots: domhandler: 5.0.3 selderee: 0.11.0 - '@sentry/browser-utils@11.0.0': + '@sentry/browser-utils@11.4.0': dependencies: - '@sentry/conventions': 0.23.0 - '@sentry/core': 11.0.0 + '@sentry/conventions': 0.25.0 + '@sentry/core': 11.4.0 web-vitals: 6.2.2 - '@sentry/browser@11.0.0': + '@sentry/browser@11.4.0': dependencies: - '@sentry/browser-utils': 11.0.0 - '@sentry/conventions': 0.23.0 - '@sentry/core': 11.0.0 - '@sentry/feedback': 11.0.0 - '@sentry/replay': 11.0.0 - '@sentry/replay-canvas': 11.0.0 + '@sentry/browser-utils': 11.4.0 + '@sentry/conventions': 0.25.0 + '@sentry/core': 11.4.0 + '@sentry/feedback': 11.4.0 + '@sentry/replay': 11.4.0 + '@sentry/replay-canvas': 11.4.0 - '@sentry/bundler-plugins@11.0.0(rollup@4.63.5)': + '@sentry/bundler-plugins@11.4.0(@babel/core@7.29.7)(rollup@4.64.0)': dependencies: - '@babel/core': 7.29.0(supports-color@8.1.1) - '@sentry/core': 11.0.0 + '@sentry/core': 11.4.0 dotenv: 17.4.2 glob: 13.0.6 - magic-string: 0.30.21 - oxc-parser: 0.143.0 - sentry: 0.44.1 + magic-string: 1.4.2 + oxc-parser: 0.152.0 + sentry: 0.45.0 supports-color: 8.1.1 optionalDependencies: - rollup: 4.63.5 + '@babel/core': 7.29.7 + rollup: 4.64.0 - '@sentry/conventions@0.23.0': {} + '@sentry/conventions@0.25.0': {} - '@sentry/core@11.0.0': + '@sentry/core@11.4.0': dependencies: - '@sentry/conventions': 0.23.0 + '@sentry/conventions': 0.25.0 - '@sentry/feedback@11.0.0': + '@sentry/feedback@11.4.0': dependencies: - '@sentry/core': 11.0.0 + '@sentry/core': 11.4.0 - '@sentry/nextjs@11.0.0(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)': + '@sentry/nextjs@11.4.0(@babel/core@7.29.7)(next@16.3.8(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@types/node@25.9.9)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react@19.3.0)': dependencies: '@opentelemetry/api': 1.9.1 - '@rollup/plugin-commonjs': 28.0.1(rollup@4.63.5) - '@sentry/browser-utils': 11.0.0 - '@sentry/bundler-plugins': 11.0.0(rollup@4.63.5) - '@sentry/conventions': 0.23.0 - '@sentry/core': 11.0.0 - '@sentry/node': 11.0.0(rollup@4.63.5) - '@sentry/opentelemetry': 11.0.0(@opentelemetry/api@1.9.1) - '@sentry/react': 11.0.0(react@19.2.4) - '@sentry/server-runtime-injection': 11.0.0 - '@sentry/server-utils': 11.0.0 - '@sentry/vercel-edge': 11.0.0 - next: 16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - rollup: 4.63.5 + '@rollup/plugin-commonjs': 28.0.1(rollup@4.64.0) + '@sentry/browser-utils': 11.4.0 + '@sentry/bundler-plugins': 11.4.0(@babel/core@7.29.7)(rollup@4.64.0) + '@sentry/conventions': 0.25.0 + '@sentry/core': 11.4.0 + '@sentry/node': 11.4.0(@babel/core@7.29.7)(rollup@4.64.0) + '@sentry/opentelemetry': 11.4.0(@opentelemetry/api@1.9.1) + '@sentry/react': 11.4.0(react@19.3.0) + '@sentry/server-runtime-injection': 11.4.0 + '@sentry/server-utils': 11.4.0 + '@sentry/vercel-edge': 11.4.0 + next: 16.3.8(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@types/node@25.9.9)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + rollup: 4.64.0 stacktrace-parser: 0.1.11 transitivePeerDependencies: + - '@babel/core' - react - react-router - webpack - '@sentry/node@11.0.0(rollup@4.63.5)': + '@sentry/node@11.4.0(@babel/core@7.29.7)(rollup@4.64.0)': dependencies: '@opentelemetry/api': 1.9.1 - '@sentry/bundler-plugins': 11.0.0(rollup@4.63.5) - '@sentry/conventions': 0.23.0 - '@sentry/core': 11.0.0 - '@sentry/opentelemetry': 11.0.0(@opentelemetry/api@1.9.1) - '@sentry/server-runtime-injection': 11.0.0 - '@sentry/server-utils': 11.0.0 + '@sentry/bundler-plugins': 11.4.0(@babel/core@7.29.7)(rollup@4.64.0) + '@sentry/conventions': 0.25.0 + '@sentry/core': 11.4.0 + '@sentry/opentelemetry': 11.4.0(@opentelemetry/api@1.9.1) + '@sentry/server-runtime-injection': 11.4.0 + '@sentry/server-utils': 11.4.0 transitivePeerDependencies: + - '@babel/core' - rollup - webpack - '@sentry/opentelemetry@11.0.0(@opentelemetry/api@1.9.1)': + '@sentry/opentelemetry@11.4.0(@opentelemetry/api@1.9.1)': dependencies: '@opentelemetry/api': 1.9.1 - '@sentry/conventions': 0.23.0 - '@sentry/core': 11.0.0 + '@sentry/conventions': 0.25.0 + '@sentry/core': 11.4.0 - '@sentry/react@11.0.0(react@19.2.4)': + '@sentry/react@11.4.0(react@19.3.0)': dependencies: - '@sentry/browser': 11.0.0 - '@sentry/conventions': 0.23.0 - '@sentry/core': 11.0.0 - react: 19.2.4 + '@sentry/browser': 11.4.0 + '@sentry/conventions': 0.25.0 + '@sentry/core': 11.4.0 + react: 19.3.0 - '@sentry/replay-canvas@11.0.0': + '@sentry/replay-canvas@11.4.0': dependencies: - '@sentry/core': 11.0.0 - '@sentry/replay': 11.0.0 + '@sentry/core': 11.4.0 + '@sentry/replay': 11.4.0 - '@sentry/replay@11.0.0': + '@sentry/replay@11.4.0': dependencies: - '@sentry/browser-utils': 11.0.0 - '@sentry/core': 11.0.0 + '@sentry/browser-utils': 11.4.0 + '@sentry/core': 11.4.0 - '@sentry/server-runtime-injection@11.0.0': + '@sentry/server-runtime-injection@11.4.0': dependencies: - '@sentry/core': 11.0.0 - '@sentry/server-utils': 11.0.0 + '@sentry/core': 11.4.0 + '@sentry/server-utils': 11.4.0 - '@sentry/server-utils@11.0.0': + '@sentry/server-utils@11.4.0': dependencies: '@opentelemetry/api': 1.9.1 - '@sentry/conventions': 0.23.0 - '@sentry/core': 11.0.0 + '@sentry/conventions': 0.25.0 + '@sentry/core': 11.4.0 - '@sentry/vercel-edge@11.0.0': + '@sentry/vercel-edge@11.4.0': dependencies: '@opentelemetry/api': 1.9.1 - '@sentry/core': 11.0.0 - '@sentry/server-utils': 11.0.0 - - '@smithy/core@3.24.7': - dependencies: - '@aws-crypto/crc32': 5.2.0 - '@smithy/types': 4.14.4 - tslib: 2.8.1 - - '@smithy/credential-provider-imds@4.3.9': - dependencies: - '@smithy/core': 3.24.7 - '@smithy/types': 4.14.4 - tslib: 2.8.1 - - '@smithy/fetch-http-handler@5.4.7': - dependencies: - '@smithy/core': 3.24.7 - '@smithy/types': 4.14.4 - tslib: 2.8.1 + '@sentry/core': 11.4.0 + '@sentry/server-utils': 11.4.0 - '@smithy/is-array-buffer@2.2.0': + '@smithy/core@3.35.1': dependencies: + '@smithy/types': 4.19.0 tslib: 2.8.1 - '@smithy/node-http-handler@4.7.8': + '@smithy/credential-provider-imds@4.5.2': dependencies: - '@smithy/core': 3.24.7 - '@smithy/types': 4.14.4 + '@smithy/core': 3.35.1 + '@smithy/types': 4.19.0 tslib: 2.8.1 - '@smithy/signature-v4@5.4.7': + '@smithy/fetch-http-handler@5.8.0': dependencies: - '@smithy/core': 3.24.7 - '@smithy/types': 4.14.4 + '@smithy/core': 3.35.1 + '@smithy/types': 4.19.0 tslib: 2.8.1 - '@smithy/types@4.14.4': + '@smithy/node-http-handler@4.12.1': dependencies: + '@smithy/core': 3.35.1 + '@smithy/types': 4.19.0 tslib: 2.8.1 - '@smithy/util-buffer-from@2.2.0': + '@smithy/signature-v4@5.7.4': dependencies: - '@smithy/is-array-buffer': 2.2.0 + '@smithy/core': 3.35.1 + '@smithy/types': 4.19.0 tslib: 2.8.1 - '@smithy/util-utf8@2.3.0': + '@smithy/types@4.19.0': dependencies: - '@smithy/util-buffer-from': 2.2.0 tslib: 2.8.1 '@stablelib/base64@1.0.1': {} '@standard-schema/spec@1.1.0': {} - '@supabase/auth-js@2.98.0': + '@supabase/auth-js@2.117.2': dependencies: tslib: 2.8.1 - '@supabase/functions-js@2.98.0': + '@supabase/functions-js@2.117.2': dependencies: tslib: 2.8.1 - '@supabase/postgrest-js@2.98.0': + '@supabase/phoenix@0.4.5': {} + + '@supabase/postgrest-js@2.117.2': dependencies: tslib: 2.8.1 - '@supabase/realtime-js@2.98.0': + '@supabase/realtime-js@2.117.2': dependencies: - '@types/phoenix': 1.6.7 - '@types/ws': 8.18.1 + '@supabase/phoenix': 0.4.5 tslib: 2.8.1 - ws: 8.19.0 - transitivePeerDependencies: - - bufferutil - - utf-8-validate - '@supabase/ssr@0.9.0(@supabase/supabase-js@2.98.0)': + '@supabase/ssr@0.9.0(@supabase/supabase-js@2.117.2(@opentelemetry/api@1.9.1))': dependencies: - '@supabase/supabase-js': 2.98.0 + '@supabase/supabase-js': 2.117.2(@opentelemetry/api@1.9.1) cookie: 1.1.1 - '@supabase/storage-js@2.98.0': + '@supabase/storage-js@2.117.2': dependencies: iceberg-js: 0.8.1 tslib: 2.8.1 - '@supabase/supabase-js@2.98.0': + '@supabase/supabase-js@2.117.2(@opentelemetry/api@1.9.1)': dependencies: - '@supabase/auth-js': 2.98.0 - '@supabase/functions-js': 2.98.0 - '@supabase/postgrest-js': 2.98.0 - '@supabase/realtime-js': 2.98.0 - '@supabase/storage-js': 2.98.0 - transitivePeerDependencies: - - bufferutil - - utf-8-validate + '@supabase/auth-js': 2.117.2 + '@supabase/functions-js': 2.117.2 + '@supabase/postgrest-js': 2.117.2 + '@supabase/realtime-js': 2.117.2 + '@supabase/storage-js': 2.117.2 + optionalDependencies: + '@opentelemetry/api': 1.9.1 - '@swc/helpers@0.5.15': + '@swc/helpers@0.5.23': dependencies: tslib: 2.8.1 - '@tailwindcss/node@4.2.1': + '@tailwindcss/node@4.3.3': dependencies: '@jridgewell/remapping': 2.3.5 - enhanced-resolve: 5.20.0 - jiti: 2.6.1 - lightningcss: 1.31.1 + enhanced-resolve: 5.26.0 + jiti: 2.7.0 + lightningcss: 1.32.0 magic-string: 0.30.21 - source-map-js: 1.2.1 - tailwindcss: 4.2.1 + source-map-js: 1.2.2 + tailwindcss: 4.3.3 - '@tailwindcss/oxide-android-arm64@4.2.1': + '@tailwindcss/oxide-android-arm64@4.3.3': optional: true - '@tailwindcss/oxide-darwin-arm64@4.2.1': + '@tailwindcss/oxide-darwin-arm64@4.3.3': optional: true - '@tailwindcss/oxide-darwin-x64@4.2.1': + '@tailwindcss/oxide-darwin-x64@4.3.3': optional: true - '@tailwindcss/oxide-freebsd-x64@4.2.1': + '@tailwindcss/oxide-freebsd-x64@4.3.3': optional: true - '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1': + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3': optional: true - '@tailwindcss/oxide-linux-arm64-gnu@4.2.1': + '@tailwindcss/oxide-linux-arm64-gnu@4.3.3': optional: true - '@tailwindcss/oxide-linux-arm64-musl@4.2.1': + '@tailwindcss/oxide-linux-arm64-musl@4.3.3': optional: true - '@tailwindcss/oxide-linux-x64-gnu@4.2.1': + '@tailwindcss/oxide-linux-x64-gnu@4.3.3': optional: true - '@tailwindcss/oxide-linux-x64-musl@4.2.1': + '@tailwindcss/oxide-linux-x64-musl@4.3.3': optional: true - '@tailwindcss/oxide-wasm32-wasi@4.2.1': + '@tailwindcss/oxide-wasm32-wasi@4.3.3': optional: true - '@tailwindcss/oxide-win32-arm64-msvc@4.2.1': + '@tailwindcss/oxide-win32-arm64-msvc@4.3.3': optional: true - '@tailwindcss/oxide-win32-x64-msvc@4.2.1': + '@tailwindcss/oxide-win32-x64-msvc@4.3.3': optional: true - '@tailwindcss/oxide@4.2.1': + '@tailwindcss/oxide@4.3.3': optionalDependencies: - '@tailwindcss/oxide-android-arm64': 4.2.1 - '@tailwindcss/oxide-darwin-arm64': 4.2.1 - '@tailwindcss/oxide-darwin-x64': 4.2.1 - '@tailwindcss/oxide-freebsd-x64': 4.2.1 - '@tailwindcss/oxide-linux-arm-gnueabihf': 4.2.1 - '@tailwindcss/oxide-linux-arm64-gnu': 4.2.1 - '@tailwindcss/oxide-linux-arm64-musl': 4.2.1 - '@tailwindcss/oxide-linux-x64-gnu': 4.2.1 - '@tailwindcss/oxide-linux-x64-musl': 4.2.1 - '@tailwindcss/oxide-wasm32-wasi': 4.2.1 - '@tailwindcss/oxide-win32-arm64-msvc': 4.2.1 - '@tailwindcss/oxide-win32-x64-msvc': 4.2.1 + '@tailwindcss/oxide-android-arm64': 4.3.3 + '@tailwindcss/oxide-darwin-arm64': 4.3.3 + '@tailwindcss/oxide-darwin-x64': 4.3.3 + '@tailwindcss/oxide-freebsd-x64': 4.3.3 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.3 + '@tailwindcss/oxide-linux-arm64-gnu': 4.3.3 + '@tailwindcss/oxide-linux-arm64-musl': 4.3.3 + '@tailwindcss/oxide-linux-x64-gnu': 4.3.3 + '@tailwindcss/oxide-linux-x64-musl': 4.3.3 + '@tailwindcss/oxide-wasm32-wasi': 4.3.3 + '@tailwindcss/oxide-win32-arm64-msvc': 4.3.3 + '@tailwindcss/oxide-win32-x64-msvc': 4.3.3 - '@tailwindcss/postcss@4.2.1': + '@tailwindcss/postcss@4.3.3': dependencies: - '@alloc/quick-lru': 5.2.0 - '@tailwindcss/node': 4.2.1 - '@tailwindcss/oxide': 4.2.1 - postcss: 8.5.8 - tailwindcss: 4.2.1 + '@alloc/quick-lru': 5.3.0 + '@tailwindcss/node': 4.3.3 + '@tailwindcss/oxide': 4.3.3 + postcss: 8.5.28 + tailwindcss: 4.3.3 - '@tanstack/query-core@5.101.0': {} + '@tanstack/query-core@5.104.1': {} - '@tanstack/react-query@5.101.0(react@19.2.4)': + '@tanstack/react-query@5.104.1(react@19.3.0)': dependencies: - '@tanstack/query-core': 5.101.0 - react: 19.2.4 + '@tanstack/query-core': 5.104.1 + react: 19.3.0 - '@tanstack/react-table@8.21.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@tanstack/react-table@8.21.3(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': dependencies: '@tanstack/table-core': 8.21.3 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) '@tanstack/table-core@8.21.3': {} - '@tybys/wasm-util@0.10.1': + '@tybys/wasm-util@0.10.4': dependencies: tslib: 2.8.1 optional: true @@ -5694,225 +6058,394 @@ snapshots: dependencies: bcryptjs: 3.0.3 - '@types/estree@1.0.8': {} + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/d3-array@3.0.3': {} + + '@types/d3-color@3.1.0': {} + + '@types/d3-delaunay@6.0.1': {} + + '@types/d3-format@3.0.1': {} + + '@types/d3-geo@3.1.0': + dependencies: + '@types/geojson': 7946.0.16 + + '@types/d3-interpolate@3.0.1': + dependencies: + '@types/d3-color': 3.1.0 + + '@types/d3-path@3.1.1': {} + + '@types/d3-scale@4.0.2': + dependencies: + '@types/d3-time': 3.0.0 + + '@types/d3-shape@3.1.7': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time-format@2.1.0': {} + + '@types/d3-time@3.0.0': {} + + '@types/deep-eql@4.0.2': {} '@types/estree@1.0.9': {} + '@types/geojson@7946.0.16': {} + '@types/json-schema@7.0.15': {} '@types/json5@0.0.29': {} - '@types/node@25.3.5': + '@types/lodash@4.17.25': {} + + '@types/node@24.19.1': dependencies: - undici-types: 7.18.2 + undici-types: 7.24.6 - '@types/pg@8.20.0': + '@types/node@25.9.9': dependencies: - '@types/node': 25.3.5 - pg-protocol: 1.14.0 - pg-types: 2.2.0 + undici-types: 7.24.6 - '@types/phoenix@1.6.7': {} + '@types/pg@8.23.1': + dependencies: + '@types/node': 25.9.9 + pg-protocol: 1.16.1 + pg-types: 2.2.0 '@types/qrcode@1.5.6': dependencies: - '@types/node': 25.3.5 + '@types/node': 25.9.9 - '@types/react-dom@19.2.3(@types/react@19.2.14)': + '@types/react-dom@19.3.0(@types/react@19.3.0)': dependencies: - '@types/react': 19.2.14 + '@types/react': 19.3.0 - '@types/react@19.2.14': + '@types/react@19.3.0': dependencies: csstype: 3.2.3 - '@types/stylis@4.2.7': {} - '@types/trusted-types@2.0.7': optional: true - '@types/ws@8.18.1': - dependencies: - '@types/node': 25.3.5 - - '@typescript-eslint/eslint-plugin@8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.71.0(@typescript-eslint/parser@8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.56.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.56.1 - '@typescript-eslint/type-utils': 8.56.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/utils': 8.56.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.56.1 - eslint: 9.39.4(jiti@2.6.1) - ignore: 7.0.5 + '@typescript-eslint/parser': 8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.71.0 + '@typescript-eslint/type-utils': 8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/utils': 8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.71.0 + eslint: 9.39.5(jiti@2.7.0) + ignore: 7.0.12 natural-compare: 1.4.0 - ts-api-utils: 2.4.0(typescript@5.9.3) + ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.56.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/parser@8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)': dependencies: - '@typescript-eslint/scope-manager': 8.56.1 - '@typescript-eslint/types': 8.56.1 - '@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.56.1 - debug: 4.4.3(supports-color@8.1.1) - eslint: 9.39.4(jiti@2.6.1) + '@typescript-eslint/scope-manager': 8.71.0 + '@typescript-eslint/types': 8.71.0 + '@typescript-eslint/typescript-estree': 8.71.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.71.0 + debug: 4.4.3 + eslint: 9.39.5(jiti@2.7.0) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.56.1(typescript@5.9.3)': + '@typescript-eslint/project-service@8.71.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.56.1(typescript@5.9.3) - '@typescript-eslint/types': 8.56.1 - debug: 4.4.3(supports-color@8.1.1) + '@typescript-eslint/tsconfig-utils': 8.71.0(typescript@5.9.3) + '@typescript-eslint/types': 8.71.0 + debug: 4.4.3 typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@8.56.1': + '@typescript-eslint/scope-manager@8.71.0': dependencies: - '@typescript-eslint/types': 8.56.1 - '@typescript-eslint/visitor-keys': 8.56.1 + '@typescript-eslint/types': 8.71.0 + '@typescript-eslint/visitor-keys': 8.71.0 - '@typescript-eslint/tsconfig-utils@8.56.1(typescript@5.9.3)': + '@typescript-eslint/tsconfig-utils@8.71.0(typescript@5.9.3)': dependencies: typescript: 5.9.3 - '@typescript-eslint/type-utils@8.56.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)': dependencies: - '@typescript-eslint/types': 8.56.1 - '@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3) - '@typescript-eslint/utils': 8.56.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - debug: 4.4.3(supports-color@8.1.1) - eslint: 9.39.4(jiti@2.6.1) - ts-api-utils: 2.4.0(typescript@5.9.3) + '@typescript-eslint/types': 8.71.0 + '@typescript-eslint/typescript-estree': 8.71.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) + debug: 4.4.3 + eslint: 9.39.5(jiti@2.7.0) + ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/types@8.56.1': {} + '@typescript-eslint/types@8.71.0': {} - '@typescript-eslint/typescript-estree@8.56.1(typescript@5.9.3)': + '@typescript-eslint/typescript-estree@8.71.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/project-service': 8.56.1(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.56.1(typescript@5.9.3) - '@typescript-eslint/types': 8.56.1 - '@typescript-eslint/visitor-keys': 8.56.1 - debug: 4.4.3(supports-color@8.1.1) - minimatch: 10.2.4 - semver: 7.7.4 - tinyglobby: 0.2.15 - ts-api-utils: 2.4.0(typescript@5.9.3) + '@typescript-eslint/project-service': 8.71.0(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.71.0(typescript@5.9.3) + '@typescript-eslint/types': 8.71.0 + '@typescript-eslint/visitor-keys': 8.71.0 + debug: 4.4.3 + minimatch: 10.2.6 + semver: 7.8.5 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.56.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/utils@8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)': dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) - '@typescript-eslint/scope-manager': 8.56.1 - '@typescript-eslint/types': 8.56.1 - '@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3) - eslint: 9.39.4(jiti@2.6.1) + '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5(jiti@2.7.0)) + '@typescript-eslint/scope-manager': 8.71.0 + '@typescript-eslint/types': 8.71.0 + '@typescript-eslint/typescript-estree': 8.71.0(typescript@5.9.3) + eslint: 9.39.5(jiti@2.7.0) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.56.1': + '@typescript-eslint/visitor-keys@8.71.0': dependencies: - '@typescript-eslint/types': 8.56.1 + '@typescript-eslint/types': 8.71.0 eslint-visitor-keys: 5.0.1 - '@unrs/resolver-binding-android-arm-eabi@1.11.1': + '@unrs/resolver-binding-android-arm-eabi@1.12.2': optional: true - '@unrs/resolver-binding-android-arm64@1.11.1': + '@unrs/resolver-binding-android-arm64@1.12.2': optional: true - '@unrs/resolver-binding-darwin-arm64@1.11.1': + '@unrs/resolver-binding-darwin-arm64@1.12.2': optional: true - '@unrs/resolver-binding-darwin-x64@1.11.1': + '@unrs/resolver-binding-darwin-x64@1.12.2': optional: true - '@unrs/resolver-binding-freebsd-x64@1.11.1': + '@unrs/resolver-binding-freebsd-x64@1.12.2': optional: true - '@unrs/resolver-binding-linux-arm-gnueabihf@1.11.1': + '@unrs/resolver-binding-linux-arm-gnueabihf@1.12.2': optional: true - '@unrs/resolver-binding-linux-arm-musleabihf@1.11.1': + '@unrs/resolver-binding-linux-arm-musleabihf@1.12.2': optional: true - '@unrs/resolver-binding-linux-arm64-gnu@1.11.1': + '@unrs/resolver-binding-linux-arm64-gnu@1.12.2': optional: true - '@unrs/resolver-binding-linux-arm64-musl@1.11.1': + '@unrs/resolver-binding-linux-arm64-musl@1.12.2': optional: true - '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': + '@unrs/resolver-binding-linux-loong64-gnu@1.12.2': optional: true - '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': + '@unrs/resolver-binding-linux-loong64-musl@1.12.2': optional: true - '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': + '@unrs/resolver-binding-linux-ppc64-gnu@1.12.2': optional: true - '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': + '@unrs/resolver-binding-linux-riscv64-gnu@1.12.2': optional: true - '@unrs/resolver-binding-linux-x64-gnu@1.11.1': + '@unrs/resolver-binding-linux-riscv64-musl@1.12.2': optional: true - '@unrs/resolver-binding-linux-x64-musl@1.11.1': + '@unrs/resolver-binding-linux-s390x-gnu@1.12.2': optional: true - '@unrs/resolver-binding-wasm32-wasi@1.11.1': + '@unrs/resolver-binding-linux-x64-gnu@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-x64-musl@1.12.2': + optional: true + + '@unrs/resolver-binding-openharmony-arm64@1.12.2': + optional: true + + '@unrs/resolver-binding-wasm32-wasi@1.12.2': dependencies: - '@napi-rs/wasm-runtime': 0.2.12 + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@napi-rs/wasm-runtime': 1.2.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) optional: true - '@unrs/resolver-binding-win32-arm64-msvc@1.11.1': + '@unrs/resolver-binding-win32-arm64-msvc@1.12.2': optional: true - '@unrs/resolver-binding-win32-ia32-msvc@1.11.1': + '@unrs/resolver-binding-win32-ia32-msvc@1.12.2': optional: true - '@unrs/resolver-binding-win32-x64-msvc@1.11.1': + '@unrs/resolver-binding-win32-x64-msvc@1.12.2': optional: true '@upstash/core-analytics@0.0.10': dependencies: - '@upstash/redis': 1.38.0 + '@upstash/redis': 1.39.0 - '@upstash/qstash@2.9.0': + '@upstash/qstash@2.12.0': dependencies: - crypto-js: 4.2.0 jose: 5.10.0 neverthrow: 7.2.0 + uncrypto: 0.1.3 - '@upstash/ratelimit@2.0.8(@upstash/redis@1.38.0)': + '@upstash/ratelimit@2.2.0(@upstash/redis@1.39.0)': dependencies: '@upstash/core-analytics': 0.0.10 - '@upstash/redis': 1.38.0 + '@upstash/redis': 1.39.0 - '@upstash/redis@1.38.0': + '@upstash/redis@1.39.0': dependencies: uncrypto: 0.1.3 - '@yr/monotone-cubic-spline@1.0.3': {} + '@visx/curve@4.0.1-alpha.0': + dependencies: + '@visx/vendor': 4.0.0-alpha.0 + + '@visx/event@4.0.1-alpha.0': + dependencies: + '@types/react': 19.3.0 + '@visx/point': 4.0.1-alpha.0 + + '@visx/grid@4.0.1-alpha.0(react@19.3.0)': + dependencies: + '@types/react': 19.3.0 + '@visx/curve': 4.0.1-alpha.0 + '@visx/group': 4.0.1-alpha.0(react@19.3.0) + '@visx/point': 4.0.1-alpha.0 + '@visx/scale': 4.0.1-alpha.0 + '@visx/shape': 4.0.1-alpha.0(react@19.3.0) + classnames: 2.5.1 + react: 19.3.0 + + '@visx/group@4.0.1-alpha.0(react@19.3.0)': + dependencies: + '@types/react': 19.3.0 + classnames: 2.5.1 + react: 19.3.0 + + '@visx/point@4.0.1-alpha.0': {} + + '@visx/responsive@4.0.1-alpha.0(react@19.3.0)': + dependencies: + '@types/lodash': 4.17.25 + '@types/react': 19.3.0 + lodash: 4.18.1 + react: 19.3.0 + + '@visx/scale@4.0.1-alpha.0': + dependencies: + '@visx/vendor': 4.0.0-alpha.0 + + '@visx/shape@4.0.1-alpha.0(react@19.3.0)': + dependencies: + '@types/lodash': 4.17.25 + '@types/react': 19.3.0 + '@visx/curve': 4.0.1-alpha.0 + '@visx/group': 4.0.1-alpha.0(react@19.3.0) + '@visx/scale': 4.0.1-alpha.0 + '@visx/vendor': 4.0.0-alpha.0 + classnames: 2.5.1 + lodash: 4.18.1 + react: 19.3.0 + + '@visx/vendor@4.0.0-alpha.0': + dependencies: + '@types/d3-array': 3.0.3 + '@types/d3-color': 3.1.0 + '@types/d3-delaunay': 6.0.1 + '@types/d3-format': 3.0.1 + '@types/d3-geo': 3.1.0 + '@types/d3-interpolate': 3.0.1 + '@types/d3-path': 3.1.1 + '@types/d3-scale': 4.0.2 + '@types/d3-shape': 3.1.7 + '@types/d3-time': 3.0.0 + '@types/d3-time-format': 2.1.0 + d3-array: 3.2.1 + d3-color: 3.1.0 + d3-delaunay: 6.0.2 + d3-format: 3.1.0 + d3-geo: 3.1.0 + d3-interpolate: 3.0.1 + d3-path: 3.1.0 + d3-scale: 4.0.2 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + internmap: 2.0.3 + + '@vitest/expect@3.2.7': + dependencies: + '@types/chai': 5.2.3 + '@vitest/spy': 3.2.7 + '@vitest/utils': 3.2.7 + chai: 5.3.3 + tinyrainbow: 2.0.0 + + '@vitest/mocker@3.2.7(vite@7.3.6(@types/node@25.9.9)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.15))': + dependencies: + '@vitest/spy': 3.2.7 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.6(@types/node@25.9.9)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.15) + + '@vitest/pretty-format@3.2.7': + dependencies: + tinyrainbow: 2.0.0 + + '@vitest/runner@3.2.7': + dependencies: + '@vitest/utils': 3.2.7 + pathe: 2.0.3 + strip-literal: 3.1.0 + + '@vitest/snapshot@3.2.7': + dependencies: + '@vitest/pretty-format': 3.2.7 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@3.2.7': + dependencies: + tinyspy: 4.0.6 + + '@vitest/utils@3.2.7': + dependencies: + '@vitest/pretty-format': 3.2.7 + loupe: 3.2.1 + tinyrainbow: 2.0.0 - acorn-jsx@5.3.2(acorn@8.16.0): + acorn-jsx@5.3.2(acorn@8.18.0): dependencies: - acorn: 8.16.0 + acorn: 8.18.0 - acorn@8.16.0: {} + acorn@8.18.0: {} - ajv@6.14.0: + agent-base@6.0.2: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + ajv@6.15.0: dependencies: fast-deep-equal: 3.1.3 fast-json-stable-stringify: 2.1.0 @@ -5922,7 +6455,7 @@ snapshots: ajv@8.20.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.1.2 + fast-uri: 3.1.8 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 @@ -5932,11 +6465,11 @@ snapshots: dependencies: color-convert: 2.0.1 - anynum@1.0.0: {} + apex-commons@0.8.1: {} - apexcharts@5.10.3: + apexcharts@7.8.0: dependencies: - '@yr/monotone-cubic-spline': 1.0.3 + apex-commons: 0.8.1 argparse@2.0.1: {} @@ -5947,68 +6480,70 @@ snapshots: call-bound: 1.0.4 is-array-buffer: 3.0.5 - array-includes@3.1.9: + array-includes@3.2.0: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 call-bound: 1.0.4 define-properties: 1.2.1 - es-abstract: 1.24.1 - es-object-atoms: 1.1.1 - get-intrinsic: 1.3.0 + es-abstract: 1.24.2 + es-object-atoms: 1.1.2 + es-shim-unscopables: 1.1.0 is-string: 1.1.1 math-intrinsics: 1.1.0 array.prototype.findlast@1.2.5: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 define-properties: 1.2.1 - es-abstract: 1.24.1 + es-abstract: 1.24.2 es-errors: 1.3.0 - es-object-atoms: 1.1.1 + es-object-atoms: 1.1.2 es-shim-unscopables: 1.1.0 array.prototype.findlastindex@1.2.6: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 call-bound: 1.0.4 define-properties: 1.2.1 - es-abstract: 1.24.1 + es-abstract: 1.24.2 es-errors: 1.3.0 - es-object-atoms: 1.1.1 + es-object-atoms: 1.1.2 es-shim-unscopables: 1.1.0 array.prototype.flat@1.3.3: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 define-properties: 1.2.1 - es-abstract: 1.24.1 + es-abstract: 1.24.2 es-shim-unscopables: 1.1.0 array.prototype.flatmap@1.3.3: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 define-properties: 1.2.1 - es-abstract: 1.24.1 + es-abstract: 1.24.2 es-shim-unscopables: 1.1.0 array.prototype.tosorted@1.1.4: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 define-properties: 1.2.1 - es-abstract: 1.24.1 + es-abstract: 1.24.2 es-errors: 1.3.0 es-shim-unscopables: 1.1.0 arraybuffer.prototype.slice@1.0.4: dependencies: array-buffer-byte-length: 1.0.2 - call-bind: 1.0.8 + call-bind: 1.0.9 define-properties: 1.2.1 - es-abstract: 1.24.1 + es-abstract: 1.24.2 es-errors: 1.3.0 get-intrinsic: 1.3.0 is-array-buffer: 3.0.5 + assertion-error@2.0.1: {} + ast-types-flow@0.0.8: {} async-function@1.0.0: {} @@ -6021,80 +6556,83 @@ snapshots: aws-ssl-profiles@1.1.2: {} - axe-core@4.11.1: {} + axe-core@4.13.0: {} - axios@1.13.6: + axios@1.20.0: dependencies: - follow-redirects: 1.15.11 - form-data: 4.0.5 - proxy-from-env: 1.1.0 + follow-redirects: 1.16.1 + form-data: 4.0.6 + https-proxy-agent: 5.0.1 + proxy-from-env: 2.1.0 transitivePeerDependencies: - debug + - supports-color axobject-query@4.1.0: {} babel-plugin-react-compiler@1.0.0: dependencies: - '@babel/types': 7.29.0 + '@babel/types': 7.29.8 balanced-match@1.0.2: {} balanced-match@4.0.4: {} - baseline-browser-mapping@2.10.0: {} + baseline-browser-mapping@2.11.27: {} bcryptjs@3.0.3: {} - better-auth@1.6.18(@opentelemetry/api@1.9.1)(@prisma/client@7.8.0(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3))(mysql2@3.15.3)(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(pg@8.21.0)(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + better-auth@1.6.18(@opentelemetry/api@1.9.1)(@prisma/client@7.10.0(prisma@7.10.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@5.9.3))(typescript@5.9.3))(mysql2@3.15.3)(next@16.3.8(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@types/node@25.9.9)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(pg@8.23.1)(prisma@7.10.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@5.9.3))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(vitest@3.2.7(@types/node@25.9.9)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.15)): dependencies: - '@better-auth/core': 1.6.18(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.3.0)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.3.0) - '@better-auth/drizzle-adapter': 1.6.18(@better-auth/core@1.6.18(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.3.0)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1) - '@better-auth/kysely-adapter': 1.6.18(@better-auth/core@1.6.18(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.3.0)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1)(kysely@0.29.2) - '@better-auth/memory-adapter': 1.6.18(@better-auth/core@1.6.18(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.3.0)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1) - '@better-auth/mongo-adapter': 1.6.18(@better-auth/core@1.6.18(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.3.0)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1) - '@better-auth/prisma-adapter': 1.6.18(@better-auth/core@1.6.18(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.3.0)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1)(@prisma/client@7.8.0(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3))(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)) - '@better-auth/telemetry': 1.6.18(@better-auth/core@1.6.18(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.3.0)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.3.0) + '@better-auth/core': 1.6.18(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.3.0)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4) + '@better-auth/drizzle-adapter': 1.6.18(@better-auth/core@1.6.18(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.3.0)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4))(@better-auth/utils@0.4.1) + '@better-auth/kysely-adapter': 1.6.18(@better-auth/core@1.6.18(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.3.0)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4))(@better-auth/utils@0.4.1)(kysely@0.29.6) + '@better-auth/memory-adapter': 1.6.18(@better-auth/core@1.6.18(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.3.0)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4))(@better-auth/utils@0.4.1) + '@better-auth/mongo-adapter': 1.6.18(@better-auth/core@1.6.18(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.3.0)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4))(@better-auth/utils@0.4.1) + '@better-auth/prisma-adapter': 1.6.18(@better-auth/core@1.6.18(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.3.0)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4))(@better-auth/utils@0.4.1)(@prisma/client@7.10.0(prisma@7.10.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@5.9.3))(typescript@5.9.3))(prisma@7.10.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@5.9.3)) + '@better-auth/telemetry': 1.6.18(@better-auth/core@1.6.18(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.3.0)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4))(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.3.0) '@better-auth/utils': 0.4.1 '@better-fetch/fetch': 1.3.0 - '@noble/ciphers': 2.2.0 - '@noble/hashes': 2.2.0 - better-call: 1.3.6(zod@4.4.3) + '@noble/ciphers': 2.4.0 + '@noble/hashes': 2.4.0 + better-call: 1.3.6(zod@4.6.5) defu: 6.1.7 - jose: 6.2.2 - kysely: 0.29.2 - nanostores: 1.3.0 - zod: 4.4.3 + jose: 6.2.12 + kysely: 0.29.6 + nanostores: 1.5.4 + zod: 4.6.5 optionalDependencies: - '@prisma/client': 7.8.0(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3) + '@prisma/client': 7.10.0(prisma@7.10.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@5.9.3))(typescript@5.9.3) mysql2: 3.15.3 - next: 16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - pg: 8.21.0 - prisma: 7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + next: 16.3.8(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@types/node@25.9.9)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + pg: 8.23.1 + prisma: 7.10.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@5.9.3) + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + vitest: 3.2.7(@types/node@25.9.9)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.15) transitivePeerDependencies: - '@cloudflare/workers-types' - '@opentelemetry/api' - better-call@1.3.6(zod@4.4.3): + better-call@1.3.6(zod@4.6.5): dependencies: - '@better-auth/utils': 0.4.1 - '@better-fetch/fetch': 1.3.0 + '@better-auth/utils': 0.4.2 + '@better-fetch/fetch': 1.3.2 rou3: 0.7.12 - set-cookie-parser: 3.1.0 + set-cookie-parser: 3.1.2 optionalDependencies: - zod: 4.4.3 + zod: 4.6.5 - better-result@2.9.2: {} + better-result@2.10.0: {} bowser@2.14.1: {} - brace-expansion@1.1.12: + brace-expansion@1.1.21: dependencies: balanced-match: 1.0.2 concat-map: 0.0.1 - brace-expansion@5.0.4: + brace-expansion@5.0.12: dependencies: balanced-match: 4.0.4 @@ -6102,13 +6640,13 @@ snapshots: dependencies: fill-range: 7.1.1 - browserslist@4.28.1: + browserslist@4.29.3: dependencies: - baseline-browser-mapping: 2.10.0 - caniuse-lite: 1.0.30001777 - electron-to-chromium: 1.5.307 - node-releases: 2.0.36 - update-browserslist-db: 1.2.3(browserslist@4.28.1) + baseline-browser-mapping: 2.11.27 + caniuse-lite: 1.0.30001814 + electron-to-chromium: 1.5.444 + node-releases: 2.0.57 + update-browserslist-db: 1.3.3(browserslist@4.29.3) c12@3.3.4: dependencies: @@ -6116,21 +6654,23 @@ snapshots: confbox: 0.2.4 defu: 6.1.7 dotenv: 17.4.2 - exsolve: 1.0.8 - giget: 3.3.0 - jiti: 2.6.1 - ohash: 2.0.11 + exsolve: 1.1.3 + giget: 3.3.1 + jiti: 2.7.0 + ohash: 2.0.12 pathe: 2.0.3 perfect-debounce: 2.1.0 - pkg-types: 2.3.1 - rc9: 3.0.1 + pkg-types: 2.3.3 + rc9: 3.1.0 + + cac@6.7.14: {} call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 function-bind: 1.1.2 - call-bind@1.0.8: + call-bind@1.0.9: dependencies: call-bind-apply-helpers: 1.0.2 es-define-property: 1.0.1 @@ -6146,22 +6686,28 @@ snapshots: camelcase@5.3.1: {} - camelize@1.0.1: {} + caniuse-lite@1.0.30001814: {} - caniuse-lite@1.0.30001777: {} + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 chalk@4.1.2: dependencies: ansi-styles: 4.3.0 supports-color: 7.2.0 - chart.js@4.5.1: - dependencies: - '@kurkle/color': 0.3.4 + check-error@2.1.3: {} chokidar@5.0.0: dependencies: - readdirp: 5.0.0 + readdirp: 5.1.1 + + classnames@2.5.1: {} client-only@0.0.1: {} @@ -6189,6 +6735,8 @@ snapshots: confbox@0.2.4: {} + confbox@0.3.1: {} + convert-source-map@2.0.0: {} cookie@1.1.1: {} @@ -6201,17 +6749,53 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 - crypto-js@4.2.0: {} + csstype@3.2.3: {} - css-color-keywords@1.0.0: {} + d3-array@3.2.1: + dependencies: + internmap: 2.0.3 - css-to-react-native@3.2.0: + d3-array@3.2.4: dependencies: - camelize: 1.0.1 - css-color-keywords: 1.0.0 - postcss-value-parser: 4.2.0 + internmap: 2.0.3 - csstype@3.2.3: {} + d3-color@3.1.0: {} + + d3-delaunay@6.0.2: + dependencies: + delaunator: 5.1.0 + + d3-format@3.1.0: {} + + d3-geo@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@3.1.0: {} + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.0 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 damerau-levenshtein@1.0.8: {} @@ -6237,14 +6821,14 @@ snapshots: dependencies: ms: 2.1.3 - debug@4.4.3(supports-color@8.1.1): + debug@4.4.3: dependencies: ms: 2.1.3 - optionalDependencies: - supports-color: 8.1.1 decamelize@1.2.0: {} + deep-eql@5.0.2: {} + deep-is@0.1.4: {} deepmerge-ts@7.1.5: {} @@ -6265,6 +6849,10 @@ snapshots: defu@6.1.7: {} + delaunator@5.1.0: + dependencies: + robust-predicates: 3.0.3 + delayed-stream@1.0.0: {} denque@2.1.0: {} @@ -6314,7 +6902,9 @@ snapshots: '@standard-schema/spec': 1.1.0 fast-check: 3.23.2 - electron-to-chromium@1.5.307: {} + electron-to-chromium@1.5.444: {} + + elkjs@0.11.1: {} emoji-regex@8.0.0: {} @@ -6322,31 +6912,38 @@ snapshots: empathic@2.0.0: {} - enhanced-resolve@5.20.0: + enhanced-resolve@5.26.0: dependencies: graceful-fs: 4.2.11 - tapable: 2.3.0 + tapable: 2.3.3 entities@4.5.0: {} env-paths@3.0.0: {} - es-abstract@1.24.1: + es-abstract-get@1.0.0: + dependencies: + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + is-callable: 1.2.7 + object-inspect: 1.13.4 + + es-abstract@1.24.2: dependencies: array-buffer-byte-length: 1.0.2 arraybuffer.prototype.slice: 1.0.4 available-typed-arrays: 1.0.7 - call-bind: 1.0.8 + call-bind: 1.0.9 call-bound: 1.0.4 data-view-buffer: 1.0.2 data-view-byte-length: 1.0.2 data-view-byte-offset: 1.0.1 es-define-property: 1.0.1 es-errors: 1.3.0 - es-object-atoms: 1.1.1 + es-object-atoms: 1.1.2 es-set-tostringtag: 2.1.0 - es-to-primitive: 1.3.0 - function.prototype.name: 1.1.8 + es-to-primitive: 1.3.4 + function.prototype.name: 1.2.0 get-intrinsic: 1.3.0 get-proto: 1.0.1 get-symbol-description: 1.1.0 @@ -6355,7 +6952,7 @@ snapshots: has-property-descriptors: 1.0.2 has-proto: 1.2.0 has-symbols: 1.1.0 - hasown: 2.0.2 + hasown: 2.0.4 internal-slot: 1.1.0 is-array-buffer: 3.0.5 is-callable: 1.2.7 @@ -6371,33 +6968,33 @@ snapshots: object-inspect: 1.13.4 object-keys: 1.1.1 object.assign: 4.1.7 - own-keys: 1.0.1 + own-keys: 1.0.2 regexp.prototype.flags: 1.5.4 - safe-array-concat: 1.1.3 + safe-array-concat: 1.1.4 safe-push-apply: 1.0.0 safe-regex-test: 1.1.0 set-proto: 1.0.0 stop-iteration-iterator: 1.1.0 - string.prototype.trim: 1.2.10 - string.prototype.trimend: 1.0.9 + string.prototype.trim: 1.2.11 + string.prototype.trimend: 1.0.10 string.prototype.trimstart: 1.0.8 typed-array-buffer: 1.0.3 typed-array-byte-length: 1.0.3 - typed-array-byte-offset: 1.0.4 - typed-array-length: 1.0.7 + typed-array-byte-offset: 1.0.5 + typed-array-length: 1.0.8 unbox-primitive: 1.1.0 - which-typed-array: 1.1.20 + which-typed-array: 1.1.24 es-define-property@1.0.1: {} es-errors@1.3.0: {} - es-iterator-helpers@1.2.2: + es-iterator-helpers@1.4.0: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 call-bound: 1.0.4 define-properties: 1.2.1 - es-abstract: 1.24.1 + es-abstract: 1.24.2 es-errors: 1.3.0 es-set-tostringtag: 2.1.0 function-bind: 1.1.2 @@ -6409,9 +7006,11 @@ snapshots: has-symbols: 1.1.0 internal-slot: 1.1.0 iterator.prototype: 1.1.5 - safe-array-concat: 1.1.3 + math-intrinsics: 1.1.0 - es-object-atoms@1.1.1: + es-module-lexer@1.7.0: {} + + es-object-atoms@1.1.2: dependencies: es-errors: 1.3.0 @@ -6420,63 +7019,66 @@ snapshots: es-errors: 1.3.0 get-intrinsic: 1.3.0 has-tostringtag: 1.0.2 - hasown: 2.0.2 + hasown: 2.0.4 es-shim-unscopables@1.1.0: dependencies: - hasown: 2.0.2 + hasown: 2.0.4 - es-to-primitive@1.3.0: + es-to-primitive@1.3.4: dependencies: + es-abstract-get: 1.0.0 + es-define-property: 1.0.1 + es-errors: 1.3.0 is-callable: 1.2.7 is-date-object: 1.1.0 is-symbol: 1.1.1 - esbuild@0.28.1: + esbuild@0.28.2: optionalDependencies: - '@esbuild/aix-ppc64': 0.28.1 - '@esbuild/android-arm': 0.28.1 - '@esbuild/android-arm64': 0.28.1 - '@esbuild/android-x64': 0.28.1 - '@esbuild/darwin-arm64': 0.28.1 - '@esbuild/darwin-x64': 0.28.1 - '@esbuild/freebsd-arm64': 0.28.1 - '@esbuild/freebsd-x64': 0.28.1 - '@esbuild/linux-arm': 0.28.1 - '@esbuild/linux-arm64': 0.28.1 - '@esbuild/linux-ia32': 0.28.1 - '@esbuild/linux-loong64': 0.28.1 - '@esbuild/linux-mips64el': 0.28.1 - '@esbuild/linux-ppc64': 0.28.1 - '@esbuild/linux-riscv64': 0.28.1 - '@esbuild/linux-s390x': 0.28.1 - '@esbuild/linux-x64': 0.28.1 - '@esbuild/netbsd-arm64': 0.28.1 - '@esbuild/netbsd-x64': 0.28.1 - '@esbuild/openbsd-arm64': 0.28.1 - '@esbuild/openbsd-x64': 0.28.1 - '@esbuild/openharmony-arm64': 0.28.1 - '@esbuild/sunos-x64': 0.28.1 - '@esbuild/win32-arm64': 0.28.1 - '@esbuild/win32-ia32': 0.28.1 - '@esbuild/win32-x64': 0.28.1 + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 escalade@3.2.0: {} escape-string-regexp@4.0.0: {} - eslint-config-next@16.1.6(@typescript-eslint/parser@8.56.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3): + eslint-config-next@16.3.8(@typescript-eslint/parser@8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3): dependencies: - '@next/eslint-plugin-next': 16.1.6 - eslint: 9.39.4(jiti@2.6.1) - eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) - eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.4(jiti@2.6.1)) - eslint-plugin-react: 7.37.5(eslint@9.39.4(jiti@2.6.1)) - eslint-plugin-react-hooks: 7.0.1(eslint@9.39.4(jiti@2.6.1)) + '@next/eslint-plugin-next': 16.3.8(eslint@9.39.5(jiti@2.7.0)) + eslint: 9.39.5(jiti@2.7.0) + eslint-import-resolver-node: 0.3.10 + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)) + eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.5(jiti@2.7.0)) + eslint-plugin-react: 7.37.5(eslint@9.39.5(jiti@2.7.0)) + eslint-plugin-react-hooks: 7.1.1(eslint@9.39.5(jiti@2.7.0)) globals: 16.4.0 - typescript-eslint: 8.56.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + typescript-eslint: 8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) optionalDependencies: typescript: 5.9.3 transitivePeerDependencies: @@ -6485,81 +7087,81 @@ snapshots: - eslint-plugin-import-x - supports-color - eslint-import-resolver-node@0.3.9: + eslint-import-resolver-node@0.3.10: dependencies: debug: 3.2.7 - is-core-module: 2.16.1 - resolve: 1.22.11 + is-core-module: 2.17.0 + resolve: 2.0.0-next.7 transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1)): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)): dependencies: '@nolyfill/is-core-module': 1.0.39 - debug: 4.4.3(supports-color@8.1.1) - eslint: 9.39.4(jiti@2.6.1) - get-tsconfig: 4.13.6 + debug: 4.4.3 + eslint: 9.39.5(jiti@2.7.0) + get-tsconfig: 4.14.3 is-bun-module: 2.0.0 stable-hash: 0.0.5 - tinyglobby: 0.2.15 - unrs-resolver: 1.11.1 + tinyglobby: 0.2.17 + unrs-resolver: 1.12.2 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)) transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.56.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)): + eslint-module-utils@2.14.0(@typescript-eslint/parser@8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)): dependencies: debug: 3.2.7 optionalDependencies: - '@typescript-eslint/parser': 8.56.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - eslint: 9.39.4(jiti@2.6.1) - eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1)) + '@typescript-eslint/parser': 8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) + eslint: 9.39.5(jiti@2.7.0) + eslint-import-resolver-node: 0.3.10 + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)) transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)): dependencies: '@rtsao/scc': 1.1.0 - array-includes: 3.1.9 + array-includes: 3.2.0 array.prototype.findlastindex: 1.2.6 array.prototype.flat: 1.3.3 array.prototype.flatmap: 1.3.3 debug: 3.2.7 doctrine: 2.1.0 - eslint: 9.39.4(jiti@2.6.1) - eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.56.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) - hasown: 2.0.2 - is-core-module: 2.16.1 + eslint: 9.39.5(jiti@2.7.0) + eslint-import-resolver-node: 0.3.10 + eslint-module-utils: 2.14.0(@typescript-eslint/parser@8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)) + hasown: 2.0.4 + is-core-module: 2.17.0 is-glob: 4.0.3 minimatch: 3.1.5 object.fromentries: 2.0.8 object.groupby: 1.0.3 object.values: 1.2.1 semver: 6.3.1 - string.prototype.trimend: 1.0.9 + string.prototype.trimend: 1.0.10 tsconfig-paths: 3.15.0 optionalDependencies: - '@typescript-eslint/parser': 8.56.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack - supports-color - eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.4(jiti@2.6.1)): + eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.5(jiti@2.7.0)): dependencies: aria-query: 5.3.2 - array-includes: 3.1.9 + array-includes: 3.2.0 array.prototype.flatmap: 1.3.3 ast-types-flow: 0.0.8 - axe-core: 4.11.1 + axe-core: 4.13.0 axobject-query: 4.1.0 damerau-levenshtein: 1.0.8 emoji-regex: 9.2.2 - eslint: 9.39.4(jiti@2.6.1) - hasown: 2.0.2 + eslint: 9.39.5(jiti@2.7.0) + hasown: 2.0.4 jsx-ast-utils: 3.3.5 language-tags: 1.0.9 minimatch: 3.1.5 @@ -6567,37 +7169,37 @@ snapshots: safe-regex-test: 1.1.0 string.prototype.includes: 2.0.1 - eslint-plugin-react-hooks@7.0.1(eslint@9.39.4(jiti@2.6.1)): + eslint-plugin-react-hooks@7.1.1(eslint@9.39.5(jiti@2.7.0)): dependencies: - '@babel/core': 7.29.0(supports-color@8.1.1) - '@babel/parser': 7.29.0 - eslint: 9.39.4(jiti@2.6.1) + '@babel/core': 7.29.7 + '@babel/parser': 7.29.9 + eslint: 9.39.5(jiti@2.7.0) hermes-parser: 0.25.1 - zod: 4.4.3 - zod-validation-error: 4.0.2(zod@4.4.3) + zod: 4.6.5 + zod-validation-error: 4.0.2(zod@4.6.5) transitivePeerDependencies: - supports-color - eslint-plugin-react@7.37.5(eslint@9.39.4(jiti@2.6.1)): + eslint-plugin-react@7.37.5(eslint@9.39.5(jiti@2.7.0)): dependencies: - array-includes: 3.1.9 + array-includes: 3.2.0 array.prototype.findlast: 1.2.5 array.prototype.flatmap: 1.3.3 array.prototype.tosorted: 1.1.4 doctrine: 2.1.0 - es-iterator-helpers: 1.2.2 - eslint: 9.39.4(jiti@2.6.1) + es-iterator-helpers: 1.4.0 + eslint: 9.39.5(jiti@2.7.0) estraverse: 5.3.0 - hasown: 2.0.2 + hasown: 2.0.4 jsx-ast-utils: 3.3.5 minimatch: 3.1.5 object.entries: 1.1.9 object.fromentries: 2.0.8 object.values: 1.2.1 prop-types: 15.8.1 - resolve: 2.0.0-next.6 + resolve: 2.0.0-next.7 semver: 6.3.1 - string.prototype.matchall: 4.0.12 + string.prototype.matchall: 4.1.0 string.prototype.repeat: 1.0.0 eslint-scope@8.4.0: @@ -6611,24 +7213,24 @@ snapshots: eslint-visitor-keys@5.0.1: {} - eslint@9.39.4(jiti@2.6.1): + eslint@9.39.5(jiti@2.7.0): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) + '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5(jiti@2.7.0)) '@eslint-community/regexpp': 4.12.2 '@eslint/config-array': 0.21.2 '@eslint/config-helpers': 0.4.2 '@eslint/core': 0.17.0 - '@eslint/eslintrc': 3.3.5 - '@eslint/js': 9.39.4 + '@eslint/eslintrc': 3.3.7 + '@eslint/js': 9.39.5 '@eslint/plugin-kit': 0.4.1 - '@humanfs/node': 0.16.7 + '@humanfs/node': 0.16.8 '@humanwhocodes/module-importer': 1.0.1 '@humanwhocodes/retry': 0.4.3 - '@types/estree': 1.0.8 - ajv: 6.14.0 + '@types/estree': 1.0.9 + ajv: 6.15.0 chalk: 4.1.2 cross-spawn: 7.0.6 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3 escape-string-regexp: 4.0.0 eslint-scope: 8.4.0 eslint-visitor-keys: 4.2.1 @@ -6648,14 +7250,14 @@ snapshots: natural-compare: 1.4.0 optionator: 0.9.4 optionalDependencies: - jiti: 2.6.1 + jiti: 2.7.0 transitivePeerDependencies: - supports-color espree@10.4.0: dependencies: - acorn: 8.16.0 - acorn-jsx: 5.3.2(acorn@8.16.0) + acorn: 8.18.0 + acorn-jsx: 5.3.2(acorn@8.18.0) eslint-visitor-keys: 4.2.1 esquery@1.7.0: @@ -6670,14 +7272,22 @@ snapshots: estree-walker@2.0.2: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + esutils@2.0.3: {} - exsolve@1.0.8: {} + expect-type@1.4.0: {} + + exsolve@1.1.3: {} fast-check@3.23.2: dependencies: pure-rand: 6.1.0 + fast-decode-uri-component@1.0.1: {} + fast-deep-equal@3.1.3: {} fast-glob@3.3.1: @@ -6692,29 +7302,21 @@ snapshots: fast-levenshtein@2.0.6: {} - fast-sha256@1.3.0: {} - - fast-uri@3.1.2: {} - - fast-xml-builder@1.2.0: + fast-querystring@1.1.2: dependencies: - path-expression-matcher: 1.5.0 - xml-naming: 0.1.0 + fast-decode-uri-component: 1.0.1 - fast-xml-parser@5.7.3: - dependencies: - '@nodable/entities': 2.2.0 - fast-xml-builder: 1.2.0 - path-expression-matcher: 1.5.0 - strnum: 2.4.0 + fast-sha256@1.3.0: {} - fastq@1.20.1: + fast-uri@3.1.8: {} + + fastq@1.20.3: dependencies: reusify: 1.1.0 - fdir@6.5.0(picomatch@4.0.3): + fdir@6.5.0(picomatch@4.0.7): optionalDependencies: - picomatch: 4.0.3 + picomatch: 4.0.7 fflate@0.4.9: {} @@ -6726,6 +7328,12 @@ snapshots: dependencies: to-regex-range: 5.0.1 + find-my-way@9.7.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-querystring: 1.1.2 + safe-regex2: 5.1.1 + find-up@4.1.0: dependencies: locate-path: 5.0.0 @@ -6738,12 +7346,12 @@ snapshots: flat-cache@4.0.1: dependencies: - flatted: 3.3.4 + flatted: 3.4.4 keyv: 4.5.4 - flatted@3.3.4: {} + flatted@3.4.4: {} - follow-redirects@1.15.11: {} + follow-redirects@1.16.1: {} for-each@0.3.5: dependencies: @@ -6754,37 +7362,40 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 - form-data@4.0.5: + form-data@4.0.6: dependencies: asynckit: 0.4.0 combined-stream: 1.0.8 es-set-tostringtag: 2.1.0 - hasown: 2.0.2 + hasown: 2.0.4 mime-types: 2.1.35 - framer-motion@12.38.0(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + framer-motion@12.43.0(@emotion/is-prop-valid@1.4.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0): dependencies: - motion-dom: 12.38.0 - motion-utils: 12.36.0 + motion-dom: 12.43.0 + motion-utils: 12.39.0 tslib: 2.8.1 optionalDependencies: '@emotion/is-prop-valid': 1.4.0 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) fsevents@2.3.3: optional: true function-bind@1.1.2: {} - function.prototype.name@1.1.8: + function.prototype.name@1.2.0: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 call-bound: 1.0.4 - define-properties: 1.2.1 + es-define-property: 1.0.1 + es-errors: 1.3.0 functions-have-names: 1.2.3 - hasown: 2.0.2 + has-property-descriptors: 1.0.2 + hasown: 2.0.4 is-callable: 1.2.7 + is-document.all: 1.0.0 functions-have-names@1.2.3: {} @@ -6803,12 +7414,12 @@ snapshots: call-bind-apply-helpers: 1.0.2 es-define-property: 1.0.1 es-errors: 1.3.0 - es-object-atoms: 1.1.1 + es-object-atoms: 1.1.2 function-bind: 1.1.2 get-proto: 1.0.1 gopd: 1.2.0 has-symbols: 1.1.0 - hasown: 2.0.2 + hasown: 2.0.4 math-intrinsics: 1.1.0 get-port-please@3.2.0: {} @@ -6816,7 +7427,7 @@ snapshots: get-proto@1.0.1: dependencies: dunder-proto: 1.0.1 - es-object-atoms: 1.1.1 + es-object-atoms: 1.1.2 get-symbol-description@1.1.0: dependencies: @@ -6824,11 +7435,11 @@ snapshots: es-errors: 1.3.0 get-intrinsic: 1.3.0 - get-tsconfig@4.13.6: + get-tsconfig@4.14.3: dependencies: resolve-pkg-maps: 1.0.0 - giget@3.3.0: {} + giget@3.3.1: {} glob-parent@5.1.2: dependencies: @@ -6840,7 +7451,7 @@ snapshots: glob@13.0.6: dependencies: - minimatch: 10.2.4 + minimatch: 10.2.6 minipass: 7.1.3 path-scurry: 2.0.2 @@ -6857,7 +7468,7 @@ snapshots: graceful-fs@4.2.11: {} - grammex@3.1.12: {} + grammex@3.1.13: {} graphmatch@1.1.1: {} @@ -6879,7 +7490,7 @@ snapshots: dependencies: has-symbols: 1.1.0 - hasown@2.0.2: + hasown@2.0.4: dependencies: function-bind: 1.1.2 @@ -6889,7 +7500,7 @@ snapshots: dependencies: hermes-estree: 0.25.1 - hono@4.12.25: {} + hono@4.13.13: {} html-to-text@9.0.5: dependencies: @@ -6899,6 +7510,8 @@ snapshots: htmlparser2: 8.0.2 selderee: 0.11.0 + html5parser@3.0.0: {} + htmlparser2@8.0.2: dependencies: domelementtype: 2.3.0 @@ -6906,21 +7519,26 @@ snapshots: domutils: 3.2.2 entities: 4.5.0 - http-status-codes@2.3.0: {} + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color iceberg-js@0.8.1: {} - iconify-icon@3.0.2: + iconify-icon@3.0.3: dependencies: '@iconify/types': 2.0.0 - iconv-lite@0.7.2: + iconv-lite@0.7.3: dependencies: safer-buffer: 2.1.2 ignore@5.3.2: {} - ignore@7.0.5: {} + ignore@7.0.12: {} import-fresh@3.3.1: dependencies: @@ -6932,12 +7550,14 @@ snapshots: internal-slot@1.1.0: dependencies: es-errors: 1.3.0 - hasown: 2.0.2 - side-channel: 1.1.0 + hasown: 2.0.4 + side-channel: 1.1.1 + + internmap@2.0.3: {} is-array-buffer@3.0.5: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 call-bound: 1.0.4 get-intrinsic: 1.3.0 @@ -6960,13 +7580,13 @@ snapshots: is-bun-module@2.0.0: dependencies: - semver: 7.7.4 + semver: 7.8.5 is-callable@1.2.7: {} - is-core-module@2.16.1: + is-core-module@2.17.0: dependencies: - hasown: 2.0.2 + hasown: 2.0.4 is-data-view@1.0.2: dependencies: @@ -6979,6 +7599,10 @@ snapshots: call-bound: 1.0.4 has-tostringtag: 1.0.2 + is-document.all@1.0.0: + dependencies: + call-bound: 1.0.4 + is-extglob@2.1.1: {} is-finalizationregistry@1.1.1: @@ -7014,14 +7638,14 @@ snapshots: is-reference@1.2.1: dependencies: - '@types/estree': 1.0.8 + '@types/estree': 1.0.9 is-regex@1.2.1: dependencies: call-bound: 1.0.4 gopd: 1.2.0 has-tostringtag: 1.0.2 - hasown: 2.0.2 + hasown: 2.0.4 is-set@2.0.3: {} @@ -7042,7 +7666,7 @@ snapshots: is-typed-array@1.1.15: dependencies: - which-typed-array: 1.1.20 + which-typed-array: 1.1.24 is-weakmap@2.0.2: {} @@ -7062,21 +7686,23 @@ snapshots: iterator.prototype@1.1.5: dependencies: define-data-property: 1.1.4 - es-object-atoms: 1.1.1 + es-object-atoms: 1.1.2 get-intrinsic: 1.3.0 get-proto: 1.0.1 has-symbols: 1.1.0 set-function-name: 2.0.2 - jiti@2.6.1: {} + jiti@2.7.0: {} jose@5.10.0: {} - jose@6.2.2: {} + jose@6.2.12: {} js-tokens@4.0.0: {} - js-yaml@4.1.1: + js-tokens@9.0.1: {} + + js-yaml@4.3.2: dependencies: argparse: 2.0.1 @@ -7098,7 +7724,7 @@ snapshots: jsx-ast-utils@3.3.5: dependencies: - array-includes: 3.1.9 + array-includes: 3.2.0 array.prototype.flat: 1.3.3 object.assign: 4.1.7 object.values: 1.2.1 @@ -7107,7 +7733,7 @@ snapshots: dependencies: json-buffer: 3.0.1 - kysely@0.29.2: {} + kysely@0.29.6: {} language-subtag-registry@0.3.23: {} @@ -7122,54 +7748,54 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 - lightningcss-android-arm64@1.31.1: + lightningcss-android-arm64@1.32.0: optional: true - lightningcss-darwin-arm64@1.31.1: + lightningcss-darwin-arm64@1.32.0: optional: true - lightningcss-darwin-x64@1.31.1: + lightningcss-darwin-x64@1.32.0: optional: true - lightningcss-freebsd-x64@1.31.1: + lightningcss-freebsd-x64@1.32.0: optional: true - lightningcss-linux-arm-gnueabihf@1.31.1: + lightningcss-linux-arm-gnueabihf@1.32.0: optional: true - lightningcss-linux-arm64-gnu@1.31.1: + lightningcss-linux-arm64-gnu@1.32.0: optional: true - lightningcss-linux-arm64-musl@1.31.1: + lightningcss-linux-arm64-musl@1.32.0: optional: true - lightningcss-linux-x64-gnu@1.31.1: + lightningcss-linux-x64-gnu@1.32.0: optional: true - lightningcss-linux-x64-musl@1.31.1: + lightningcss-linux-x64-musl@1.32.0: optional: true - lightningcss-win32-arm64-msvc@1.31.1: + lightningcss-win32-arm64-msvc@1.32.0: optional: true - lightningcss-win32-x64-msvc@1.31.1: + lightningcss-win32-x64-msvc@1.32.0: optional: true - lightningcss@1.31.1: + lightningcss@1.32.0: dependencies: detect-libc: 2.1.2 optionalDependencies: - lightningcss-android-arm64: 1.31.1 - lightningcss-darwin-arm64: 1.31.1 - lightningcss-darwin-x64: 1.31.1 - lightningcss-freebsd-x64: 1.31.1 - lightningcss-linux-arm-gnueabihf: 1.31.1 - lightningcss-linux-arm64-gnu: 1.31.1 - lightningcss-linux-arm64-musl: 1.31.1 - lightningcss-linux-x64-gnu: 1.31.1 - lightningcss-linux-x64-musl: 1.31.1 - lightningcss-win32-arm64-msvc: 1.31.1 - lightningcss-win32-x64-msvc: 1.31.1 + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 locate-path@5.0.0: dependencies: @@ -7181,27 +7807,35 @@ snapshots: lodash.merge@4.6.2: {} + lodash@4.18.1: {} + long@5.3.2: {} loose-envify@1.4.0: dependencies: js-tokens: 4.0.0 + loupe@3.2.1: {} + lru-cache@11.5.3: {} lru-cache@5.1.1: dependencies: yallist: 3.1.1 - lru.min@1.1.4: {} + lru.min@1.1.5: {} - lucide-react@0.577.0(react@19.2.4): + lucide-react@0.577.0(react@19.3.0): dependencies: - react: 19.2.4 + react: 19.3.0 magic-string@0.30.21: dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/sourcemap-codec': 1.6.0 + + magic-string@1.4.2: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 marked@15.0.12: {} @@ -7212,7 +7846,7 @@ snapshots: micromatch@4.0.8: dependencies: braces: 3.0.3 - picomatch: 2.3.1 + picomatch: 2.3.2 mime-db@1.52.0: {} @@ -7220,23 +7854,23 @@ snapshots: dependencies: mime-db: 1.52.0 - minimatch@10.2.4: + minimatch@10.2.6: dependencies: - brace-expansion: 5.0.4 + brace-expansion: 5.0.12 minimatch@3.1.5: dependencies: - brace-expansion: 1.1.12 + brace-expansion: 1.1.21 minimist@1.2.8: {} minipass@7.1.3: {} - motion-dom@12.38.0: + motion-dom@12.43.0: dependencies: - motion-utils: 12.36.0 + motion-utils: 12.39.0 - motion-utils@12.36.0: {} + motion-utils@12.39.0: {} ms@2.1.3: {} @@ -7245,20 +7879,20 @@ snapshots: aws-ssl-profiles: 1.1.2 denque: 2.1.0 generate-function: 2.3.1 - iconv-lite: 0.7.2 + iconv-lite: 0.7.3 long: 5.3.2 - lru.min: 1.1.4 + lru.min: 1.1.5 named-placeholders: 1.1.6 seq-queue: 0.0.5 sqlstring: 2.3.3 named-placeholders@1.1.6: dependencies: - lru.min: 1.1.4 + lru.min: 1.1.5 - nanoid@3.3.11: {} + nanoid@3.3.19: {} - nanostores@1.3.0: {} + nanostores@1.5.4: {} napi-postinstall@0.3.4: {} @@ -7266,44 +7900,39 @@ snapshots: neverthrow@7.2.0: {} - next-auth@5.0.0-beta.30(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4): + next-themes@0.4.6(react-dom@19.3.0(react@19.3.0))(react@19.3.0): dependencies: - '@auth/core': 0.41.0 - next: 16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - react: 19.2.4 + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) - next-themes@0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + next@16.3.8(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@types/node@25.9.9)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0): dependencies: - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - - next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): - dependencies: - '@next/env': 16.1.6 - '@swc/helpers': 0.5.15 - baseline-browser-mapping: 2.10.0 - caniuse-lite: 1.0.30001777 - postcss: 8.4.31 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - styled-jsx: 5.1.6(@babel/core@7.29.0)(react@19.2.4) + '@next/env': 16.3.8 + '@swc/helpers': 0.5.23 + baseline-browser-mapping: 2.11.27 + caniuse-lite: 1.0.30001814 + postcss: 8.5.23 + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + styled-jsx: 5.1.6(@babel/core@7.29.7)(react@19.3.0) optionalDependencies: - '@next/swc-darwin-arm64': 16.1.6 - '@next/swc-darwin-x64': 16.1.6 - '@next/swc-linux-arm64-gnu': 16.1.6 - '@next/swc-linux-arm64-musl': 16.1.6 - '@next/swc-linux-x64-gnu': 16.1.6 - '@next/swc-linux-x64-musl': 16.1.6 - '@next/swc-win32-arm64-msvc': 16.1.6 - '@next/swc-win32-x64-msvc': 16.1.6 + '@next/swc-darwin-arm64': 16.3.8 + '@next/swc-darwin-x64': 16.3.8 + '@next/swc-linux-arm64-gnu': 16.3.8 + '@next/swc-linux-arm64-musl': 16.3.8 + '@next/swc-linux-x64-gnu': 16.3.8 + '@next/swc-linux-x64-musl': 16.3.8 + '@next/swc-win32-arm64-msvc': 16.3.8 + '@next/swc-win32-x64-msvc': 16.3.8 '@opentelemetry/api': 1.9.1 babel-plugin-react-compiler: 1.0.0 - sharp: 0.34.5 + sharp: 0.35.5(@types/node@25.9.9) transitivePeerDependencies: - '@babel/core' + - '@types/node' - babel-plugin-macros - node-exports-info@1.6.0: + node-exports-info@1.6.2: dependencies: array.prototype.flatmap: 1.3.3 es-errors: 1.3.0 @@ -7314,9 +7943,7 @@ snapshots: dependencies: whatwg-url: 5.0.0 - node-releases@2.0.36: {} - - oauth4webapi@3.8.5: {} + node-releases@2.0.57: {} object-assign@4.1.1: {} @@ -7326,41 +7953,41 @@ snapshots: object.assign@4.1.7: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 call-bound: 1.0.4 define-properties: 1.2.1 - es-object-atoms: 1.1.1 + es-object-atoms: 1.1.2 has-symbols: 1.1.0 object-keys: 1.1.1 object.entries@1.1.9: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 call-bound: 1.0.4 define-properties: 1.2.1 - es-object-atoms: 1.1.1 + es-object-atoms: 1.1.2 object.fromentries@2.0.8: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 define-properties: 1.2.1 - es-abstract: 1.24.1 - es-object-atoms: 1.1.1 + es-abstract: 1.24.2 + es-object-atoms: 1.1.2 object.groupby@1.0.3: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 define-properties: 1.2.1 - es-abstract: 1.24.1 + es-abstract: 1.24.2 object.values@1.2.1: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 call-bound: 1.0.4 define-properties: 1.2.1 - es-object-atoms: 1.1.1 + es-object-atoms: 1.1.2 - ohash@2.0.11: {} + ohash@2.0.12: {} optionator@0.9.4: dependencies: @@ -7371,35 +7998,36 @@ snapshots: type-check: 0.4.0 word-wrap: 1.2.5 - own-keys@1.0.1: + own-keys@1.0.2: dependencies: + call-bound: 1.0.4 get-intrinsic: 1.3.0 object-keys: 1.1.1 safe-push-apply: 1.0.0 - oxc-parser@0.143.0: + oxc-parser@0.152.0: dependencies: - '@oxc-project/types': 0.143.0 + '@oxc-project/types': 0.152.0 optionalDependencies: - '@oxc-parser/binding-android-arm-eabi': 0.143.0 - '@oxc-parser/binding-android-arm64': 0.143.0 - '@oxc-parser/binding-darwin-arm64': 0.143.0 - '@oxc-parser/binding-darwin-x64': 0.143.0 - '@oxc-parser/binding-freebsd-x64': 0.143.0 - '@oxc-parser/binding-linux-arm-gnueabihf': 0.143.0 - '@oxc-parser/binding-linux-arm-musleabihf': 0.143.0 - '@oxc-parser/binding-linux-arm64-gnu': 0.143.0 - '@oxc-parser/binding-linux-arm64-musl': 0.143.0 - '@oxc-parser/binding-linux-ppc64-gnu': 0.143.0 - '@oxc-parser/binding-linux-riscv64-gnu': 0.143.0 - '@oxc-parser/binding-linux-riscv64-musl': 0.143.0 - '@oxc-parser/binding-linux-s390x-gnu': 0.143.0 - '@oxc-parser/binding-linux-x64-gnu': 0.143.0 - '@oxc-parser/binding-linux-x64-musl': 0.143.0 - '@oxc-parser/binding-openharmony-arm64': 0.143.0 - '@oxc-parser/binding-win32-arm64-msvc': 0.143.0 - '@oxc-parser/binding-win32-ia32-msvc': 0.143.0 - '@oxc-parser/binding-win32-x64-msvc': 0.143.0 + '@oxc-parser/binding-android-arm-eabi': 0.152.0 + '@oxc-parser/binding-android-arm64': 0.152.0 + '@oxc-parser/binding-darwin-arm64': 0.152.0 + '@oxc-parser/binding-darwin-x64': 0.152.0 + '@oxc-parser/binding-freebsd-x64': 0.152.0 + '@oxc-parser/binding-linux-arm-gnueabihf': 0.152.0 + '@oxc-parser/binding-linux-arm-musleabihf': 0.152.0 + '@oxc-parser/binding-linux-arm64-gnu': 0.152.0 + '@oxc-parser/binding-linux-arm64-musl': 0.152.0 + '@oxc-parser/binding-linux-ppc64-gnu': 0.152.0 + '@oxc-parser/binding-linux-riscv64-gnu': 0.152.0 + '@oxc-parser/binding-linux-riscv64-musl': 0.152.0 + '@oxc-parser/binding-linux-s390x-gnu': 0.152.0 + '@oxc-parser/binding-linux-x64-gnu': 0.152.0 + '@oxc-parser/binding-linux-x64-musl': 0.152.0 + '@oxc-parser/binding-openharmony-arm64': 0.152.0 + '@oxc-parser/binding-win32-arm64-msvc': 0.152.0 + '@oxc-parser/binding-win32-ia32-msvc': 0.152.0 + '@oxc-parser/binding-win32-x64-msvc': 0.152.0 p-limit@2.3.0: dependencies: @@ -7430,8 +8058,6 @@ snapshots: path-exists@4.0.0: {} - path-expression-matcher@1.5.0: {} - path-key@3.1.1: {} path-parse@1.0.7: {} @@ -7443,22 +8069,24 @@ snapshots: pathe@2.0.3: {} + pathval@2.0.1: {} + peberminta@0.9.0: {} perfect-debounce@2.1.0: {} - pg-cloudflare@1.4.0: + pg-cloudflare@1.4.1: optional: true - pg-connection-string@2.13.0: {} + pg-connection-string@2.14.1: {} pg-int8@1.0.1: {} - pg-pool@3.14.0(pg@8.21.0): + pg-pool@3.14.0(pg@8.23.1): dependencies: - pg: 8.21.0 + pg: 8.23.1 - pg-protocol@1.14.0: {} + pg-protocol@1.16.1: {} pg-types@2.2.0: dependencies: @@ -7468,15 +8096,15 @@ snapshots: postgres-date: 1.0.7 postgres-interval: 1.2.0 - pg@8.21.0: + pg@8.23.1: dependencies: - pg-connection-string: 2.13.0 - pg-pool: 3.14.0(pg@8.21.0) - pg-protocol: 1.14.0 + pg-connection-string: 2.14.1 + pg-pool: 3.14.0(pg@8.23.1) + pg-protocol: 1.16.1 pg-types: 2.2.0 pgpass: 1.0.5 optionalDependencies: - pg-cloudflare: 1.4.0 + pg-cloudflare: 1.4.1 pgpass@1.0.5: dependencies: @@ -7484,47 +8112,39 @@ snapshots: picocolors@1.1.1: {} - picomatch@2.3.1: {} + picomatch@2.3.2: {} - picomatch@4.0.3: {} + picomatch@4.0.7: {} - pingram@1.0.1: + pingram@1.0.36: dependencies: node-fetch: 2.7.0 transitivePeerDependencies: - encoding - pkg-types@2.3.1: + pkg-types@2.3.3: dependencies: - confbox: 0.2.4 - exsolve: 1.0.8 + confbox: 0.3.1 + exsolve: 1.1.3 pathe: 2.0.3 pngjs@5.0.0: {} possible-typed-array-names@1.1.0: {} - postal-mime@2.7.3: {} - - postcss-value-parser@4.2.0: {} - - postcss@8.4.31: - dependencies: - nanoid: 3.3.11 - picocolors: 1.1.1 - source-map-js: 1.2.1 + postal-mime@2.7.6: {} - postcss@8.4.49: + postcss@8.5.23: dependencies: - nanoid: 3.3.11 + nanoid: 3.3.19 picocolors: 1.1.1 - source-map-js: 1.2.1 + source-map-js: 1.2.2 - postcss@8.5.8: + postcss@8.5.28: dependencies: - nanoid: 3.3.11 + nanoid: 3.3.19 picocolors: 1.1.1 - source-map-js: 1.2.1 + source-map-js: 1.2.2 postgres-array@2.0.0: {} @@ -7540,44 +8160,36 @@ snapshots: postgres@3.4.7: {} - posthog-js@1.434.15(@types/react@19.2.14)(preact-render-to-string@6.5.11(preact@10.24.3))(react@19.2.4): + posthog-js@1.435.8(@types/react@19.3.0)(react@19.3.0): dependencies: - '@posthog/browser-common': 0.9.0 - '@posthog/core': 1.55.2 - '@posthog/types': 1.412.4 + '@posthog/browser-common': 0.9.1 + '@posthog/core': 1.55.3 + '@posthog/types': 1.413.0 core-js: 3.50.0 dompurify: 3.4.16 fflate: 0.4.9 - preact: 10.29.8(preact-render-to-string@6.5.11(preact@10.24.3)) + preact: 10.29.8 query-selector-shadow-dom: 1.0.1 web-vitals: 6.2.2 web-vitals-soft-navs: web-vitals@6.2.1 optionalDependencies: - '@types/react': 19.2.14 - react: 19.2.4 + '@types/react': 19.3.0 + react: 19.3.0 transitivePeerDependencies: - preact-render-to-string - preact-render-to-string@6.5.11(preact@10.24.3): - dependencies: - preact: 10.24.3 - - preact@10.24.3: {} - - preact@10.29.8(preact-render-to-string@6.5.11(preact@10.24.3)): - optionalDependencies: - preact-render-to-string: 6.5.11(preact@10.24.3) + preact@10.29.8: {} prelude-ls@1.2.1: {} - prettier@3.8.1: {} + prettier@3.9.9: {} - prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3): + prisma@7.10.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@5.9.3): dependencies: - '@prisma/config': 7.8.0 - '@prisma/dev': 0.24.3(typescript@5.9.3) - '@prisma/engines': 7.8.0 - '@prisma/studio-core': 0.27.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@prisma/config': 7.10.0 + '@prisma/dev': 0.24.17(typescript@5.9.3) + '@prisma/engines': 7.10.0 + '@prisma/studio-core': 0.33.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) mysql2: 3.15.3 postgres: 3.4.7 optionalDependencies: @@ -7603,7 +8215,7 @@ snapshots: retry: 0.12.0 signal-exit: 3.0.7 - proxy-from-env@1.1.0: {} + proxy-from-env@2.1.0: {} punycode@2.3.1: {} @@ -7619,42 +8231,42 @@ snapshots: queue-microtask@1.2.3: {} - rc9@3.0.1: + rc9@3.1.0: dependencies: defu: 6.1.7 destr: 2.0.5 - react-apexcharts@2.1.0(apexcharts@5.10.3)(react@19.2.4): + react-apexcharts@2.1.1(apexcharts@7.8.0)(react@19.3.0): dependencies: - apexcharts: 5.10.3 + apexcharts: 7.8.0 prop-types: 15.8.1 - react: 19.2.4 + react: 19.3.0 - react-dom@19.2.4(react@19.2.4): + react-dom@19.3.0(react@19.3.0): dependencies: - react: 19.2.4 - scheduler: 0.27.0 + react: 19.3.0 + scheduler: 0.28.0 react-is@16.13.1: {} - react@19.2.4: {} + react@19.3.0: {} - readdirp@5.0.0: {} + readdirp@5.1.1: {} reflect.getprototypeof@1.0.10: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 define-properties: 1.2.1 - es-abstract: 1.24.1 + es-abstract: 1.24.2 es-errors: 1.3.0 - es-object-atoms: 1.1.1 + es-object-atoms: 1.1.2 get-intrinsic: 1.3.0 get-proto: 1.0.1 which-builtin-type: 1.2.1 regexp.prototype.flags@1.5.4: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 define-properties: 1.2.1 es-errors: 1.3.0 get-proto: 1.0.1 @@ -7669,66 +8281,64 @@ snapshots: require-main-filename@2.0.0: {} - resend@6.9.3(@react-email/render@2.0.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)): + resend@6.32.0(@react-email/render@2.1.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0)): dependencies: - postal-mime: 2.7.3 - svix: 1.84.1 + postal-mime: 2.7.6 + standardwebhooks: 1.1.1 optionalDependencies: - '@react-email/render': 2.0.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-email/render': 2.1.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) resolve-from@4.0.0: {} resolve-pkg-maps@1.0.0: {} - resolve@1.22.11: - dependencies: - is-core-module: 2.16.1 - path-parse: 1.0.7 - supports-preserve-symlinks-flag: 1.0.0 - - resolve@2.0.0-next.6: + resolve@2.0.0-next.7: dependencies: es-errors: 1.3.0 - is-core-module: 2.16.1 - node-exports-info: 1.6.0 + is-core-module: 2.17.0 + node-exports-info: 1.6.2 object-keys: 1.1.1 path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 + ret@0.5.0: {} + retry@0.12.0: {} reusify@1.1.0: {} - rollup@4.63.5: + robust-predicates@3.0.3: {} + + rollup@4.64.0: dependencies: '@types/estree': 1.0.9 optionalDependencies: '@napi-rs/lzma-linux-x64-gnu': 1.5.1 - '@rollup/rollup-android-arm-eabi': 4.63.5 - '@rollup/rollup-android-arm64': 4.63.5 - '@rollup/rollup-darwin-arm64': 4.63.5 - '@rollup/rollup-darwin-x64': 4.63.5 - '@rollup/rollup-freebsd-arm64': 4.63.5 - '@rollup/rollup-freebsd-x64': 4.63.5 - '@rollup/rollup-linux-arm-gnueabihf': 4.63.5 - '@rollup/rollup-linux-arm-musleabihf': 4.63.5 - '@rollup/rollup-linux-arm64-gnu': 4.63.5 - '@rollup/rollup-linux-arm64-musl': 4.63.5 - '@rollup/rollup-linux-loong64-gnu': 4.63.5 - '@rollup/rollup-linux-loong64-musl': 4.63.5 - '@rollup/rollup-linux-ppc64-gnu': 4.63.5 - '@rollup/rollup-linux-ppc64-musl': 4.63.5 - '@rollup/rollup-linux-riscv64-gnu': 4.63.5 - '@rollup/rollup-linux-riscv64-musl': 4.63.5 - '@rollup/rollup-linux-s390x-gnu': 4.63.5 - '@rollup/rollup-linux-x64-gnu': 4.63.5 - '@rollup/rollup-linux-x64-musl': 4.63.5 - '@rollup/rollup-openbsd-x64': 4.63.5 - '@rollup/rollup-openharmony-arm64': 4.63.5 - '@rollup/rollup-win32-arm64-msvc': 4.63.5 - '@rollup/rollup-win32-ia32-msvc': 4.63.5 - '@rollup/rollup-win32-x64-gnu': 4.63.5 - '@rollup/rollup-win32-x64-msvc': 4.63.5 + '@rollup/rollup-android-arm-eabi': 4.64.0 + '@rollup/rollup-android-arm64': 4.64.0 + '@rollup/rollup-darwin-arm64': 4.64.0 + '@rollup/rollup-darwin-x64': 4.64.0 + '@rollup/rollup-freebsd-arm64': 4.64.0 + '@rollup/rollup-freebsd-x64': 4.64.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.64.0 + '@rollup/rollup-linux-arm-musleabihf': 4.64.0 + '@rollup/rollup-linux-arm64-gnu': 4.64.0 + '@rollup/rollup-linux-arm64-musl': 4.64.0 + '@rollup/rollup-linux-loong64-gnu': 4.64.0 + '@rollup/rollup-linux-loong64-musl': 4.64.0 + '@rollup/rollup-linux-ppc64-gnu': 4.64.0 + '@rollup/rollup-linux-ppc64-musl': 4.64.0 + '@rollup/rollup-linux-riscv64-gnu': 4.64.0 + '@rollup/rollup-linux-riscv64-musl': 4.64.0 + '@rollup/rollup-linux-s390x-gnu': 4.64.0 + '@rollup/rollup-linux-x64-gnu': 4.64.0 + '@rollup/rollup-linux-x64-musl': 4.64.0 + '@rollup/rollup-openbsd-x64': 4.64.0 + '@rollup/rollup-openharmony-arm64': 4.64.0 + '@rollup/rollup-win32-arm64-msvc': 4.64.0 + '@rollup/rollup-win32-ia32-msvc': 4.64.0 + '@rollup/rollup-win32-x64-gnu': 4.64.0 + '@rollup/rollup-win32-x64-msvc': 4.64.0 fsevents: 2.3.3 rou3@0.7.12: {} @@ -7737,9 +8347,9 @@ snapshots: dependencies: queue-microtask: 1.2.3 - safe-array-concat@1.1.3: + safe-array-concat@1.1.4: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 call-bound: 1.0.4 get-intrinsic: 1.3.0 has-symbols: 1.1.0 @@ -7756,9 +8366,13 @@ snapshots: es-errors: 1.3.0 is-regex: 1.2.1 + safe-regex2@5.1.1: + dependencies: + ret: 0.5.0 + safer-buffer@2.1.2: {} - scheduler@0.27.0: {} + scheduler@0.28.0: {} selderee@0.11.0: dependencies: @@ -7766,15 +8380,15 @@ snapshots: semver@6.3.1: {} - semver@7.7.4: {} + semver@7.8.5: {} - sentry@0.44.1: {} + sentry@0.45.0: {} seq-queue@0.0.5: {} set-blocking@2.0.0: {} - set-cookie-parser@3.1.0: {} + set-cookie-parser@3.1.2: {} set-function-length@1.2.2: dependencies: @@ -7796,40 +8410,40 @@ snapshots: dependencies: dunder-proto: 1.0.1 es-errors: 1.3.0 - es-object-atoms: 1.1.1 - - shallowequal@1.1.0: {} + es-object-atoms: 1.1.2 - sharp@0.34.5: + sharp@0.35.5(@types/node@25.9.9): dependencies: '@img/colour': 1.1.0 detect-libc: 2.1.2 - semver: 7.7.4 + semver: 7.8.5 optionalDependencies: - '@img/sharp-darwin-arm64': 0.34.5 - '@img/sharp-darwin-x64': 0.34.5 - '@img/sharp-libvips-darwin-arm64': 1.2.4 - '@img/sharp-libvips-darwin-x64': 1.2.4 - '@img/sharp-libvips-linux-arm': 1.2.4 - '@img/sharp-libvips-linux-arm64': 1.2.4 - '@img/sharp-libvips-linux-ppc64': 1.2.4 - '@img/sharp-libvips-linux-riscv64': 1.2.4 - '@img/sharp-libvips-linux-s390x': 1.2.4 - '@img/sharp-libvips-linux-x64': 1.2.4 - '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 - '@img/sharp-libvips-linuxmusl-x64': 1.2.4 - '@img/sharp-linux-arm': 0.34.5 - '@img/sharp-linux-arm64': 0.34.5 - '@img/sharp-linux-ppc64': 0.34.5 - '@img/sharp-linux-riscv64': 0.34.5 - '@img/sharp-linux-s390x': 0.34.5 - '@img/sharp-linux-x64': 0.34.5 - '@img/sharp-linuxmusl-arm64': 0.34.5 - '@img/sharp-linuxmusl-x64': 0.34.5 - '@img/sharp-wasm32': 0.34.5 - '@img/sharp-win32-arm64': 0.34.5 - '@img/sharp-win32-ia32': 0.34.5 - '@img/sharp-win32-x64': 0.34.5 + '@img/sharp-darwin-arm64': 0.35.5 + '@img/sharp-darwin-x64': 0.35.5 + '@img/sharp-freebsd-wasm32': 0.35.5 + '@img/sharp-libvips-darwin-arm64': 1.3.4 + '@img/sharp-libvips-darwin-x64': 1.3.4 + '@img/sharp-libvips-linux-arm': 1.3.4 + '@img/sharp-libvips-linux-arm64': 1.3.4 + '@img/sharp-libvips-linux-ppc64': 1.3.4 + '@img/sharp-libvips-linux-riscv64': 1.3.4 + '@img/sharp-libvips-linux-s390x': 1.3.4 + '@img/sharp-libvips-linux-x64': 1.3.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.4 + '@img/sharp-libvips-linuxmusl-x64': 1.3.4 + '@img/sharp-linux-arm': 0.35.5 + '@img/sharp-linux-arm64': 0.35.5 + '@img/sharp-linux-ppc64': 0.35.5 + '@img/sharp-linux-riscv64': 0.35.5 + '@img/sharp-linux-s390x': 0.35.5 + '@img/sharp-linux-x64': 0.35.5 + '@img/sharp-linuxmusl-arm64': 0.35.5 + '@img/sharp-linuxmusl-x64': 0.35.5 + '@img/sharp-webcontainers-wasm32': 0.35.5 + '@img/sharp-win32-arm64': 0.35.5 + '@img/sharp-win32-ia32': 0.35.5 + '@img/sharp-win32-x64': 0.35.5 + '@types/node': 25.9.9 optional: true shebang-command@2.0.0: @@ -7838,7 +8452,7 @@ snapshots: shebang-regex@3.0.0: {} - side-channel-list@1.0.0: + side-channel-list@1.0.1: dependencies: es-errors: 1.3.0 object-inspect: 1.13.4 @@ -7858,19 +8472,21 @@ snapshots: object-inspect: 1.13.4 side-channel-map: 1.0.1 - side-channel@1.1.0: + side-channel@1.1.1: dependencies: es-errors: 1.3.0 object-inspect: 1.13.4 - side-channel-list: 1.0.0 + side-channel-list: 1.0.1 side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} + signal-exit@3.0.7: {} signal-exit@4.1.0: {} - source-map-js@1.2.1: {} + source-map-js@1.2.2: {} split2@4.2.0: {} @@ -7878,11 +8494,13 @@ snapshots: stable-hash@0.0.5: {} + stackback@0.0.2: {} + stacktrace-parser@0.1.11: dependencies: type-fest: 0.7.1 - standardwebhooks@1.0.0: + standardwebhooks@1.1.1: dependencies: '@stablelib/base64': 1.0.1 fast-sha256: 1.3.0 @@ -7902,53 +8520,54 @@ snapshots: string.prototype.includes@2.0.1: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 define-properties: 1.2.1 - es-abstract: 1.24.1 + es-abstract: 1.24.2 - string.prototype.matchall@4.0.12: + string.prototype.matchall@4.1.0: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 call-bound: 1.0.4 define-properties: 1.2.1 - es-abstract: 1.24.1 + es-abstract: 1.24.2 es-errors: 1.3.0 - es-object-atoms: 1.1.1 + es-object-atoms: 1.1.2 get-intrinsic: 1.3.0 gopd: 1.2.0 has-symbols: 1.1.0 internal-slot: 1.1.0 regexp.prototype.flags: 1.5.4 set-function-name: 2.0.2 - side-channel: 1.1.0 + side-channel: 1.1.1 string.prototype.repeat@1.0.0: dependencies: define-properties: 1.2.1 - es-abstract: 1.24.1 + es-abstract: 1.24.2 - string.prototype.trim@1.2.10: + string.prototype.trim@1.2.11: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 call-bound: 1.0.4 define-data-property: 1.1.4 define-properties: 1.2.1 - es-abstract: 1.24.1 - es-object-atoms: 1.1.1 + es-abstract: 1.24.2 + es-object-atoms: 1.1.2 has-property-descriptors: 1.0.2 + safe-regex-test: 1.1.0 - string.prototype.trimend@1.0.9: + string.prototype.trimend@1.0.10: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 call-bound: 1.0.4 define-properties: 1.2.1 - es-object-atoms: 1.1.1 + es-object-atoms: 1.1.2 string.prototype.trimstart@1.0.8: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 define-properties: 1.2.1 - es-object-atoms: 1.1.1 + es-object-atoms: 1.1.2 strip-ansi@6.0.1: dependencies: @@ -7958,31 +8577,25 @@ snapshots: strip-json-comments@3.1.1: {} - strnum@2.4.0: + strip-literal@3.1.0: dependencies: - anynum: 1.0.0 + js-tokens: 9.0.1 - styled-components@6.3.11(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + styled-components@6.5.3(react-dom@19.3.0(react@19.3.0))(react@19.3.0): dependencies: '@emotion/is-prop-valid': 1.4.0 - '@emotion/unitless': 0.10.0 - '@types/stylis': 4.2.7 - css-to-react-native: 3.2.0 csstype: 3.2.3 - postcss: 8.4.49 - react: 19.2.4 - shallowequal: 1.1.0 + react: 19.3.0 stylis: 4.3.6 - tslib: 2.8.1 optionalDependencies: - react-dom: 19.2.4(react@19.2.4) + react-dom: 19.3.0(react@19.3.0) - styled-jsx@5.1.6(@babel/core@7.29.0)(react@19.2.4): + styled-jsx@5.1.6(@babel/core@7.29.7)(react@19.3.0): dependencies: client-only: 0.0.1 - react: 19.2.4 + react: 19.3.0 optionalDependencies: - '@babel/core': 7.29.0(supports-color@8.1.1) + '@babel/core': 7.29.7 stylis@4.3.6: {} @@ -7996,19 +8609,24 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} - svix@1.84.1: - dependencies: - standardwebhooks: 1.0.0 - uuid: 10.0.0 + tailwindcss@4.3.3: {} + + tapable@2.3.3: {} - tailwindcss@4.2.1: {} + tinybench@2.9.0: {} - tapable@2.3.0: {} + tinyexec@0.3.2: {} - tinyglobby@0.2.15: + tinyglobby@0.2.17: dependencies: - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + + tinypool@1.1.1: {} + + tinyrainbow@2.0.0: {} + + tinyspy@4.0.6: {} to-regex-range@5.0.1: dependencies: @@ -8016,7 +8634,7 @@ snapshots: tr46@0.0.3: {} - ts-api-utils@2.4.0(typescript@5.9.3): + ts-api-utils@2.5.0(typescript@5.9.3): dependencies: typescript: 5.9.3 @@ -8029,9 +8647,9 @@ snapshots: tslib@2.8.1: {} - tsx@4.22.4: + tsx@4.23.15: dependencies: - esbuild: 0.28.1 + esbuild: 0.28.2 optionalDependencies: fsevents: 2.3.3 @@ -8049,38 +8667,37 @@ snapshots: typed-array-byte-length@1.0.3: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 for-each: 0.3.5 gopd: 1.2.0 has-proto: 1.2.0 is-typed-array: 1.1.15 - typed-array-byte-offset@1.0.4: + typed-array-byte-offset@1.0.5: dependencies: available-typed-arrays: 1.0.7 - call-bind: 1.0.8 + call-bind: 1.0.9 for-each: 0.3.5 gopd: 1.2.0 - has-proto: 1.2.0 is-typed-array: 1.1.15 reflect.getprototypeof: 1.0.10 - typed-array-length@1.0.7: + typed-array-length@1.0.8: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 for-each: 0.3.5 gopd: 1.2.0 is-typed-array: 1.1.15 possible-typed-array-names: 1.1.0 reflect.getprototypeof: 1.0.10 - typescript-eslint@8.56.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3): + typescript-eslint@8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/parser': 8.56.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3) - '@typescript-eslint/utils': 8.56.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - eslint: 9.39.4(jiti@2.6.1) + '@typescript-eslint/eslint-plugin': 8.71.0(@typescript-eslint/parser@8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/parser': 8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.71.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) + eslint: 9.39.5(jiti@2.7.0) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -8096,35 +8713,38 @@ snapshots: uncrypto@0.1.3: {} - undici-types@7.18.2: {} + undici-types@7.24.6: {} - unrs-resolver@1.11.1: + unrs-resolver@1.12.2: dependencies: napi-postinstall: 0.3.4 optionalDependencies: - '@unrs/resolver-binding-android-arm-eabi': 1.11.1 - '@unrs/resolver-binding-android-arm64': 1.11.1 - '@unrs/resolver-binding-darwin-arm64': 1.11.1 - '@unrs/resolver-binding-darwin-x64': 1.11.1 - '@unrs/resolver-binding-freebsd-x64': 1.11.1 - '@unrs/resolver-binding-linux-arm-gnueabihf': 1.11.1 - '@unrs/resolver-binding-linux-arm-musleabihf': 1.11.1 - '@unrs/resolver-binding-linux-arm64-gnu': 1.11.1 - '@unrs/resolver-binding-linux-arm64-musl': 1.11.1 - '@unrs/resolver-binding-linux-ppc64-gnu': 1.11.1 - '@unrs/resolver-binding-linux-riscv64-gnu': 1.11.1 - '@unrs/resolver-binding-linux-riscv64-musl': 1.11.1 - '@unrs/resolver-binding-linux-s390x-gnu': 1.11.1 - '@unrs/resolver-binding-linux-x64-gnu': 1.11.1 - '@unrs/resolver-binding-linux-x64-musl': 1.11.1 - '@unrs/resolver-binding-wasm32-wasi': 1.11.1 - '@unrs/resolver-binding-win32-arm64-msvc': 1.11.1 - '@unrs/resolver-binding-win32-ia32-msvc': 1.11.1 - '@unrs/resolver-binding-win32-x64-msvc': 1.11.1 - - update-browserslist-db@1.2.3(browserslist@4.28.1): - dependencies: - browserslist: 4.28.1 + '@unrs/resolver-binding-android-arm-eabi': 1.12.2 + '@unrs/resolver-binding-android-arm64': 1.12.2 + '@unrs/resolver-binding-darwin-arm64': 1.12.2 + '@unrs/resolver-binding-darwin-x64': 1.12.2 + '@unrs/resolver-binding-freebsd-x64': 1.12.2 + '@unrs/resolver-binding-linux-arm-gnueabihf': 1.12.2 + '@unrs/resolver-binding-linux-arm-musleabihf': 1.12.2 + '@unrs/resolver-binding-linux-arm64-gnu': 1.12.2 + '@unrs/resolver-binding-linux-arm64-musl': 1.12.2 + '@unrs/resolver-binding-linux-loong64-gnu': 1.12.2 + '@unrs/resolver-binding-linux-loong64-musl': 1.12.2 + '@unrs/resolver-binding-linux-ppc64-gnu': 1.12.2 + '@unrs/resolver-binding-linux-riscv64-gnu': 1.12.2 + '@unrs/resolver-binding-linux-riscv64-musl': 1.12.2 + '@unrs/resolver-binding-linux-s390x-gnu': 1.12.2 + '@unrs/resolver-binding-linux-x64-gnu': 1.12.2 + '@unrs/resolver-binding-linux-x64-musl': 1.12.2 + '@unrs/resolver-binding-openharmony-arm64': 1.12.2 + '@unrs/resolver-binding-wasm32-wasi': 1.12.2 + '@unrs/resolver-binding-win32-arm64-msvc': 1.12.2 + '@unrs/resolver-binding-win32-ia32-msvc': 1.12.2 + '@unrs/resolver-binding-win32-x64-msvc': 1.12.2 + + update-browserslist-db@1.3.3(browserslist@4.29.3): + dependencies: + browserslist: 4.29.3 escalade: 3.2.0 picocolors: 1.1.1 @@ -8132,12 +8752,87 @@ snapshots: dependencies: punycode: 2.3.1 - uuid@10.0.0: {} - - valibot@1.2.0(typescript@5.9.3): + valibot@1.4.2(typescript@5.9.3): optionalDependencies: typescript: 5.9.3 + vite-node@3.2.4(@types/node@25.9.9)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.15): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 7.3.6(@types/node@25.9.9)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.15) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vite@7.3.6(@types/node@25.9.9)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.15): + dependencies: + esbuild: 0.28.2 + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + postcss: 8.5.28 + rollup: 4.64.0 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 25.9.9 + fsevents: 2.3.3 + jiti: 2.7.0 + lightningcss: 1.32.0 + tsx: 4.23.15 + + vitest@3.2.7(@types/node@25.9.9)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.15): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.7 + '@vitest/mocker': 3.2.7(vite@7.3.6(@types/node@25.9.9)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.15)) + '@vitest/pretty-format': 3.2.7 + '@vitest/runner': 3.2.7 + '@vitest/snapshot': 3.2.7 + '@vitest/spy': 3.2.7 + '@vitest/utils': 3.2.7 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.4.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.7 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.17 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 7.3.6(@types/node@25.9.9)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.15) + vite-node: 3.2.4(@types/node@25.9.9)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.15) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 25.9.9 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + web-vitals@6.2.1: {} web-vitals@6.2.2: {} @@ -8160,7 +8855,7 @@ snapshots: which-builtin-type@1.2.1: dependencies: call-bound: 1.0.4 - function.prototype.name: 1.1.8 + function.prototype.name: 1.2.0 has-tostringtag: 1.0.2 is-async-function: 2.1.1 is-date-object: 1.1.0 @@ -8171,7 +8866,7 @@ snapshots: isarray: 2.0.5 which-boxed-primitive: 1.1.1 which-collection: 1.0.2 - which-typed-array: 1.1.20 + which-typed-array: 1.1.24 which-collection@1.0.2: dependencies: @@ -8182,10 +8877,10 @@ snapshots: which-module@2.0.1: {} - which-typed-array@1.1.20: + which-typed-array@1.1.24: dependencies: available-typed-arrays: 1.0.7 - call-bind: 1.0.8 + call-bind: 1.0.9 call-bound: 1.0.4 for-each: 0.3.5 get-proto: 1.0.1 @@ -8196,6 +8891,11 @@ snapshots: dependencies: isexe: 2.0.0 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + word-wrap@1.2.5: {} wrap-ansi@6.2.0: @@ -8204,10 +8904,6 @@ snapshots: string-width: 4.2.3 strip-ansi: 6.0.1 - ws@8.19.0: {} - - xml-naming@0.1.0: {} - xtend@4.0.2: {} y18n@4.0.3: {} @@ -8237,11 +8933,11 @@ snapshots: zeptomatch@2.1.0: dependencies: - grammex: 3.1.12 + grammex: 3.1.13 graphmatch: 1.1.1 - zod-validation-error@4.0.2(zod@4.4.3): + zod-validation-error@4.0.2(zod@4.6.5): dependencies: - zod: 4.4.3 + zod: 4.6.5 - zod@4.4.3: {} + zod@4.6.5: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..71d9d8b --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,10 @@ +packages: + - stackable-app + - packages/* + - services/* + +# pnpm 10 keeps build-script permissions here; Prisma needs them to fetch its engine. +onlyBuiltDependencies: + - prisma + - "@prisma/client" + - "@prisma/engines" diff --git a/services/academics/README.md b/services/academics/README.md new file mode 100644 index 0000000..9427b54 --- /dev/null +++ b/services/academics/README.md @@ -0,0 +1,53 @@ +# academics + +Homework, CATs and grading for Stackable. TypeScript, Hono via `@stackable/service-kit`, Postgres schema `academics`. +Default port 4002. Contract: `packages/contracts/openapi/academics.yaml`. + +This schema is new and separate from the legacy Prisma tables in the web app (`assessments` there stay owned by the web app for now). + +## Run + +``` +pnpm --filter @stackable/academics dev # in-memory store when DATABASE_URL is unset +pnpm --filter @stackable/academics typecheck +pnpm test # from the repo root +``` + +Environment: `SERVICE_TOKEN_SECRET` (32+ chars, required), `DATABASE_URL` (required in production), `PORT`. +Migrations (goose): `services/academics/migrations`, applied by the owner role. The app role must not bypass RLS. + +## Layout + +``` +src/app.ts wires routes under /v1 behind the service token +src/features/homework assignments, submissions, review, parent read model +src/features/cats assessments, attempts, marking, deadline worker +src/features/grading pure band maths, systems, results, release +src/store Repo contract, in-memory store, pg store, table registry +src/lib validation kit, auth/actor, outbox, clock +``` + +Every request runs `store.tx(schoolId, ...)`: one transaction with `app.school_id` set, so RLS applies. Repos also filter `school_id` themselves. + +## Rules worth knowing + +- Roles read from the token: `teacher`, `admin`, `student`/`pupil`, `parent`, `ai`/`service`. Admin also passes teacher checks. +- Answer keys, correct flags and calculation specs are never returned by student or parent endpoints. +- Homework: a submission after `closes_at` is accepted and flagged `late`. Submit is refused (409 `HOMEWORK_AI_GATE_LOCKED`) while any answer has `ai_gate_locked`, which the ai service sets via `POST /v1/answers/{id}/gate`. +- Parent read model: AI usage counts are passed by the caller (`aiHints`, `aiPractice` query params) and echoed. The gateway must verify the parent-child link before calling. +- Student list/open/start need `classId` from the gateway (class membership is not owned here). +- CATs: the deadline is `start + duration + accommodation` from the server clock. Autosave after the deadline is refused; `autoSubmitDue(store, now)` closes expired attempts. +- Marking: multiple choice, reorder and calculation are auto-marked, all or nothing per question. A paper with no teacher-marked answers is graded on submit if a grading system is active. +- Grading bands cover 0 to 100 on the finest decimal grid used (whole-number bands: 79 then 80 is contiguous). A percentage is rounded to that grid before lookup. +- Release: `POST /v1/assessments/{id}/release` needs the service-token claim `rel: true`. The web gateway sets it only after verifying the school security code and enforcing the 5-try lockout. A token without it can never release. It refuses while the window is open or any script is unmarked, then sets `released_at` on all results, marks the assessment RELEASED and writes outbox `results.released` in one transaction. +- Outbox: `assignment.published`, `results.released`. Relaying rows (`published_at is null`) is done elsewhere. + +## Operations + +- Run the deadline sweep from one instance only (`main.ts` runs it every 15 s). It reads school ids through the security-definer function `academics.due_attempt_school_ids`, the only cross-tenant read. +- Scan upload links return a placeholder key and `uploadUrl: null`; real signing lives in the documents service. + +## Not done here + +- Migrations were not applied to a database in this session; SQL is checked against the table registry by `src/store/pg.test.ts`. +- DB role grants and column grants for answer keys belong to the database work (D10). diff --git a/services/academics/migrations/00001_homework.sql b/services/academics/migrations/00001_homework.sql new file mode 100644 index 0000000..901437e --- /dev/null +++ b/services/academics/migrations/00001_homework.sql @@ -0,0 +1,154 @@ +-- +goose Up +create schema if not exists academics; + +create table academics.outbox ( + id uuid primary key default gen_random_uuid(), + school_id uuid not null, + topic text not null, + payload jsonb not null, + created_at timestamptz not null default now(), + published_at timestamptz +); +create index outbox_unpublished_idx on academics.outbox (created_at) where published_at is null; +create index outbox_school_idx on academics.outbox (school_id); + +create table academics.questions ( + id uuid primary key default gen_random_uuid(), + school_id uuid not null, + type text not null check (type in ('short_answer', 'multiple_choice', 'research')), + stem text not null, + marks double precision not null check (marks >= 0), + answer_key text, + created_by text not null, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); +create index questions_school_idx on academics.questions (school_id); + +create table academics.question_options ( + id uuid primary key default gen_random_uuid(), + school_id uuid not null, + question_id uuid not null references academics.questions (id) on delete cascade, + position integer not null, + label text not null, + is_correct boolean not null default false, + created_at timestamptz not null default now() +); +create index question_options_question_idx on academics.question_options (question_id); +create index question_options_school_idx on academics.question_options (school_id); + +create table academics.assignments ( + id uuid primary key default gen_random_uuid(), + school_id uuid not null, + class_id text not null, + subject_id text not null, + teacher_id text not null, + title text not null, + due_at timestamptz not null, + closes_at timestamptz not null, + status text not null default 'DRAFT' check (status in ('DRAFT', 'OPEN', 'CLOSED', 'ARCHIVED')), + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + check (closes_at >= due_at) +); +create index assignments_school_idx on academics.assignments (school_id); +create index assignments_class_status_idx on academics.assignments (class_id, status); +create index assignments_teacher_idx on academics.assignments (teacher_id); + +create table academics.assignment_items ( + id uuid primary key default gen_random_uuid(), + school_id uuid not null, + assignment_id uuid not null references academics.assignments (id) on delete cascade, + question_id uuid not null references academics.questions (id), + position integer not null, + marks double precision not null check (marks >= 0), + created_at timestamptz not null default now(), + unique (assignment_id, position) +); +create index assignment_items_question_idx on academics.assignment_items (question_id); +create index assignment_items_school_idx on academics.assignment_items (school_id); + +create table academics.assignment_submissions ( + id uuid primary key default gen_random_uuid(), + school_id uuid not null, + assignment_id uuid not null references academics.assignments (id) on delete cascade, + student_id text not null, + status text not null default 'IN_PROGRESS' + check (status in ('IN_PROGRESS', 'SUBMITTED', 'APPROVED', 'RETURNED', 'REJECTED', 'MISSED')), + submitted_at timestamptz, + late boolean not null default false, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + unique (assignment_id, student_id) +); +create index assignment_submissions_student_idx on academics.assignment_submissions (student_id); +create index assignment_submissions_school_idx on academics.assignment_submissions (school_id); + +create table academics.answers ( + id uuid primary key default gen_random_uuid(), + school_id uuid not null, + submission_id uuid not null references academics.assignment_submissions (id) on delete cascade, + item_id uuid not null references academics.assignment_items (id), + response_text text, + selected_option_id uuid references academics.question_options (id), + marks_awarded double precision, + ai_gate_locked boolean not null default false, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + unique (submission_id, item_id) +); +create index answers_item_idx on academics.answers (item_id); +create index answers_option_idx on academics.answers (selected_option_id); +create index answers_school_idx on academics.answers (school_id); + +create table academics.review_decisions ( + id uuid primary key default gen_random_uuid(), + school_id uuid not null, + submission_id uuid not null references academics.assignment_submissions (id) on delete cascade, + decision text not null check (decision in ('APPROVED', 'RETURNED', 'REJECTED')), + reason text, + reviewer_id text not null, + created_at timestamptz not null default now(), + check (decision = 'APPROVED' or (reason is not null and length(trim(reason)) > 0)) +); +create index review_decisions_submission_idx on academics.review_decisions (submission_id); +create index review_decisions_school_idx on academics.review_decisions (school_id); + +create table academics.parent_reviews ( + id uuid primary key default gen_random_uuid(), + school_id uuid not null, + submission_id uuid not null references academics.assignment_submissions (id) on delete cascade, + parent_id text not null, + acknowledged boolean not null default false, + comment text, + created_at timestamptz not null default now(), + unique (submission_id, parent_id) +); +create index parent_reviews_school_idx on academics.parent_reviews (school_id); + +-- +goose StatementBegin +do $$ +declare t text; +begin + foreach t in array array['outbox', 'questions', 'question_options', 'assignments', 'assignment_items', + 'assignment_submissions', 'answers', 'review_decisions', 'parent_reviews'] + loop + execute format('alter table academics.%I enable row level security', t); + execute format('alter table academics.%I force row level security', t); + execute format( + 'create policy tenant_isolation on academics.%I using (school_id = current_setting(''app.school_id'', true)::uuid) with check (school_id = current_setting(''app.school_id'', true)::uuid)', + t); + end loop; +end $$; +-- +goose StatementEnd + +-- +goose Down +drop table if exists academics.parent_reviews; +drop table if exists academics.review_decisions; +drop table if exists academics.answers; +drop table if exists academics.assignment_submissions; +drop table if exists academics.assignment_items; +drop table if exists academics.assignments; +drop table if exists academics.question_options; +drop table if exists academics.questions; +drop table if exists academics.outbox; diff --git a/services/academics/migrations/00002_cats.sql b/services/academics/migrations/00002_cats.sql new file mode 100644 index 0000000..cda737c --- /dev/null +++ b/services/academics/migrations/00002_cats.sql @@ -0,0 +1,139 @@ +-- +goose Up +create table academics.assessments ( + id uuid primary key default gen_random_uuid(), + school_id uuid not null, + title text not null, + type text not null check (type in ('cat', 'quiz')), -- exams are a separate future module + class_id text not null, + subject_id text, + teacher_id text not null, + duration_minutes integer not null check (duration_minutes > 0), + opens_at timestamptz not null, + closes_at timestamptz not null, + status text not null default 'DRAFT' + check (status in ('DRAFT', 'SCHEDULED', 'OPEN', 'CLOSED', 'MARKED', 'RELEASED')), + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + check (closes_at > opens_at) +); +create index assessments_school_idx on academics.assessments (school_id); +create index assessments_class_status_idx on academics.assessments (class_id, status); + +create table academics.assessment_questions ( + id uuid primary key default gen_random_uuid(), + school_id uuid not null, + assessment_id uuid not null references academics.assessments (id) on delete cascade, + position integer not null, + type text not null check (type in ('short_answer', 'multiple_choice', 'reorder', 'calculation')), + stem text not null, + marks double precision not null check (marks > 0), + choices jsonb, + answer_key jsonb, + created_at timestamptz not null default now(), + unique (assessment_id, position) +); +create index assessment_questions_school_idx on academics.assessment_questions (school_id); + +create table academics.calc_specs ( + id uuid primary key default gen_random_uuid(), + school_id uuid not null, + question_id uuid not null unique references academics.assessment_questions (id) on delete cascade, + expected_value double precision not null, + tolerance double precision not null check (tolerance >= 0), + created_at timestamptz not null default now() +); +create index calc_specs_school_idx on academics.calc_specs (school_id); + +create table academics.assessment_accommodations ( + id uuid primary key default gen_random_uuid(), + school_id uuid not null, + assessment_id uuid not null references academics.assessments (id) on delete cascade, + student_id text not null, + extra_minutes integer not null check (extra_minutes > 0), + created_at timestamptz not null default now(), + unique (assessment_id, student_id) +); +create index assessment_accommodations_school_idx on academics.assessment_accommodations (school_id); + +create table academics.attempts ( + id uuid primary key default gen_random_uuid(), + school_id uuid not null, + assessment_id uuid not null references academics.assessments (id) on delete cascade, + student_id text not null, + status text not null default 'NOT_STARTED' + check (status in ('NOT_STARTED', 'IN_PROGRESS', 'SUBMITTED', 'UNDER_MARKING', 'MARKED', 'AUTO_SUBMITTED')), + started_at timestamptz, + due_at timestamptz, -- start + duration + accommodation, from the server clock + submitted_at timestamptz, + extra_minutes integer not null default 0, + score double precision, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + unique (assessment_id, student_id) +); +create index attempts_student_idx on academics.attempts (student_id); +create index attempts_school_idx on academics.attempts (school_id); +create index attempts_due_idx on academics.attempts (due_at) where status = 'IN_PROGRESS'; + +create table academics.attempt_answers ( + id uuid primary key default gen_random_uuid(), + school_id uuid not null, + attempt_id uuid not null references academics.attempts (id) on delete cascade, + question_id uuid not null references academics.assessment_questions (id), + response jsonb, + marks_awarded double precision, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + unique (attempt_id, question_id) +); +create index attempt_answers_question_idx on academics.attempt_answers (question_id); +create index attempt_answers_school_idx on academics.attempt_answers (school_id); + +create table academics.paper_scans ( + id uuid primary key default gen_random_uuid(), + school_id uuid not null, + attempt_id uuid not null references academics.attempts (id) on delete cascade, + object_key text not null, + created_by text not null, + created_at timestamptz not null default now() +); +create index paper_scans_attempt_idx on academics.paper_scans (attempt_id); +create index paper_scans_school_idx on academics.paper_scans (school_id); + +-- +goose StatementBegin +-- Cross-tenant by design: returns ids only, so the deadline worker can find schools without bypassing RLS. +create function academics.due_attempt_school_ids(p_now timestamptz) +returns table (school_id uuid) +language sql +security definer +set search_path = academics, pg_temp +as $$ + select distinct a.school_id from academics.attempts a where a.status = 'IN_PROGRESS' and a.due_at < p_now; +$$; +-- +goose StatementEnd + +-- +goose StatementBegin +do $$ +declare t text; +begin + foreach t in array array['assessments', 'assessment_questions', 'calc_specs', 'assessment_accommodations', + 'attempts', 'attempt_answers', 'paper_scans'] + loop + execute format('alter table academics.%I enable row level security', t); + execute format('alter table academics.%I force row level security', t); + execute format( + 'create policy tenant_isolation on academics.%I using (school_id = current_setting(''app.school_id'', true)::uuid) with check (school_id = current_setting(''app.school_id'', true)::uuid)', + t); + end loop; +end $$; +-- +goose StatementEnd + +-- +goose Down +drop function if exists academics.due_attempt_school_ids(timestamptz); +drop table if exists academics.paper_scans; +drop table if exists academics.attempt_answers; +drop table if exists academics.attempts; +drop table if exists academics.assessment_accommodations; +drop table if exists academics.calc_specs; +drop table if exists academics.assessment_questions; +drop table if exists academics.assessments; diff --git a/services/academics/migrations/00003_grading.sql b/services/academics/migrations/00003_grading.sql new file mode 100644 index 0000000..b5dd431 --- /dev/null +++ b/services/academics/migrations/00003_grading.sql @@ -0,0 +1,84 @@ +-- +goose Up +create table academics.grading_systems ( + id uuid primary key default gen_random_uuid(), + school_id uuid not null, + name text not null, + effective_from timestamptz not null, + active boolean not null default false, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); +create index grading_systems_school_idx on academics.grading_systems (school_id); +create unique index grading_systems_one_active_idx on academics.grading_systems (school_id) where active; + +create table academics.grade_bands ( + id uuid primary key default gen_random_uuid(), + school_id uuid not null, + system_id uuid not null references academics.grading_systems (id) on delete cascade, + min_pct double precision not null check (min_pct >= 0), + max_pct double precision not null check (max_pct <= 100), + grade text not null, + points double precision not null, + remark text, + created_at timestamptz not null default now(), + check (min_pct <= max_pct) +); +create index grade_bands_system_idx on academics.grade_bands (system_id); +create index grade_bands_school_idx on academics.grade_bands (school_id); + +create table academics.pass_marks ( + id uuid primary key default gen_random_uuid(), + school_id uuid not null, + system_id uuid not null references academics.grading_systems (id) on delete cascade, + subject_id text, -- null is the default pass mark + pass_pct double precision not null check (pass_pct between 0 and 100), + created_at timestamptz not null default now() +); +create unique index pass_marks_default_idx on academics.pass_marks (system_id) where subject_id is null; +create unique index pass_marks_subject_idx on academics.pass_marks (system_id, subject_id) where subject_id is not null; +create index pass_marks_school_idx on academics.pass_marks (school_id); + +create table academics.assessment_results ( + id uuid primary key default gen_random_uuid(), + school_id uuid not null, + assessment_id uuid not null references academics.assessments (id) on delete cascade, + student_id text not null, + attempt_id uuid not null references academics.attempts (id), + system_id uuid not null references academics.grading_systems (id), -- old results keep their system + raw double precision not null check (raw >= 0), + total double precision not null check (total > 0), + pct double precision not null check (pct between 0 and 100), + grade text not null, + points double precision not null, + remark text, + is_pass boolean not null, + released_at timestamptz, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + unique (assessment_id, student_id) +); +create index assessment_results_attempt_idx on academics.assessment_results (attempt_id); +create index assessment_results_system_idx on academics.assessment_results (system_id); +create index assessment_results_student_idx on academics.assessment_results (student_id); +create index assessment_results_school_idx on academics.assessment_results (school_id); + +-- +goose StatementBegin +do $$ +declare t text; +begin + foreach t in array array['grading_systems', 'grade_bands', 'pass_marks', 'assessment_results'] + loop + execute format('alter table academics.%I enable row level security', t); + execute format('alter table academics.%I force row level security', t); + execute format( + 'create policy tenant_isolation on academics.%I using (school_id = current_setting(''app.school_id'', true)::uuid) with check (school_id = current_setting(''app.school_id'', true)::uuid)', + t); + end loop; +end $$; +-- +goose StatementEnd + +-- +goose Down +drop table if exists academics.assessment_results; +drop table if exists academics.pass_marks; +drop table if exists academics.grade_bands; +drop table if exists academics.grading_systems; diff --git a/services/academics/package.json b/services/academics/package.json new file mode 100644 index 0000000..6520dc7 --- /dev/null +++ b/services/academics/package.json @@ -0,0 +1,24 @@ +{ + "name": "@stackable/academics", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "tsx watch src/main.ts", + "start": "tsx src/main.ts", + "typecheck": "tsc --noEmit", + "test": "vitest run --root ../.." + }, + "dependencies": { + "@hono/node-server": "^1.19.0", + "@stackable/contracts": "workspace:*", + "@stackable/service-kit": "workspace:*", + "pg": "^8.16.0" + }, + "devDependencies": { + "@types/node": "^24.0.0", + "@types/pg": "^8.15.0", + "tsx": "^4.20.0", + "typescript": "^5.9.0" + } +} diff --git a/services/academics/src/app.ts b/services/academics/src/app.ts new file mode 100644 index 0000000..31ada31 --- /dev/null +++ b/services/academics/src/app.ts @@ -0,0 +1,24 @@ +import type { Pool } from "pg"; +import { createService, requireServiceToken, type ReadyCheck } from "@stackable/service-kit"; +import { catRoutes } from "./features/cats/routes"; +import { gradingRoutes } from "./features/grading/routes"; +import { homeworkRoutes } from "./features/homework/routes"; +import type { Clock } from "./lib/clock"; +import type { Store } from "./store/tables"; + +export const SERVICE_NAME = "academics"; + +/** Builds the academics app; pool is optional so dev and tests can run on the in-memory store. */ +export function buildApp(store: Store, pool: Pool | undefined, version: string, now: Clock = () => new Date()) { + const dbCheck: ReadyCheck = pool + ? { name: "database", run: () => pool.query("select 1") } + : { name: "database", required: false, run: () => Promise.reject(new Error("DATABASE_URL not set")) }; + + const app = createService({ name: SERVICE_NAME, version, checks: [dbCheck] }); + app.use("/v1/*", requireServiceToken(SERVICE_NAME)); + + homeworkRoutes(app, store, now); + catRoutes(app, store, now); + gradingRoutes(app, store, now); + return app; +} diff --git a/services/academics/src/cat-fixtures.ts b/services/academics/src/cat-fixtures.ts new file mode 100644 index 0000000..cc1e399 --- /dev/null +++ b/services/academics/src/cat-fixtures.ts @@ -0,0 +1,42 @@ +import { CLASS_ID, SCHOOL_A, SUBJECT_ID, studentToken, teacherToken, type Harness } from "./test-support"; + +export const OPENS = "2026-03-01T09:00:00.000Z"; +export const CLOSES = "2026-03-01T12:00:00.000Z"; +export const DURATION_MINUTES = 30; + +const AUTO_QUESTIONS = [ + { type: "multiple_choice", stem: "2+2?", marks: 2, choices: [{ label: "4", isCorrect: true }, { label: "5", isCorrect: false }] }, + { type: "reorder", stem: "Order the planets", marks: 3, items: ["Mercury", "Venus", "Earth"] }, + { type: "calculation", stem: "Area of 3 by 4.1?", marks: 5, expectedValue: 12.3, tolerance: 0.05 }, +]; +const SHORT_QUESTION = { type: "short_answer", stem: "Define a prime", marks: 10, modelAnswer: "SECRET-MODEL" }; + +/** Creates and schedules an assessment; withShort adds one teacher-marked question. */ +export async function createScheduled(h: Harness, withShort = false): Promise { + const created = await h.call("POST", "/v1/assessments", teacherToken(), { + title: "CAT 1", type: "cat", classId: CLASS_ID, subjectId: SUBJECT_ID, durationMinutes: DURATION_MINUTES, + opensAt: OPENS, closesAt: CLOSES, questions: withShort ? [...AUTO_QUESTIONS, SHORT_QUESTION] : AUTO_QUESTIONS, + }); + if (created.status !== 201) throw new Error(`create failed: ${JSON.stringify(created.body)}`); + const scheduled = await h.call("POST", `/v1/assessments/${created.body.id}/schedule`, teacherToken()); + if (scheduled.status !== 200) throw new Error(`schedule failed: ${JSON.stringify(scheduled.body)}`); + return created.body.id; +} + +export const start = (h: Harness, assessmentId: string, student: string, classId = CLASS_ID) => + h.call("POST", `/v1/assessments/${assessmentId}/attempts`, studentToken(student), { classId }); + +/** Saves a correct answer to every auto-markable question, reading the keys straight from the store. */ +export async function answerCorrectly(h: Harness, assessmentId: string, attemptId: string, student: string): Promise { + const questions = await h.store.tx(SCHOOL_A, (db) => db.assessmentQuestions.find({ assessmentId })); + const specs = await h.store.tx(SCHOOL_A, (db) => db.calcSpecs.find({})); + for (const q of questions) { + let response: unknown; + if (q.type === "multiple_choice") response = q.answerKey; + else if (q.type === "reorder") response = q.answerKey; + else if (q.type === "calculation") response = String(specs.find((s) => s.questionId === q.id)!.expectedValue); + else response = "A number with exactly two divisors"; + const res = await h.call("PUT", `/v1/attempts/${attemptId}/answers/${q.id}`, studentToken(student), { response }); + if (res.status !== 200) throw new Error(`autosave failed: ${JSON.stringify(res.body)}`); + } +} diff --git a/services/academics/src/features/cats/assessments.ts b/services/academics/src/features/cats/assessments.ts new file mode 100644 index 0000000..75a42ae --- /dev/null +++ b/services/academics/src/features/cats/assessments.ts @@ -0,0 +1,84 @@ +import { errors } from "@stackable/service-kit"; +import { STUDENT_ROLES, TEACHER_ROLES, domainError, isAdmin, requireRole, type Actor } from "../../lib/auth"; +import type { Db } from "../../store/tables"; +import type { CreateAssessmentInput } from "./schema"; +import type { Assessment, AssessmentQuestion, Choice } from "./types"; + +type QuestionInput = CreateAssessmentInput["questions"][number]; + +// Builds the stored choices and key for one question, rejecting inputs that cannot be marked. +function buildQuestionParts(q: QuestionInput): { choices: Choice[] | null; answerKey: unknown } { + const fail = (message: string) => errors.validation(message, { stem: q.stem }); + if (q.type === "multiple_choice") { + const options = q.choices ?? []; + if (options.filter((o) => o.isCorrect).length !== 1) throw fail("A multiple choice question needs exactly one correct choice"); + const choices = options.map((o) => ({ id: crypto.randomUUID(), label: o.label })); + return { choices, answerKey: choices[options.findIndex((o) => o.isCorrect)].id }; + } + if (q.type === "reorder") { + if (!q.items) throw fail("A reorder question needs its items in the correct order"); + const choices = q.items.map((label) => ({ id: crypto.randomUUID(), label })); + return { choices, answerKey: choices.map((c) => c.id) }; + } + if (q.type === "calculation") { + if (q.expectedValue === undefined || q.tolerance === undefined) throw fail("A calculation question needs expectedValue and tolerance"); + return { choices: null, answerKey: null }; + } + return { choices: null, answerKey: q.modelAnswer ?? null }; +} + +/** Creates a DRAFT cat or quiz with its questions; keys are stored but never returned to students. */ +export async function createAssessment(db: Db, actor: Actor, input: CreateAssessmentInput): Promise { + requireRole(actor, TEACHER_ROLES); + if (input.closesAt <= input.opensAt) throw errors.validation("closesAt must be after opensAt"); + const assessment = await db.assessments.insert({ + title: input.title, type: input.type, classId: input.classId, subjectId: input.subjectId ?? null, teacherId: actor.id, + durationMinutes: input.durationMinutes, opensAt: input.opensAt, closesAt: input.closesAt, status: "DRAFT", + }); + for (const [index, q] of input.questions.entries()) { + const { choices, answerKey } = buildQuestionParts(q); + const stored = await db.assessmentQuestions.insert({ assessmentId: assessment.id, position: index + 1, type: q.type, stem: q.stem, marks: q.marks, choices, answerKey }); + if (q.type === "calculation") await db.calcSpecs.insert({ questionId: stored.id, expectedValue: q.expectedValue!, tolerance: q.tolerance! }); + } + return assessment; +} + +async function ownedAssessment(db: Db, actor: Actor, id: string): Promise { + const assessment = await db.assessments.get(id); + if (!assessment) throw errors.notFound("Assessment not found"); + if (assessment.teacherId !== actor.id && !isAdmin(actor)) throw errors.forbidden("Not your assessment"); + return assessment; +} + +/** Moves a DRAFT assessment to SCHEDULED so students of its class can start inside the window. */ +export async function scheduleAssessment(db: Db, actor: Actor, id: string): Promise { + requireRole(actor, TEACHER_ROLES); + const assessment = await ownedAssessment(db, actor, id); + if (assessment.status !== "DRAFT") throw domainError(409, "ASSESSMENT_NOT_DRAFT", "Only a draft can be scheduled"); + const windowMinutes = (Date.parse(assessment.closesAt) - Date.parse(assessment.opensAt)) / 60_000; + if (assessment.durationMinutes > windowMinutes) throw errors.validation("Duration is longer than the window"); + return (await db.assessments.update(id, { status: "SCHEDULED" }))!; +} + +export async function listAssessments(db: Db, actor: Actor, classId?: string) { + if ((STUDENT_ROLES as readonly string[]).includes(actor.role)) { + if (!classId) throw errors.validation("classId is required", { fields: [{ path: "classId", message: "is required" }] }); + const open = await db.assessments.find({ classId, status: ["SCHEDULED", "OPEN"] }); + return open.map(({ id, title, type, durationMinutes, opensAt, closesAt, status }) => ({ id, title, type, classId, durationMinutes, opensAt, closesAt, status })); + } + requireRole(actor, TEACHER_ROLES); + return db.assessments.find({ teacherId: isAdmin(actor) ? undefined : actor.id, classId }); +} + +/** Gives one student extra minutes; the attempt deadline adds them when the student starts. */ +export async function setAccommodation(db: Db, actor: Actor, id: string, studentId: string, extraMinutes: number) { + requireRole(actor, TEACHER_ROLES); + await ownedAssessment(db, actor, id); + const existing = (await db.accommodations.find({ assessmentId: id, studentId }, { limit: 1 }))[0]; + if (existing) return (await db.accommodations.update(existing.id, { extraMinutes }))!; + return db.accommodations.insert({ assessmentId: id, studentId, extraMinutes }); +} + +export async function questionsOf(db: Db, assessmentId: string): Promise { + return db.assessmentQuestions.find({ assessmentId }, { orderBy: "position", limit: 200 }); +} diff --git a/services/academics/src/features/cats/attempts.ts b/services/academics/src/features/cats/attempts.ts new file mode 100644 index 0000000..08964d6 --- /dev/null +++ b/services/academics/src/features/cats/attempts.ts @@ -0,0 +1,152 @@ +import { errors } from "@stackable/service-kit"; +import { STUDENT_ROLES, TEACHER_ROLES, domainError, isAdmin, requireRole, type Actor } from "../../lib/auth"; +import type { Db } from "../../store/tables"; +import { getActiveSystem, recordResult } from "../grading/service"; +import { autoMark, responseProblem, studentQuestion } from "./marking"; +import { questionsOf } from "./assessments"; +import type { Assessment, Attempt, AttemptStatus } from "./types"; + +const MS_PER_MINUTE = 60_000; +const MARKABLE: AttemptStatus[] = ["SUBMITTED", "AUTO_SUBMITTED", "UNDER_MARKING", "MARKED"]; + +async function ownAttempt(db: Db, actor: Actor, attemptId: string): Promise { + requireRole(actor, STUDENT_ROLES); + const attempt = await db.attempts.get(attemptId); + // Another student's attempt looks the same as a missing one. + if (!attempt || attempt.studentId !== actor.id) throw errors.notFound("Attempt not found"); + return attempt; +} + +/** Starts (or resumes) an attempt; the deadline comes from the server clock, never from the client. */ +export async function startAttempt(db: Db, actor: Actor, assessmentId: string, classId: string, now: Date) { + requireRole(actor, STUDENT_ROLES); + const assessment = await db.assessments.get(assessmentId); + // A wrong class gets the same answer as a missing assessment. + if (!assessment || assessment.classId !== classId || !["SCHEDULED", "OPEN"].includes(assessment.status)) throw errors.notFound("Assessment not found"); + const iso = now.toISOString(); + if (iso < assessment.opensAt) throw domainError(409, "ASSESSMENT_NOT_OPEN", "The assessment has not opened yet", { opensAt: assessment.opensAt }); + if (iso >= assessment.closesAt) throw domainError(409, "ASSESSMENT_CLOSED", "The assessment window has closed"); + + const questions = (await questionsOf(db, assessmentId)).map(studentQuestion); + const existing = (await db.attempts.find({ assessmentId, studentId: actor.id }, { limit: 1 }))[0]; + if (existing) { + if (existing.status !== "IN_PROGRESS") throw domainError(409, "ATTEMPT_ALREADY_SUBMITTED", "You have already submitted this assessment"); + if (iso > existing.dueAt!) throw domainError(409, "ATTEMPT_DEADLINE_PASSED", "Your time is up"); + const answers = await db.attemptAnswers.find({ attemptId: existing.id }); + return { attempt: existing, serverNow: iso, questions, answers: answers.map(({ questionId, response }) => ({ questionId, response })) }; + } + + const accommodation = (await db.accommodations.find({ assessmentId, studentId: actor.id }, { limit: 1 }))[0]; + const extraMinutes = accommodation?.extraMinutes ?? 0; + const dueAt = new Date(now.getTime() + (assessment.durationMinutes + extraMinutes) * MS_PER_MINUTE).toISOString(); + const attempt = await db.attempts.insert({ assessmentId, studentId: actor.id, status: "IN_PROGRESS", startedAt: iso, dueAt, submittedAt: null, extraMinutes, score: null }); + if (assessment.status === "SCHEDULED") await db.assessments.update(assessmentId, { status: "OPEN" }); + return { attempt, serverNow: iso, questions, answers: [] }; +} + +/** Autosaves one answer; refused once the server deadline has passed (the worker auto-submits the attempt). */ +export async function saveAttemptAnswer(db: Db, actor: Actor, attemptId: string, questionId: string, response: unknown, now: Date) { + const attempt = await ownAttempt(db, actor, attemptId); + if (attempt.status !== "IN_PROGRESS") throw domainError(409, "ATTEMPT_NOT_IN_PROGRESS", "This attempt is no longer open"); + if (now.toISOString() > attempt.dueAt!) throw domainError(409, "ATTEMPT_DEADLINE_PASSED", "Your time is up"); + const question = (await db.assessmentQuestions.get(questionId)); + if (!question || question.assessmentId !== attempt.assessmentId) throw errors.notFound("Question not found"); + const problem = responseProblem(question, response); + if (problem) throw errors.validation(problem, { fields: [{ path: "response", message: problem }] }); + + const existing = (await db.attemptAnswers.find({ attemptId, questionId }, { limit: 1 }))[0]; + if (existing) await db.attemptAnswers.update(existing.id, { response }); + else await db.attemptAnswers.insert({ attemptId, questionId, response, marksAwarded: null }); + return { savedAt: now.toISOString() }; +} + +// Sets auto-markable marks, then finishes marking only when nothing needs a teacher and a grading system exists. +async function finaliseSubmission(db: Db, attempt: Attempt, status: "SUBMITTED" | "AUTO_SUBMITTED", submittedAt: string, now: Date): Promise { + const questions = await questionsOf(db, attempt.assessmentId); + const specs = await db.calcSpecs.find({ questionId: questions.map((q) => q.id) }); + const answers = await db.attemptAnswers.find({ attemptId: attempt.id }); + let needsTeacher = false; + for (const answer of answers) { + const q = questions.find((x) => x.id === answer.questionId)!; + const marks = autoMark(q, specs.find((s) => s.questionId === q.id), answer.response); + if (marks === null) needsTeacher = true; + else await db.attemptAnswers.update(answer.id, { marksAwarded: marks }); + } + const submitted = (await db.attempts.update(attempt.id, { status, submittedAt }))!; + if (needsTeacher || !(await getActiveSystem(db))) return submitted; + return completeMarking(db, submitted, now); +} + +/** Submits an attempt; after the deadline it is recorded as AUTO_SUBMITTED with the answers saved in time. */ +export async function submitAttempt(db: Db, actor: Actor, attemptId: string, now: Date): Promise { + const attempt = await ownAttempt(db, actor, attemptId); + if (attempt.status !== "IN_PROGRESS") throw domainError(409, "ATTEMPT_NOT_IN_PROGRESS", "This attempt is no longer open"); + const late = now.toISOString() > attempt.dueAt!; + return finaliseSubmission(db, attempt, late ? "AUTO_SUBMITTED" : "SUBMITTED", late ? attempt.dueAt! : now.toISOString(), now); +} + +/** Auto-submits every expired in-progress attempt of one school; returns how many it closed. */ +export async function autoSubmitSchool(db: Db, now: Date): Promise { + const due = await db.attempts.find({ status: "IN_PROGRESS" }, { lt: { dueAt: now.toISOString() } }); + for (const attempt of due) await finaliseSubmission(db, attempt, "AUTO_SUBMITTED", attempt.dueAt!, now); + for (const assessmentId of new Set(due.map((a) => a.assessmentId))) await refreshAssessmentStatus(db, assessmentId, now); + return due.length; +} + +// Sums marks, stores the result and moves the attempt to MARKED. +async function completeMarking(db: Db, attempt: Attempt, now: Date): Promise { + const assessment = (await db.assessments.get(attempt.assessmentId))!; + const questions = await questionsOf(db, attempt.assessmentId); + const answers = await db.attemptAnswers.find({ attemptId: attempt.id }); + const raw = answers.reduce((sum, a) => sum + (a.marksAwarded ?? 0), 0); + const total = questions.reduce((sum, q) => sum + q.marks, 0); + await recordResult(db, assessment, attempt, raw, total); + const marked = (await db.attempts.update(attempt.id, { status: "MARKED", score: raw }))!; + await refreshAssessmentStatus(db, attempt.assessmentId, now); + return marked; +} + +// An assessment is CLOSED once its window ends and MARKED once every attempt is marked; release is checked separately. +async function refreshAssessmentStatus(db: Db, assessmentId: string, now: Date): Promise { + const assessment: Assessment | undefined = await db.assessments.get(assessmentId); + if (!assessment || ["DRAFT", "MARKED", "RELEASED"].includes(assessment.status) || now.toISOString() < assessment.closesAt) return; + const attempts = await db.attempts.find({ assessmentId }, { limit: 5000 }); + const allMarked = attempts.length > 0 && attempts.every((a) => a.status === "MARKED"); + await db.assessments.update(assessmentId, { status: allMarked ? "MARKED" : "CLOSED" }); +} + +/** Records a teacher's marks; finalise needs every answered short answer marked and an active grading system. */ +export async function markAttempt(db: Db, actor: Actor, attemptId: string, input: { marks: { questionId: string; marks: number }[]; finalise: boolean }, now: Date): Promise { + requireRole(actor, TEACHER_ROLES); + const attempt = await db.attempts.get(attemptId); + if (!attempt) throw errors.notFound("Attempt not found"); + const assessment = (await db.assessments.get(attempt.assessmentId))!; + if (assessment.teacherId !== actor.id && !isAdmin(actor)) throw errors.forbidden("Not your assessment"); + if (!MARKABLE.includes(attempt.status)) throw domainError(409, "ATTEMPT_NOT_SUBMITTED", "The attempt has not been submitted"); + + const questions = await questionsOf(db, attempt.assessmentId); + const answers = await db.attemptAnswers.find({ attemptId }); + for (const { questionId, marks } of input.marks) { + const q = questions.find((x) => x.id === questionId); + const answer = answers.find((a) => a.questionId === questionId); + if (!q || !answer) throw errors.validation("No submitted answer for that question", { questionId }); + if (marks > q.marks) throw errors.validation("Marks exceed the question maximum", { questionId, max: q.marks }); + await db.attemptAnswers.update(answer.id, { marksAwarded: marks }); + } + if (!input.finalise) return (await db.attempts.update(attemptId, { status: "UNDER_MARKING" }))!; + + const fresh = await db.attemptAnswers.find({ attemptId }); + const pending = fresh.filter((a) => a.marksAwarded === null).map((a) => a.questionId); + if (pending.length > 0) throw domainError(409, "ATTEMPT_UNMARKED_ANSWERS", "Some answers still need marks", { questionIds: pending }); + return completeMarking(db, attempt, now); +} + +/** Returns a placeholder object key for a paper scan; real signed upload URLs are issued by the documents service. */ +export async function requestScanUploadLink(db: Db, actor: Actor, attemptId: string) { + requireRole(actor, TEACHER_ROLES); + const attempt = await db.attempts.get(attemptId); + if (!attempt) throw errors.notFound("Attempt not found"); + const objectKey = `scans/${actor.schoolId}/${attemptId}/${crypto.randomUUID()}.pdf`; + await db.paperScans.insert({ attemptId, objectKey, createdBy: actor.id }); + return { objectKey, uploadUrl: null }; +} diff --git a/services/academics/src/features/cats/cats.test.ts b/services/academics/src/features/cats/cats.test.ts new file mode 100644 index 0000000..b212687 --- /dev/null +++ b/services/academics/src/features/cats/cats.test.ts @@ -0,0 +1,250 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { CLOSES, OPENS, answerCorrectly, createScheduled, start } from "../../cat-fixtures"; +import { SCHOOL_A, SCHOOL_B, STUDENT, STUDENT_2, adminToken, harness, seedGrading, studentToken, teacherToken, useTestEnv, type Harness } from "../../test-support"; +import { autoSubmitDue } from "./deadline-worker"; +import { autoMark, responseProblem } from "./marking"; + +let h: Harness; +beforeEach(() => { + useTestEnv(); + h = harness("2026-03-01T10:00:00.000Z"); +}); +afterEach(() => vi.restoreAllMocks()); + +const attemptOf = (id: string) => h.store.tx(SCHOOL_A, (db) => db.attempts.get(id)); + +describe("starting an attempt", () => { + it("computes the deadline from the server clock and hides every key", async () => { + const id = await createScheduled(h, true); + const res = await start(h, id, STUDENT); + expect(res.status).toBe(200); + expect(res.body.attempt.startedAt).toBe("2026-03-01T10:00:00.000Z"); + expect(res.body.attempt.dueAt).toBe("2026-03-01T10:30:00.000Z"); + expect(res.body.serverNow).toBe("2026-03-01T10:00:00.000Z"); + expect(res.body.questions).toHaveLength(4); + const text = JSON.stringify(res.body); + for (const leak of ["answerKey", "isCorrect", "expectedValue", "tolerance", "SECRET-MODEL"]) expect(text).not.toContain(leak); + }); + + it("adds accommodation minutes to the deadline of that student only", async () => { + const id = await createScheduled(h); + const set = await h.call("POST", `/v1/assessments/${id}/accommodations`, teacherToken(), { studentId: STUDENT, extraMinutes: 15 }); + expect(set.status).toBe(201); + expect((await start(h, id, STUDENT)).body.attempt.dueAt).toBe("2026-03-01T10:45:00.000Z"); + expect((await start(h, id, STUDENT_2)).body.attempt.dueAt).toBe("2026-03-01T10:30:00.000Z"); + }); + + it("refuses before the window opens", async () => { + const id = await createScheduled(h); + h.setNow("2026-03-01T08:59:59.000Z"); + const res = await start(h, id, STUDENT); + expect(res.status).toBe(409); + expect(res.body).toMatchObject({ code: "ASSESSMENT_NOT_OPEN" }); + }); + + it("refuses at and after the window close", async () => { + const id = await createScheduled(h); + h.setNow(CLOSES); + expect((await start(h, id, STUDENT)).body).toMatchObject({ code: "ASSESSMENT_CLOSED" }); + }); + + it("accepts the first second of the window", async () => { + const id = await createScheduled(h); + h.setNow(OPENS); + expect((await start(h, id, STUDENT)).status).toBe(200); + }); + + it("refuses a student from another class", async () => { + const id = await createScheduled(h); + expect((await start(h, id, STUDENT, "class-9z")).status).toBe(404); + }); + + it("resuming keeps the original deadline", async () => { + const id = await createScheduled(h); + const first = await start(h, id, STUDENT); + h.setNow("2026-03-01T10:10:00.000Z"); + const again = await start(h, id, STUDENT); + expect(again.body.attempt.id).toBe(first.body.attempt.id); + expect(again.body.attempt.dueAt).toBe(first.body.attempt.dueAt); + }); + + it("a draft is not startable", async () => { + const created = await h.call("POST", "/v1/assessments", teacherToken(), { + title: "Draft", type: "quiz", classId: "class-7a", durationMinutes: 10, opensAt: OPENS, closesAt: CLOSES, + questions: [{ type: "short_answer", stem: "q", marks: 1 }], + }); + expect((await start(h, created.body.id, STUDENT)).status).toBe(404); + }); + + it("rejects exam as an assessment type", async () => { + const res = await h.call("POST", "/v1/assessments", teacherToken(), { + title: "E", type: "exam", classId: "c", durationMinutes: 10, opensAt: OPENS, closesAt: CLOSES, questions: [{ type: "short_answer", stem: "q", marks: 1 }], + }); + expect(res.status).toBe(400); + }); +}); + +describe("autosave and submit", () => { + it("autosaves inside the deadline and refuses after it", async () => { + const id = await createScheduled(h); + const { body } = await start(h, id, STUDENT); + const q = body.questions[0]; + const choice = q.choices[0].id; + expect((await h.call("PUT", `/v1/attempts/${body.attempt.id}/answers/${q.id}`, studentToken(), { response: choice })).status).toBe(200); + h.setNow("2026-03-01T10:30:00.001Z"); + const late = await h.call("PUT", `/v1/attempts/${body.attempt.id}/answers/${q.id}`, studentToken(), { response: choice }); + expect(late.status).toBe(409); + expect(late.body).toMatchObject({ code: "ATTEMPT_DEADLINE_PASSED" }); + }); + + it("rejects a response of the wrong shape", async () => { + const id = await createScheduled(h); + const { body } = await start(h, id, STUDENT); + const mc = body.questions.find((q: any) => q.type === "multiple_choice"); + const res = await h.call("PUT", `/v1/attempts/${body.attempt.id}/answers/${mc.id}`, studentToken(), { response: "not-a-choice" }); + expect(res.status).toBe(400); + }); + + it("submitting after the deadline is recorded as auto-submitted at the deadline", async () => { + const id = await createScheduled(h); + const { body } = await start(h, id, STUDENT); + h.setNow("2026-03-01T10:45:00.000Z"); + const res = await h.call("POST", `/v1/attempts/${body.attempt.id}/submit`, studentToken()); + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ status: "AUTO_SUBMITTED", submittedAt: "2026-03-01T10:30:00.000Z" }); + }); + + it("without a grading system a fully auto-marked paper waits as SUBMITTED, then is marked by finalise", async () => { + const id = await createScheduled(h); + const { body } = await start(h, id, STUDENT); + await answerCorrectly(h, id, body.attempt.id, STUDENT); + expect((await h.call("POST", `/v1/attempts/${body.attempt.id}/submit`, studentToken())).body.status).toBe("SUBMITTED"); + const blocked = await h.call("POST", `/v1/attempts/${body.attempt.id}/mark`, teacherToken(), { marks: [], finalise: true }); + expect(blocked.body).toMatchObject({ code: "GRADING_NOT_CONFIGURED" }); + }); + + it("another student cannot submit someone else's attempt", async () => { + const id = await createScheduled(h); + const { body } = await start(h, id, STUDENT); + expect((await h.call("POST", `/v1/attempts/${body.attempt.id}/submit`, studentToken(STUDENT_2))).status).toBe(404); + }); +}); + +describe("deadline worker", () => { + it("auto-submits only expired attempts and keeps their saved answers", async () => { + await seedGrading(h); + const id = await createScheduled(h); + const a = await start(h, id, STUDENT); + await answerCorrectly(h, id, a.body.attempt.id, STUDENT); + h.setNow("2026-03-01T10:20:00.000Z"); + const b = await start(h, id, STUDENT_2); // due 10:50 + + expect(await autoSubmitDue(h.store, new Date("2026-03-01T10:29:59.000Z"))).toBe(0); + expect(await autoSubmitDue(h.store, new Date("2026-03-01T10:31:00.000Z"))).toBe(1); + + const first = await attemptOf(a.body.attempt.id); + expect(first).toMatchObject({ status: "MARKED", score: 10, submittedAt: "2026-03-01T10:30:00.000Z" }); + expect((await attemptOf(b.body.attempt.id))!.status).toBe("IN_PROGRESS"); + expect(await autoSubmitDue(h.store, new Date("2026-03-01T10:31:00.000Z"))).toBe(0); // idempotent + }); + + it("leaves a short-answer paper AUTO_SUBMITTED for the teacher", async () => { + await seedGrading(h); + const id = await createScheduled(h, true); + const a = await start(h, id, STUDENT); + await answerCorrectly(h, id, a.body.attempt.id, STUDENT); + await autoSubmitDue(h.store, new Date("2026-03-01T11:00:00.000Z")); + expect((await attemptOf(a.body.attempt.id))!.status).toBe("AUTO_SUBMITTED"); + }); +}); + +describe("marking", () => { + it("auto-marks choice, reorder and calculation, then grades on submit", async () => { + await seedGrading(h); + const id = await createScheduled(h); + const a = await start(h, id, STUDENT); + await answerCorrectly(h, id, a.body.attempt.id, STUDENT); + expect((await h.call("POST", `/v1/attempts/${a.body.attempt.id}/submit`, studentToken())).body).toMatchObject({ status: "MARKED", score: 10 }); + const results = await h.store.tx(SCHOOL_A, (db) => db.results.find({ assessmentId: id })); + expect(results[0]).toMatchObject({ raw: 10, total: 10, pct: 100, grade: "A", points: 12, isPass: true, releasedAt: null }); + }); + + it("a teacher marks short answers, finalise needs every answered one marked", async () => { + await seedGrading(h); + const id = await createScheduled(h, true); + const a = await start(h, id, STUDENT); + await answerCorrectly(h, id, a.body.attempt.id, STUDENT); + await h.call("POST", `/v1/attempts/${a.body.attempt.id}/submit`, studentToken()); + const short = a.body.questions.find((q: any) => q.type === "short_answer"); + + const early = await h.call("POST", `/v1/attempts/${a.body.attempt.id}/mark`, teacherToken(), { marks: [], finalise: true }); + expect(early.body).toMatchObject({ code: "ATTEMPT_UNMARKED_ANSWERS" }); + const tooMany = await h.call("POST", `/v1/attempts/${a.body.attempt.id}/mark`, teacherToken(), { marks: [{ questionId: short.id, marks: 11 }], finalise: false }); + expect(tooMany.status).toBe(400); + const partial = await h.call("POST", `/v1/attempts/${a.body.attempt.id}/mark`, teacherToken(), { marks: [{ questionId: short.id, marks: 2 }], finalise: false }); + expect(partial.body.status).toBe("UNDER_MARKING"); + const done = await h.call("POST", `/v1/attempts/${a.body.attempt.id}/mark`, teacherToken(), { marks: [{ questionId: short.id, marks: 4 }], finalise: true }); + expect(done.body).toMatchObject({ status: "MARKED", score: 14 }); + const [result] = await h.store.tx(SCHOOL_A, (db) => db.results.find({ assessmentId: id })); + expect(result).toMatchObject({ raw: 14, total: 20, pct: 70, grade: "B" }); + }); + + it("only a teacher can mark", async () => { + const id = await createScheduled(h); + const a = await start(h, id, STUDENT); + expect((await h.call("POST", `/v1/attempts/${a.body.attempt.id}/mark`, studentToken(), { marks: [], finalise: false })).status).toBe(403); + }); + + it("hands back a placeholder scan key and records it", async () => { + const id = await createScheduled(h); + const a = await start(h, id, STUDENT); + const res = await h.call("POST", `/v1/attempts/${a.body.attempt.id}/scan-upload-link`, teacherToken()); + expect(res.status).toBe(201); + expect(res.body.objectKey).toMatch(new RegExp(`^scans/${SCHOOL_A}/${a.body.attempt.id}/`)); + expect(res.body.uploadUrl).toBeNull(); + expect(await h.store.tx(SCHOOL_A, (db) => db.paperScans.find({}))).toHaveLength(1); + }); +}); + +describe("pure marking", () => { + const base = { marks: 4 }; + it("calculation honours the tolerance edges", () => { + const spec = { expectedValue: 10, tolerance: 0.5 }; + const q = { ...base, type: "calculation" as const, answerKey: null }; + expect(autoMark(q, spec, "10.5")).toBe(4); + expect(autoMark(q, spec, 9.5)).toBe(4); + expect(autoMark(q, spec, "10.51")).toBe(0); + expect(autoMark(q, spec, "abc")).toBe(0); + expect(autoMark(q, spec, "")).toBe(0); + }); + + it("reorder is all or nothing", () => { + const q = { ...base, type: "reorder" as const, answerKey: ["a", "b", "c"] }; + expect(autoMark(q, undefined, ["a", "b", "c"])).toBe(4); + expect(autoMark(q, undefined, ["a", "c", "b"])).toBe(0); + expect(autoMark(q, undefined, ["a", "b"])).toBe(0); + }); + + it("short answers need a teacher", () => { + expect(autoMark({ ...base, type: "short_answer", answerKey: null }, undefined, "x")).toBeNull(); + }); + + it("checks response shapes", () => { + const reorder = { type: "reorder" as const, choices: [{ id: "a", label: "A" }, { id: "b", label: "B" }] }; + expect(responseProblem(reorder, ["a", "b"])).toBeUndefined(); + expect(responseProblem(reorder, ["a", "a"])).toBeDefined(); + expect(responseProblem({ type: "calculation", choices: null }, "3.2")).toBeUndefined(); + expect(responseProblem({ type: "calculation", choices: null }, " ")).toBeDefined(); + }); +}); + +describe("tenant isolation", () => { + it("school B cannot start, list or mark school A assessments", async () => { + const id = await createScheduled(h); + const a = await start(h, id, STUDENT); + expect((await h.call("POST", `/v1/assessments/${id}/attempts`, studentToken(STUDENT, SCHOOL_B), { classId: "class-7a" })).status).toBe(404); + expect((await h.call("GET", "/v1/assessments", adminToken(SCHOOL_B))).body.items).toEqual([]); + expect((await h.call("POST", `/v1/attempts/${a.body.attempt.id}/mark`, adminToken(SCHOOL_B), { marks: [], finalise: false })).status).toBe(404); + expect((await h.call("POST", `/v1/assessments/${id}/schedule`, adminToken(SCHOOL_B))).status).toBe(404); + }); +}); diff --git a/services/academics/src/features/cats/deadline-worker.ts b/services/academics/src/features/cats/deadline-worker.ts new file mode 100644 index 0000000..f0fe0d1 --- /dev/null +++ b/services/academics/src/features/cats/deadline-worker.ts @@ -0,0 +1,14 @@ +import type { Store } from "../../store/tables"; +import { autoSubmitSchool } from "./attempts"; + +/** + * Auto-submits every attempt whose server deadline passed, school by school so row-level security holds. + * Run from one instance only; the clock is injected so tests can move time. + */ +export async function autoSubmitDue(store: Store, now: Date): Promise { + let closed = 0; + for (const schoolId of await store.dueSchoolIds(now)) { + closed += await store.tx(schoolId, (db) => autoSubmitSchool(db, now)); + } + return closed; +} diff --git a/services/academics/src/features/cats/marking.ts b/services/academics/src/features/cats/marking.ts new file mode 100644 index 0000000..fb8d4b3 --- /dev/null +++ b/services/academics/src/features/cats/marking.ts @@ -0,0 +1,48 @@ +import type { AssessmentQuestion, CalcSpec } from "./types"; + +const MAX_RESPONSE_LENGTH = 5000; + +/** Marks multiple choice, reorder and calculation answers; returns null when a teacher must mark. All-or-nothing per question. */ +export function autoMark(question: Pick, spec: Pick | undefined, response: unknown): number | null { + switch (question.type) { + case "multiple_choice": + return response === question.answerKey ? question.marks : 0; + case "reorder": { + const key = question.answerKey as string[]; + const ok = Array.isArray(response) && response.length === key.length && response.every((id, i) => id === key[i]); + return ok ? question.marks : 0; + } + case "calculation": { + const value = typeof response === "number" ? response : Number(String(response).trim()); + if (!spec || !Number.isFinite(value) || String(response).trim() === "") return 0; + return Math.abs(value - spec.expectedValue) <= spec.tolerance ? question.marks : 0; + } + default: + return null; + } +} + +/** Returns an error message when a response has the wrong shape for its question type, else undefined. */ +export function responseProblem(question: Pick, response: unknown): string | undefined { + const ids = (question.choices ?? []).map((c) => c.id); + switch (question.type) { + case "multiple_choice": + return typeof response === "string" && ids.includes(response) ? undefined : "response must be one of the choice ids"; + case "reorder": { + const ok = Array.isArray(response) && response.length === ids.length && new Set(response).size === ids.length && response.every((r) => ids.includes(r as string)); + return ok ? undefined : "response must list every choice id once"; + } + case "calculation": + return (typeof response === "number" && Number.isFinite(response)) || (typeof response === "string" && response.trim() !== "" && Number.isFinite(Number(response))) + ? undefined + : "response must be a number"; + default: + return typeof response === "string" && response.length <= MAX_RESPONSE_LENGTH ? undefined : "response must be text"; + } +} + +/** Student-safe question: no keys, and choices sorted by random id so a reorder list never reveals the answer. */ +export function studentQuestion(q: AssessmentQuestion) { + const choices = q.choices ? [...q.choices].sort((a, b) => a.id.localeCompare(b.id)) : undefined; + return { id: q.id, position: q.position, type: q.type, stem: q.stem, marks: q.marks, ...(choices ? { choices } : {}) }; +} diff --git a/services/academics/src/features/cats/routes.ts b/services/academics/src/features/cats/routes.ts new file mode 100644 index 0000000..0b87a6a --- /dev/null +++ b/services/academics/src/features/cats/routes.ts @@ -0,0 +1,76 @@ +import type { createService } from "@stackable/service-kit"; +import { STUDENT_ROLES, TEACHER_ROLES, actorOf, requireRole } from "../../lib/auth"; +import type { Clock } from "../../lib/clock"; +import { object, parse, readJson, uuid } from "../../lib/validate"; +import type { Store } from "../../store/tables"; +import { createAssessment, listAssessments, scheduleAssessment, setAccommodation } from "./assessments"; +import { markAttempt, requestScanUploadLink, saveAttemptAnswer, startAttempt, submitAttempt } from "./attempts"; +import { accommodationSchema, createAssessmentSchema, listAssessmentsQuery, markAttemptSchema, saveAnswerSchema, startAttemptSchema } from "./schema"; + +const idParam = object({ id: uuid() }); + +/** Registers the CAT endpoints; the clock is injected so the server deadline is testable. */ +export function catRoutes(app: ReturnType, store: Store, now: Clock): void { + app.post("/v1/assessments", async (c) => { + const actor = actorOf(c); + requireRole(actor, TEACHER_ROLES); + const input = parse(createAssessmentSchema, await readJson(c)); + return c.json(await store.tx(actor.schoolId, (db) => createAssessment(db, actor, input)), 201); + }); + + app.get("/v1/assessments", async (c) => { + const actor = actorOf(c); + const { classId } = parse(listAssessmentsQuery, c.req.query()); + return c.json({ items: await store.tx(actor.schoolId, (db) => listAssessments(db, actor, classId)) }); + }); + + app.post("/v1/assessments/:id/schedule", async (c) => { + const actor = actorOf(c); + const { id } = parse(idParam, c.req.param()); + return c.json(await store.tx(actor.schoolId, (db) => scheduleAssessment(db, actor, id))); + }); + + app.post("/v1/assessments/:id/accommodations", async (c) => { + const actor = actorOf(c); + requireRole(actor, TEACHER_ROLES); + const { id } = parse(idParam, c.req.param()); + const { studentId, extraMinutes } = parse(accommodationSchema, await readJson(c)); + return c.json(await store.tx(actor.schoolId, (db) => setAccommodation(db, actor, id, studentId, extraMinutes)), 201); + }); + + app.post("/v1/assessments/:id/attempts", async (c) => { + const actor = actorOf(c); + requireRole(actor, STUDENT_ROLES); + const { id } = parse(idParam, c.req.param()); + const { classId } = parse(startAttemptSchema, await readJson(c)); + return c.json(await store.tx(actor.schoolId, (db) => startAttempt(db, actor, id, classId, now()))); + }); + + app.put("/v1/attempts/:id/answers/:questionId", async (c) => { + const actor = actorOf(c); + requireRole(actor, STUDENT_ROLES); + const { id, questionId } = parse(object({ id: uuid(), questionId: uuid() }), c.req.param()); + const { response } = parse(saveAnswerSchema, await readJson(c)); + return c.json(await store.tx(actor.schoolId, (db) => saveAttemptAnswer(db, actor, id, questionId, response, now()))); + }); + + app.post("/v1/attempts/:id/submit", async (c) => { + const actor = actorOf(c); + const { id } = parse(idParam, c.req.param()); + return c.json(await store.tx(actor.schoolId, (db) => submitAttempt(db, actor, id, now()))); + }); + + app.post("/v1/attempts/:id/mark", async (c) => { + const actor = actorOf(c); + requireRole(actor, TEACHER_ROLES); + const { id } = parse(idParam, c.req.param()); + const input = parse(markAttemptSchema, await readJson(c)); + return c.json(await store.tx(actor.schoolId, (db) => markAttempt(db, actor, id, input, now()))); + }); + + app.post("/v1/attempts/:id/scan-upload-link", async (c) => { + const actor = actorOf(c); + const { id } = parse(idParam, c.req.param()); + return c.json(await store.tx(actor.schoolId, (db) => requestScanUploadLink(db, actor, id)), 201); + }); +} diff --git a/services/academics/src/features/cats/schema.ts b/services/academics/src/features/cats/schema.ts new file mode 100644 index 0000000..f7273f8 --- /dev/null +++ b/services/academics/src/features/cats/schema.ts @@ -0,0 +1,35 @@ +import { array, bool, externalId, int, isoDate, num, object, oneOf, optional, string, any, type Infer } from "../../lib/validate"; +import { ASSESSMENT_TYPES, CAT_QUESTION_TYPES } from "./types"; + +const questionSchema = object({ + type: oneOf(CAT_QUESTION_TYPES), + stem: string(), + marks: num({ min: 0.5, max: 1000 }), + choices: optional(array(object({ label: string({ max: 500 }), isCorrect: bool() }), { min: 2, max: 10 })), // multiple_choice + items: optional(array(string({ max: 500 }), { min: 2, max: 10 })), // reorder, listed in the correct order + expectedValue: optional(num()), // calculation + tolerance: optional(num({ min: 0 })), + modelAnswer: optional(string()), // short_answer +}); + +export const createAssessmentSchema = object({ + title: string({ max: 200 }), + type: oneOf(ASSESSMENT_TYPES), + classId: externalId(), + subjectId: optional(externalId()), + durationMinutes: int({ min: 1, max: 600 }), + opensAt: isoDate(), + closesAt: isoDate(), + questions: array(questionSchema, { min: 1, max: 200 }), +}); +export type CreateAssessmentInput = Infer; + +export const listAssessmentsQuery = object({ classId: optional(externalId()) }); +export const accommodationSchema = object({ studentId: externalId(), extraMinutes: int({ min: 1, max: 240 }) }); +export const startAttemptSchema = object({ classId: externalId() }); +export const saveAnswerSchema = object({ response: any() }); + +export const markAttemptSchema = object({ + marks: array(object({ questionId: externalId(), marks: num({ min: 0 }) }), { max: 200 }), + finalise: bool(), +}); diff --git a/services/academics/src/features/cats/types.ts b/services/academics/src/features/cats/types.ts new file mode 100644 index 0000000..4d938c6 --- /dev/null +++ b/services/academics/src/features/cats/types.ts @@ -0,0 +1,98 @@ +export const ASSESSMENT_TYPES = ["cat", "quiz"] as const; // exams are a separate future module +export const ASSESSMENT_STATUSES = ["DRAFT", "SCHEDULED", "OPEN", "CLOSED", "MARKED", "RELEASED"] as const; +export const CAT_QUESTION_TYPES = ["short_answer", "multiple_choice", "reorder", "calculation"] as const; +export const ATTEMPT_STATUSES = ["NOT_STARTED", "IN_PROGRESS", "SUBMITTED", "UNDER_MARKING", "MARKED", "AUTO_SUBMITTED"] as const; + +export type AssessmentType = (typeof ASSESSMENT_TYPES)[number]; +export type AssessmentStatus = (typeof ASSESSMENT_STATUSES)[number]; +export type CatQuestionType = (typeof CAT_QUESTION_TYPES)[number]; +export type AttemptStatus = (typeof ATTEMPT_STATUSES)[number]; + +export interface Choice { + id: string; + label: string; +} + +export interface Assessment { + id: string; + schoolId: string; + title: string; + type: AssessmentType; + classId: string; + subjectId: string | null; + teacherId: string; + durationMinutes: number; + opensAt: string; + closesAt: string; + status: AssessmentStatus; + createdAt: string; + updatedAt: string; +} + +export interface AssessmentQuestion { + id: string; + schoolId: string; + assessmentId: string; + position: number; + type: CatQuestionType; + stem: string; + marks: number; + /** Shown to students; for reorder the stored order is the correct order, so views sort by id. */ + choices: Choice[] | null; + /** MC: the correct choice id. Reorder: choice ids in order. Short answer: optional model answer. */ + answerKey: unknown; + createdAt: string; +} + +export interface CalcSpec { + id: string; + schoolId: string; + questionId: string; + expectedValue: number; + tolerance: number; + createdAt: string; +} + +export interface Accommodation { + id: string; + schoolId: string; + assessmentId: string; + studentId: string; + extraMinutes: number; + createdAt: string; +} + +export interface Attempt { + id: string; + schoolId: string; + assessmentId: string; + studentId: string; + status: AttemptStatus; + startedAt: string | null; + dueAt: string | null; + submittedAt: string | null; + extraMinutes: number; + score: number | null; + createdAt: string; + updatedAt: string; +} + +export interface AttemptAnswer { + id: string; + schoolId: string; + attemptId: string; + questionId: string; + response: unknown; + marksAwarded: number | null; + createdAt: string; + updatedAt: string; +} + +export interface PaperScan { + id: string; + schoolId: string; + attemptId: string; + objectKey: string; + createdBy: string; + createdAt: string; +} diff --git a/services/academics/src/features/grading/bands.test.ts b/services/academics/src/features/grading/bands.test.ts new file mode 100644 index 0000000..994457b --- /dev/null +++ b/services/academics/src/features/grading/bands.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from "vitest"; +import { gradeFor, isPass, normalise, validateBands, type BandInput } from "./bands"; + +const band = (minPct: number, maxPct: number, grade = "X"): BandInput => ({ minPct, maxPct, grade, points: 0 }); + +describe("validateBands", () => { + it("accepts a clean whole-number cover", () => { + expect(validateBands([band(0, 39), band(40, 79), band(80, 100)])).toEqual([]); + }); + + it("accepts bands given out of order", () => { + expect(validateBands([band(80, 100), band(0, 39), band(40, 79)])).toEqual([]); + }); + + it("accepts a single 0 to 100 band", () => { + expect(validateBands([band(0, 100)])).toEqual([]); + }); + + it("accepts decimal bands that are contiguous on the decimal grid", () => { + expect(validateBands([band(0, 49.5), band(49.6, 100)])).toEqual([]); + expect(validateBands([band(0, 49.99), band(50, 100)])).toEqual([]); + }); + + it("rejects an empty list", () => { + expect(validateBands([])[0].code).toBe("EMPTY"); + }); + + it("reports a gap with both rows", () => { + const issues = validateBands([band(0, 39), band(41, 100)]); + expect(issues).toEqual([{ code: "GAP", message: "Gap between bands", rows: [0, 1] }]); + }); + + it("reports a decimal gap", () => { + expect(validateBands([band(0, 49.5), band(49.7, 100)]).map((i) => i.code)).toEqual(["GAP"]); + }); + + it("reports an overlap with both rows, using original indexes", () => { + const issues = validateBands([band(40, 100), band(0, 40)]); + expect(issues).toEqual([{ code: "OVERLAP", message: "Bands overlap", rows: [1, 0] }]); + }); + + it("reports a band nested inside another as an overlap", () => { + expect(validateBands([band(0, 100), band(10, 20)]).map((i) => i.code)).toEqual(["OVERLAP"]); + }); + + it("requires the cover to start at 0", () => { + expect(validateBands([band(1, 100)]).map((i) => i.code)).toContain("START"); + }); + + it("requires the cover to end at 100", () => { + expect(validateBands([band(0, 99)]).map((i) => i.code)).toContain("END"); + }); + + it("rejects out of range, inverted and over-precise rows", () => { + expect(validateBands([band(-1, 50), band(51, 100)])[0]).toMatchObject({ code: "RANGE", rows: [0] }); + expect(validateBands([band(0, 101)])[0]).toMatchObject({ code: "RANGE", rows: [0] }); + expect(validateBands([band(60, 40), band(0, 100)])[0]).toMatchObject({ code: "RANGE", rows: [0] }); + expect(validateBands([band(0, 33.333), band(33.334, 100)])[0].code).toBe("RANGE"); + }); +}); + +describe("grading maths", () => { + const bands = [band(0, 39, "E"), band(40, 79, "C"), band(80, 100, "A")]; + + it("normalises raw over total to a percentage", () => { + expect(normalise(45, 60)).toBe(75); + expect(normalise(1, 3)).toBe(33.33); + expect(normalise(0, 10)).toBe(0); + expect(normalise(10, 10)).toBe(100); + }); + + it("rejects an impossible score", () => { + expect(() => normalise(1, 0)).toThrow(RangeError); + expect(() => normalise(11, 10)).toThrow(RangeError); + expect(() => normalise(-1, 10)).toThrow(RangeError); + }); + + it("picks the band at every boundary", () => { + expect(gradeFor(0, bands).grade).toBe("E"); + expect(gradeFor(39, bands).grade).toBe("E"); + expect(gradeFor(40, bands).grade).toBe("C"); + expect(gradeFor(79, bands).grade).toBe("C"); + expect(gradeFor(80, bands).grade).toBe("A"); + expect(gradeFor(100, bands).grade).toBe("A"); + }); + + it("rounds a fractional percentage to the band grid before lookup", () => { + expect(gradeFor(79.4, bands).grade).toBe("C"); + expect(gradeFor(79.5, bands).grade).toBe("A"); + expect(gradeFor(39.6, bands).grade).toBe("C"); + }); + + it("uses the finer grid for decimal bands", () => { + const fine = [band(0, 49.5, "L"), band(49.6, 100, "H")]; + expect(gradeFor(49.5, fine).grade).toBe("L"); + expect(gradeFor(49.6, fine).grade).toBe("H"); + }); + + it("refuses a percentage outside 0 to 100", () => { + expect(() => gradeFor(100.5, bands)).toThrow(RangeError); + expect(() => gradeFor(-0.1, bands)).toThrow(RangeError); + }); + + it("passes at exactly the pass mark and fails just under it", () => { + expect(isPass(50, 50)).toBe(true); + expect(isPass(49.99, 50)).toBe(false); + }); +}); diff --git a/services/academics/src/features/grading/bands.ts b/services/academics/src/features/grading/bands.ts new file mode 100644 index 0000000..28e3580 --- /dev/null +++ b/services/academics/src/features/grading/bands.ts @@ -0,0 +1,84 @@ +export interface BandInput { + minPct: number; + maxPct: number; + grade: string; + points: number; + remark?: string | null; +} + +export type BandIssueCode = "EMPTY" | "RANGE" | "START" | "END" | "GAP" | "OVERLAP"; + +export interface BandIssue { + code: BandIssueCode; + message: string; + /** Indexes into the input array of the offending rows. */ + rows: number[]; +} + +const MAX_DECIMALS = 2; +const EPSILON = 1e-9; + +// The grid is the finest decimal precision any bound uses, so whole-number bands (79 then 80) are contiguous. +function gridDecimals(bands: BandInput[]): number { + for (let d = 0; d <= MAX_DECIMALS; d++) { + const factor = 10 ** d; + const onGrid = bands.every((b) => [b.minPct, b.maxPct].every((x) => Math.abs(x * factor - Math.round(x * factor)) < EPSILON)); + if (onGrid) return d; + } + return MAX_DECIMALS; +} + +/** Checks that bands cover 0 to 100 with no gaps or overlaps and returns every offending row. */ +export function validateBands(bands: BandInput[]): BandIssue[] { + if (bands.length === 0) return [{ code: "EMPTY", message: "At least one band is required", rows: [] }]; + + const issues: BandIssue[] = []; + bands.forEach((b, i) => { + const decimalsOk = Math.abs(b.minPct * 100 - Math.round(b.minPct * 100)) < EPSILON && Math.abs(b.maxPct * 100 - Math.round(b.maxPct * 100)) < EPSILON; + if (!(b.minPct >= 0 && b.maxPct <= 100 && b.minPct <= b.maxPct && decimalsOk)) { + issues.push({ code: "RANGE", message: "Band must satisfy 0 <= min <= max <= 100 with at most 2 decimals", rows: [i] }); + } + }); + if (issues.length > 0) return issues; + + const factor = 10 ** gridDecimals(bands); + const scaled = bands.map((b, i) => ({ i, min: Math.round(b.minPct * factor), max: Math.round(b.maxPct * factor) })); + scaled.sort((a, b) => a.min - b.min || a.max - b.max); + + if (scaled[0].min !== 0) issues.push({ code: "START", message: "Bands must start at 0", rows: [scaled[0].i] }); + const last = scaled[scaled.length - 1]; + if (Math.max(...scaled.map((s) => s.max)) !== 100 * factor) { + issues.push({ code: "END", message: "Bands must end at 100", rows: [last.i] }); + } + + let reach = scaled[0]; + for (const cur of scaled.slice(1)) { + if (cur.min <= reach.max) issues.push({ code: "OVERLAP", message: "Bands overlap", rows: [reach.i, cur.i] }); + else if (cur.min > reach.max + 1) issues.push({ code: "GAP", message: "Gap between bands", rows: [reach.i, cur.i] }); + if (cur.max > reach.max) reach = cur; + } + return issues; +} + +const round2 = (x: number) => Math.round((x + Number.EPSILON) * 100) / 100; + +/** Converts a raw score to a percentage rounded to 2 decimals. */ +export function normalise(raw: number, total: number): number { + if (!(total > 0)) throw new RangeError("total must be positive"); + if (!(raw >= 0 && raw <= total)) throw new RangeError("raw must be between 0 and total"); + return round2((raw / total) * 100); +} + +/** Finds the band for a percentage, rounding it to the bands' own precision first (79.6 is 80 for whole bands). */ +export function gradeFor(pct: number, bands: B[]): B { + if (!(pct >= 0 && pct <= 100)) throw new RangeError("pct must be between 0 and 100"); + const factor = 10 ** gridDecimals(bands); + const p = Math.round(pct * factor + EPSILON); + const hit = bands.find((b) => p >= Math.round(b.minPct * factor) && p <= Math.round(b.maxPct * factor)); + if (!hit) throw new Error(`bands do not cover ${pct}`); + return hit; +} + +export function isPass(pct: number, passPct: number): boolean { + return pct >= passPct; +} diff --git a/services/academics/src/features/grading/release.test.ts b/services/academics/src/features/grading/release.test.ts new file mode 100644 index 0000000..b9ebbf3 --- /dev/null +++ b/services/academics/src/features/grading/release.test.ts @@ -0,0 +1,181 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { answerCorrectly, createScheduled, start } from "../../cat-fixtures"; +import { + SCHOOL_A, SCHOOL_B, STUDENT, STUDENT_2, adminToken, harness, releaserToken, seedGrading, studentToken, teacherToken, token, useTestEnv, type Harness, +} from "../../test-support"; + +const AFTER_CLOSE = "2026-03-01T12:30:00.000Z"; + +beforeEach(() => useTestEnv()); +afterEach(() => vi.restoreAllMocks()); + +/** Two students submit perfect papers; returns the assessment id with every script marked. */ +async function markedAssessment(h: Harness): Promise { + await seedGrading(h); + const id = await createScheduled(h); + for (const student of [STUDENT, STUDENT_2]) { + const a = await start(h, id, student); + await answerCorrectly(h, id, a.body.attempt.id, student); + await h.call("POST", `/v1/attempts/${a.body.attempt.id}/submit`, studentToken(student)); + } + h.setNow(AFTER_CLOSE); + return id; +} + +const results = (h: Harness, assessmentId: string) => h.store.tx(SCHOOL_A, (db) => db.results.find({ assessmentId })); + +describe("release", () => { + it("needs the rel claim: teacher and admin tokens without it can never release", async () => { + const h = harness("2026-03-01T10:00:00.000Z"); + const id = await markedAssessment(h); + for (const bearer of [teacherToken(), adminToken(), token("t", "teacher", SCHOOL_A, { rel: false })]) { + const res = await h.call("POST", `/v1/assessments/${id}/release`, bearer); + expect(res.status).toBe(403); + expect(res.body).toMatchObject({ code: "RESULTS_RELEASE_FORBIDDEN" }); + } + expect((await results(h, id)).every((r) => r.releasedAt === null)).toBe(true); + }); + + it("a student token with rel still cannot release", async () => { + const h = harness("2026-03-01T10:00:00.000Z"); + const id = await markedAssessment(h); + expect((await h.call("POST", `/v1/assessments/${id}/release`, token(STUDENT, "student", SCHOOL_A, { rel: true }))).status).toBe(403); + }); + + it("releases every result at once with one timestamp and writes results.released", async () => { + const h = harness("2026-03-01T10:00:00.000Z"); + const id = await markedAssessment(h); + const res = await h.call("POST", `/v1/assessments/${id}/release`, releaserToken()); + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ count: 2, releasedAt: AFTER_CLOSE }); + + expect((await results(h, id)).map((r) => r.releasedAt)).toEqual([AFTER_CLOSE, AFTER_CLOSE]); + const outbox = await h.store.tx(SCHOOL_A, (db) => db.outbox.find({ topic: "results.released" })); + expect(outbox).toHaveLength(1); + expect(outbox[0].payload).toMatchObject({ assessmentId: id, resultCount: 2 }); + const assessment = await h.store.tx(SCHOOL_A, (db) => db.assessments.get(id)); + expect(assessment!.status).toBe("RELEASED"); + expect((await h.call("POST", `/v1/assessments/${id}/release`, releaserToken())).body).toMatchObject({ code: "RESULTS_ALREADY_RELEASED" }); + }); + + it("is atomic: a failure after the results update releases nothing", async () => { + const h = harness("2026-03-01T10:00:00.000Z", { + onInsert: (table) => { + if (table === "outbox" && armed) throw new Error("outbox down"); + }, + }); + let armed = false; + const id = await markedAssessment(h); + armed = true; + vi.spyOn(console, "error").mockImplementation(() => undefined); + + const res = await h.call("POST", `/v1/assessments/${id}/release`, releaserToken()); + expect(res.status).toBe(500); + expect((await results(h, id)).every((r) => r.releasedAt === null)).toBe(true); + const assessment = await h.store.tx(SCHOOL_A, (db) => db.assessments.get(id)); + expect(assessment!.status).not.toBe("RELEASED"); + expect(await h.store.tx(SCHOOL_A, (db) => db.outbox.find({ topic: "results.released" }))).toHaveLength(0); + + armed = false; + expect((await h.call("POST", `/v1/assessments/${id}/release`, releaserToken())).status).toBe(200); + }); + + it("refuses while any script is unmarked and names how many", async () => { + const h = harness("2026-03-01T10:00:00.000Z"); + await seedGrading(h); + const id = await createScheduled(h, true); // has a short answer, so papers wait for the teacher + for (const student of [STUDENT, STUDENT_2]) { + const a = await start(h, id, student); + await answerCorrectly(h, id, a.body.attempt.id, student); + await h.call("POST", `/v1/attempts/${a.body.attempt.id}/submit`, studentToken(student)); + } + h.setNow(AFTER_CLOSE); + const res = await h.call("POST", `/v1/assessments/${id}/release`, releaserToken()); + expect(res.status).toBe(409); + expect(res.body).toMatchObject({ code: "RESULTS_UNMARKED_SCRIPTS", details: { count: 2 } }); + }); + + it("refuses an attempt still in progress", async () => { + const h = harness("2026-03-01T10:00:00.000Z"); + await seedGrading(h); + const id = await createScheduled(h); + await start(h, id, STUDENT); + h.setNow(AFTER_CLOSE); + expect((await h.call("POST", `/v1/assessments/${id}/release`, releaserToken())).body).toMatchObject({ code: "RESULTS_UNMARKED_SCRIPTS" }); + }); + + it("refuses while the window is still open", async () => { + const h = harness("2026-03-01T10:00:00.000Z"); + const id = await markedAssessment(h); + h.setNow("2026-03-01T11:00:00.000Z"); + expect((await h.call("POST", `/v1/assessments/${id}/release`, releaserToken())).body).toMatchObject({ code: "ASSESSMENT_STILL_OPEN" }); + }); + + it("students see their own result only after release", async () => { + const h = harness("2026-03-01T10:00:00.000Z"); + const id = await markedAssessment(h); + expect((await h.call("GET", `/v1/assessments/${id}/results`, studentToken())).body.items).toEqual([]); + await h.call("POST", `/v1/assessments/${id}/release`, releaserToken()); + const mine = await h.call("GET", `/v1/assessments/${id}/results`, studentToken()); + expect(mine.body.items).toHaveLength(1); + expect(mine.body.items[0]).toMatchObject({ studentId: STUDENT, grade: "A" }); + expect((await h.call("GET", `/v1/assessments/${id}/results`, teacherToken())).body.items).toHaveLength(2); + }); + + it("school B cannot release school A results", async () => { + const h = harness("2026-03-01T10:00:00.000Z"); + const id = await markedAssessment(h); + expect((await h.call("POST", `/v1/assessments/${id}/release`, releaserToken(SCHOOL_B))).status).toBe(404); + expect((await results(h, id)).every((r) => r.releasedAt === null)).toBe(true); + }); +}); + +describe("grading systems", () => { + it("rejects bands with a gap and reports the rows", async () => { + const h = harness(); + const res = await h.call("POST", "/v1/grading/systems", adminToken(), { + name: "Bad", effectiveFrom: "2026-01-01T00:00:00Z", + bands: [{ minPct: 0, maxPct: 39, grade: "E", points: 1 }, { minPct: 45, maxPct: 100, grade: "A", points: 12 }], + }); + expect(res.status).toBe(400); + expect(res.body).toMatchObject({ code: "GRADING_BANDS_INVALID" }); + expect(res.body.details.issues[0]).toMatchObject({ code: "GAP", rows: [0, 1] }); + }); + + it("only an admin manages systems", async () => { + const h = harness(); + expect((await h.call("POST", "/v1/grading/systems", teacherToken(), {})).status).toBe(403); + }); + + it("activating a new system deactivates the old one, and old results keep their system id", async () => { + const h = harness("2026-03-01T10:00:00.000Z"); + const id = await markedAssessment(h); + const before = await results(h, id); + + const created = await h.call("POST", "/v1/grading/systems", adminToken(), { + name: "Strict", effectiveFrom: "2026-06-01T00:00:00Z", + bands: [{ minPct: 0, maxPct: 89, grade: "B", points: 5 }, { minPct: 90, maxPct: 100, grade: "A", points: 12 }], + }); + await h.call("POST", `/v1/grading/systems/${created.body.id}/activate`, adminToken()); + + const systems = (await h.call("GET", "/v1/grading/systems", adminToken())).body.items; + expect(systems.filter((s: any) => s.active).map((s: any) => s.name)).toEqual(["Strict"]); + expect((await results(h, id)).map((r) => r.systemId)).toEqual(before.map((r) => r.systemId)); + expect(before[0].systemId).not.toBe(created.body.id); + }); + + it("a per-subject pass mark overrides the default", async () => { + const h = harness("2026-03-01T10:00:00.000Z"); + await seedGrading(h); + const system = (await h.call("GET", "/v1/grading/systems", adminToken())).body.items[0]; + await h.call("PUT", `/v1/grading/systems/${system.id}/pass-marks`, adminToken(), { subjectId: "subject-math", passPct: 100 }); + const id = await createScheduled(h, true); + const a = await start(h, id, STUDENT); + await answerCorrectly(h, id, a.body.attempt.id, STUDENT); + await h.call("POST", `/v1/attempts/${a.body.attempt.id}/submit`, studentToken()); + const short = a.body.questions.find((q: any) => q.type === "short_answer"); + await h.call("POST", `/v1/attempts/${a.body.attempt.id}/mark`, teacherToken(), { marks: [{ questionId: short.id, marks: 5 }], finalise: true }); + const [result] = await results(h, id); + expect(result).toMatchObject({ pct: 75, isPass: false }); // 75 is below the subject mark of 100, above the default 40 + }); +}); diff --git a/services/academics/src/features/grading/release.ts b/services/academics/src/features/grading/release.ts new file mode 100644 index 0000000..d757f4a --- /dev/null +++ b/services/academics/src/features/grading/release.ts @@ -0,0 +1,38 @@ +import { errors } from "@stackable/service-kit"; +import { TEACHER_ROLES, domainError, isAdmin, requireRole, type Actor } from "../../lib/auth"; +import { emit } from "../../lib/outbox"; +import type { Db } from "../../store/tables"; + +const MAX_LISTED_UNMARKED = 50; // keeps the error body small when a whole class is unmarked +const MAX_RELEASE_ROWS = 5000; + +/** + * Publishes every result of an assessment at once. The gateway sets the `rel` claim only after + * verifying the school security code and enforcing the 5-try lockout; a token without it can never release. + */ +export async function releaseAssessment(db: Db, actor: Actor, assessmentId: string, now: Date) { + requireRole(actor, TEACHER_ROLES); + if (!actor.canRelease) throw domainError(403, "RESULTS_RELEASE_FORBIDDEN", "Releasing results needs the school security code"); + + const assessment = await db.assessments.get(assessmentId); + if (!assessment) throw errors.notFound("Assessment not found"); + if (assessment.teacherId !== actor.id && !isAdmin(actor)) throw errors.forbidden("Not your assessment"); + if (assessment.status === "RELEASED") throw domainError(409, "RESULTS_ALREADY_RELEASED", "Results are already released"); + if (now.toISOString() < assessment.closesAt) throw domainError(409, "ASSESSMENT_STILL_OPEN", "The assessment window has not closed"); + + const attempts = await db.attempts.find({ assessmentId }, { limit: MAX_RELEASE_ROWS }); + const unmarked = attempts.filter((a) => a.status !== "MARKED"); + if (unmarked.length > 0) { + throw domainError(409, "RESULTS_UNMARKED_SCRIPTS", "Some scripts are not marked yet", { + count: unmarked.length, + attemptIds: unmarked.slice(0, MAX_LISTED_UNMARKED).map((a) => a.id), + }); + } + + const releasedAt = now.toISOString(); + const count = await db.results.updateMany({ assessmentId, releasedAt: null }, { releasedAt }); + if (count === 0) throw domainError(409, "RESULTS_EMPTY", "There are no results to release"); + await db.assessments.update(assessmentId, { status: "RELEASED" }); + await emit(db, "results.released", { assessmentId, classId: assessment.classId, resultCount: count, releasedAt }); + return { assessmentId, releasedAt, count }; +} diff --git a/services/academics/src/features/grading/results.ts b/services/academics/src/features/grading/results.ts new file mode 100644 index 0000000..c767f59 --- /dev/null +++ b/services/academics/src/features/grading/results.ts @@ -0,0 +1,18 @@ +import { errors } from "@stackable/service-kit"; +import { STUDENT_ROLES, TEACHER_ROLES, isAdmin, requireRole, type Actor } from "../../lib/auth"; +import type { Db } from "../../store/tables"; + +const MAX_RESULTS = 2000; + +/** Teachers see every result; a student sees only their own, and only after release. */ +export async function listResults(db: Db, actor: Actor, assessmentId: string) { + const assessment = await db.assessments.get(assessmentId); + if (!assessment) throw errors.notFound("Assessment not found"); + if ((STUDENT_ROLES as readonly string[]).includes(actor.role)) { + const mine = await db.results.find({ assessmentId, studentId: actor.id }, { limit: 1 }); + return mine.filter((r) => r.releasedAt !== null); + } + requireRole(actor, TEACHER_ROLES); + if (assessment.teacherId !== actor.id && !isAdmin(actor)) throw errors.forbidden("Not your assessment"); + return db.results.find({ assessmentId }, { limit: MAX_RESULTS }); +} diff --git a/services/academics/src/features/grading/routes.ts b/services/academics/src/features/grading/routes.ts new file mode 100644 index 0000000..a32bece --- /dev/null +++ b/services/academics/src/features/grading/routes.ts @@ -0,0 +1,52 @@ +import type { createService } from "@stackable/service-kit"; +import { ADMIN_ROLES, actorOf, requireRole } from "../../lib/auth"; +import type { Clock } from "../../lib/clock"; +import { object, parse, readJson, uuid } from "../../lib/validate"; +import type { Store } from "../../store/tables"; +import { releaseAssessment } from "./release"; +import { listResults } from "./results"; +import { createSystemSchema, passMarkSchema } from "./schema"; +import { activateSystem, createSystem, listSystems, setPassMark } from "./service"; + +const idParam = object({ id: uuid() }); + +/** Registers grading and release endpoints. Release needs the `rel` claim set by the gateway after the security-code check. */ +export function gradingRoutes(app: ReturnType, store: Store, now: Clock): void { + app.post("/v1/grading/systems", async (c) => { + const actor = actorOf(c); + requireRole(actor, ADMIN_ROLES); + const input = parse(createSystemSchema, await readJson(c)); + return c.json(await store.tx(actor.schoolId, (db) => createSystem(db, actor, input)), 201); + }); + + app.get("/v1/grading/systems", async (c) => { + const actor = actorOf(c); + return c.json({ items: await store.tx(actor.schoolId, (db) => listSystems(db)) }); + }); + + app.post("/v1/grading/systems/:id/activate", async (c) => { + const actor = actorOf(c); + const { id } = parse(idParam, c.req.param()); + return c.json(await store.tx(actor.schoolId, (db) => activateSystem(db, actor, id))); + }); + + app.put("/v1/grading/systems/:id/pass-marks", async (c) => { + const actor = actorOf(c); + requireRole(actor, ADMIN_ROLES); + const { id } = parse(idParam, c.req.param()); + const { subjectId, passPct } = parse(passMarkSchema, await readJson(c)); + return c.json(await store.tx(actor.schoolId, (db) => setPassMark(db, actor, id, subjectId ?? null, passPct))); + }); + + app.get("/v1/assessments/:id/results", async (c) => { + const actor = actorOf(c); + const { id } = parse(idParam, c.req.param()); + return c.json({ items: await store.tx(actor.schoolId, (db) => listResults(db, actor, id)) }); + }); + + app.post("/v1/assessments/:id/release", async (c) => { + const actor = actorOf(c); + const { id } = parse(idParam, c.req.param()); + return c.json(await store.tx(actor.schoolId, (db) => releaseAssessment(db, actor, id, now()))); + }); +} diff --git a/services/academics/src/features/grading/schema.ts b/services/academics/src/features/grading/schema.ts new file mode 100644 index 0000000..4ab8ce2 --- /dev/null +++ b/services/academics/src/features/grading/schema.ts @@ -0,0 +1,13 @@ +import { array, isoDate, num, object, optional, string, externalId } from "../../lib/validate"; + +export const createSystemSchema = object({ + name: string({ max: 100 }), + effectiveFrom: isoDate(), + bands: array( + object({ minPct: num({ min: 0, max: 100 }), maxPct: num({ min: 0, max: 100 }), grade: string({ max: 10 }), points: num({ min: 0, max: 100 }), remark: optional(string({ max: 100 })) }), + { min: 1, max: 30 }, + ), + passMarks: optional(array(object({ subjectId: optional(externalId()), passPct: num({ min: 0, max: 100 }) }), { max: 100 })), +}); + +export const passMarkSchema = object({ subjectId: optional(externalId()), passPct: num({ min: 0, max: 100 }) }); diff --git a/services/academics/src/features/grading/service.ts b/services/academics/src/features/grading/service.ts new file mode 100644 index 0000000..76b4f89 --- /dev/null +++ b/services/academics/src/features/grading/service.ts @@ -0,0 +1,79 @@ +import { errors } from "@stackable/service-kit"; +import { ADMIN_ROLES, domainError, requireRole, type Actor } from "../../lib/auth"; +import type { Db } from "../../store/tables"; +import type { Assessment, Attempt } from "../cats/types"; +import { gradeFor, isPass, normalise, validateBands, type BandInput } from "./bands"; +import type { AssessmentResult, GradeBand, GradingSystem, PassMark } from "./types"; + +export const DEFAULT_PASS_PCT = 50; // used only when a school has configured no pass mark + +export interface CreateSystemInput { + name: string; + effectiveFrom: string; + bands: BandInput[]; + passMarks?: { subjectId?: string; passPct: number }[]; +} + +/** Creates an inactive grading system after proving its bands cover 0 to 100 exactly once. */ +export async function createSystem(db: Db, actor: Actor, input: CreateSystemInput): Promise { + requireRole(actor, ADMIN_ROLES); + const issues = validateBands(input.bands); + if (issues.length > 0) throw domainError(400, "GRADING_BANDS_INVALID", "Grade bands are not a clean 0 to 100 cover", { issues }); + const system = await db.gradingSystems.insert({ name: input.name, effectiveFrom: input.effectiveFrom, active: false }); + for (const b of input.bands) { + await db.gradeBands.insert({ systemId: system.id, minPct: b.minPct, maxPct: b.maxPct, grade: b.grade, points: b.points, remark: b.remark ?? null }); + } + const seen = new Set(); + for (const m of input.passMarks ?? []) { + const key = m.subjectId ?? null; + if (seen.has(key)) throw errors.validation("Duplicate pass mark for the same subject", { subjectId: key }); + seen.add(key); + await db.passMarks.insert({ systemId: system.id, subjectId: key, passPct: m.passPct }); + } + return system; +} + +/** Sets the default (no subject) or a per-subject pass mark; one row per system and subject. */ +export async function setPassMark(db: Db, actor: Actor, systemId: string, subjectId: string | null, passPct: number): Promise { + requireRole(actor, ADMIN_ROLES); + if (!(await db.gradingSystems.get(systemId))) throw errors.notFound("Grading system not found"); + const existing = (await db.passMarks.find({ systemId, subjectId }, { limit: 1 }))[0]; + if (existing) return (await db.passMarks.update(existing.id, { passPct }))!; + return db.passMarks.insert({ systemId, subjectId, passPct }); +} + +export async function listSystems(db: Db): Promise { + return db.gradingSystems.find({}, { orderBy: "effectiveFrom" }); +} + +/** Makes one system the active one; the old system keeps its bands so old results stay explainable. */ +export async function activateSystem(db: Db, actor: Actor, systemId: string): Promise { + requireRole(actor, ADMIN_ROLES); + const system = await db.gradingSystems.get(systemId); + if (!system) throw errors.notFound("Grading system not found"); + await db.gradingSystems.updateMany({ active: true }, { active: false }); + return (await db.gradingSystems.update(systemId, { active: true }))!; +} + +export async function getActiveSystem(db: Db): Promise { + return (await db.gradingSystems.find({ active: true }, { limit: 1 }))[0]; +} + +/** Computes and stores one student's result with the active system; refuses to change a released result. */ +export async function recordResult(db: Db, assessment: Assessment, attempt: Attempt, raw: number, total: number): Promise { + const system = await getActiveSystem(db); + if (!system) throw domainError(409, "GRADING_NOT_CONFIGURED", "No active grading system"); + const bands: GradeBand[] = await db.gradeBands.find({ systemId: system.id }); + const pct = normalise(raw, total); + const band = gradeFor(pct, bands); + const marks: PassMark[] = await db.passMarks.find({ systemId: system.id }); + const mark = marks.find((m) => m.subjectId !== null && m.subjectId === assessment.subjectId) ?? marks.find((m) => m.subjectId === null); + const fields = { + attemptId: attempt.id, systemId: system.id, raw, total, pct, grade: band.grade, points: band.points, + remark: band.remark, isPass: isPass(pct, mark?.passPct ?? DEFAULT_PASS_PCT), + }; + const existing = (await db.results.find({ assessmentId: assessment.id, studentId: attempt.studentId }, { limit: 1 }))[0]; + if (!existing) return db.results.insert({ assessmentId: assessment.id, studentId: attempt.studentId, releasedAt: null, ...fields }); + if (existing.releasedAt) throw domainError(409, "RESULT_ALREADY_RELEASED", "Released results cannot change"); + return (await db.results.update(existing.id, fields))!; +} diff --git a/services/academics/src/features/grading/types.ts b/services/academics/src/features/grading/types.ts new file mode 100644 index 0000000..056f88f --- /dev/null +++ b/services/academics/src/features/grading/types.ts @@ -0,0 +1,50 @@ +export interface GradingSystem { + id: string; + schoolId: string; + name: string; + effectiveFrom: string; + active: boolean; + createdAt: string; + updatedAt: string; +} + +export interface GradeBand { + id: string; + schoolId: string; + systemId: string; + minPct: number; + maxPct: number; + grade: string; + points: number; + remark: string | null; + createdAt: string; +} + +/** subjectId null is the default pass mark; a row with a subject overrides it. */ +export interface PassMark { + id: string; + schoolId: string; + systemId: string; + subjectId: string | null; + passPct: number; + createdAt: string; +} + +export interface AssessmentResult { + id: string; + schoolId: string; + assessmentId: string; + studentId: string; + attemptId: string; + systemId: string; + raw: number; + total: number; + pct: number; + grade: string; + points: number; + remark: string | null; + isPass: boolean; + releasedAt: string | null; + createdAt: string; + updatedAt: string; +} diff --git a/services/academics/src/features/homework/assignments.ts b/services/academics/src/features/homework/assignments.ts new file mode 100644 index 0000000..2ff6913 --- /dev/null +++ b/services/academics/src/features/homework/assignments.ts @@ -0,0 +1,109 @@ +import { errors } from "@stackable/service-kit"; +import { STUDENT_ROLES, TEACHER_ROLES, domainError, isAdmin, requireRole, type Actor } from "../../lib/auth"; +import { emit } from "../../lib/outbox"; +import type { Db } from "../../store/tables"; +import type { CreateAssignmentInput } from "./schema"; +import type { Assignment, AssignmentStatus } from "./types"; + +type ItemInput = CreateAssignmentInput["items"][number]; + +function checkQuestion(item: ItemInput): void { + const { type, options } = item.question; + if (type === "multiple_choice") { + if (!options || options.filter((o) => o.isCorrect).length !== 1) { + throw errors.validation("A multiple choice question needs exactly one correct option", { stem: item.question.stem }); + } + } else if (options) { + throw errors.validation("Only multiple choice questions have options", { stem: item.question.stem }); + } +} + +async function ownedAssignment(db: Db, actor: Actor, id: string): Promise { + requireRole(actor, TEACHER_ROLES); + const assignment = await db.assignments.get(id); + if (!assignment) throw errors.notFound("Assignment not found"); + if (assignment.teacherId !== actor.id && !isAdmin(actor)) throw errors.forbidden("Not your assignment"); + return assignment; +} + +/** Creates a DRAFT assignment with its questions and items in the caller's transaction. */ +export async function createAssignment(db: Db, actor: Actor, input: CreateAssignmentInput): Promise { + requireRole(actor, TEACHER_ROLES); + if (input.closesAt < input.dueAt) throw errors.validation("closesAt must not be before dueAt"); + input.items.forEach(checkQuestion); + const assignment = await db.assignments.insert({ + classId: input.classId, subjectId: input.subjectId, teacherId: actor.id, title: input.title, + dueAt: input.dueAt, closesAt: input.closesAt, status: "DRAFT", + }); + for (const [index, item] of input.items.entries()) { + const q = item.question; + const question = await db.questions.insert({ type: q.type, stem: q.stem, marks: item.marks, answerKey: q.answerKey ?? null, createdBy: actor.id }); + for (const [position, o] of (q.options ?? []).entries()) { + await db.questionOptions.insert({ questionId: question.id, position: position + 1, label: o.label, isCorrect: o.isCorrect }); + } + await db.assignmentItems.insert({ assignmentId: assignment.id, questionId: question.id, position: index + 1, marks: item.marks }); + } + return assignment; +} + +/** Opens a DRAFT assignment to students and queues assignment.published for the notification service. */ +export async function publishAssignment(db: Db, actor: Actor, id: string): Promise { + const assignment = await ownedAssignment(db, actor, id); + if (assignment.status !== "DRAFT") throw domainError(409, "HOMEWORK_NOT_DRAFT", "Only a draft can be published"); + const published = (await db.assignments.update(id, { status: "OPEN" }))!; + await emit(db, "assignment.published", { + assignmentId: id, classId: published.classId, subjectId: published.subjectId, title: published.title, + dueAt: published.dueAt, closesAt: published.closesAt, + }); + return published; +} + +/** Stops new work on an OPEN assignment. */ +export async function closeAssignment(db: Db, actor: Actor, id: string): Promise { + const assignment = await ownedAssignment(db, actor, id); + if (assignment.status !== "OPEN") throw domainError(409, "HOMEWORK_NOT_OPEN", "Only an open assignment can be closed"); + return (await db.assignments.update(id, { status: "CLOSED" }))!; +} + +export async function listAssignments(db: Db, actor: Actor, query: { classId?: string; status?: AssignmentStatus }) { + if ((STUDENT_ROLES as readonly string[]).includes(actor.role)) { + if (!query.classId) throw errors.validation("classId is required", { fields: [{ path: "classId", message: "is required" }] }); + const open = await db.assignments.find({ classId: query.classId, status: ["OPEN", "CLOSED"] }); + const mine = await db.submissions.find({ studentId: actor.id, assignmentId: open.map((a) => a.id) }); + return open.map((a) => { + const s = mine.find((x) => x.assignmentId === a.id); + const { id, title, subjectId, classId, dueAt, closesAt, status } = a; + return { id, title, subjectId, classId, dueAt, closesAt, status, submission: s ? { id: s.id, status: s.status, late: s.late } : null }; + }); + } + requireRole(actor, TEACHER_ROLES); + return db.assignments.find({ teacherId: isAdmin(actor) ? undefined : actor.id, classId: query.classId, status: query.status }); +} + +/** Full assignment; answer keys and correct flags are included only for the teacher view. */ +export async function getAssignment(db: Db, actor: Actor, id: string) { + requireRole(actor, [...TEACHER_ROLES, ...STUDENT_ROLES]); + const assignment = await db.assignments.get(id); + const isTeacher = (TEACHER_ROLES as readonly string[]).includes(actor.role); + if (!assignment || (!isTeacher && assignment.status === "DRAFT")) throw errors.notFound("Assignment not found"); + if (isTeacher && assignment.teacherId !== actor.id && !isAdmin(actor)) throw errors.forbidden("Not your assignment"); + + const items = await db.assignmentItems.find({ assignmentId: id }, { orderBy: "position", limit: 100 }); + const questions = await db.questions.getMany(items.map((i) => i.questionId)); + const options = await db.questionOptions.find({ questionId: items.map((i) => i.questionId) }, { orderBy: "position", limit: 2000 }); + return { + ...assignment, + items: items.map((item) => { + const q = questions.find((x) => x.id === item.questionId)!; + const opts = options.filter((o) => o.questionId === q.id); + return { + id: item.id, position: item.position, marks: item.marks, + question: { + type: q.type, stem: q.stem, + ...(isTeacher ? { answerKey: q.answerKey } : {}), + ...(opts.length > 0 ? { options: opts.map((o) => ({ id: o.id, label: o.label, ...(isTeacher ? { isCorrect: o.isCorrect } : {}) })) } : {}), + }, + }; + }), + }; +} diff --git a/services/academics/src/features/homework/homework.test.ts b/services/academics/src/features/homework/homework.test.ts new file mode 100644 index 0000000..6c4fdf1 --- /dev/null +++ b/services/academics/src/features/homework/homework.test.ts @@ -0,0 +1,240 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + CLASS_ID, PARENT, SCHOOL_A, SCHOOL_B, STUDENT, STUDENT_2, SUBJECT_ID, adminToken, harness, parentToken, studentToken, teacherToken, token, useTestEnv, + type Harness, +} from "../../test-support"; + +const SECRET_KEY = "SECRET-MODEL-ANSWER-42"; +const DUE = "2026-03-05T12:00:00Z"; +const CLOSES = "2026-03-06T12:00:00Z"; + +let h: Harness; +beforeEach(() => { + useTestEnv(); + h = harness("2026-03-02T08:00:00.000Z"); +}); +afterEach(() => vi.restoreAllMocks()); + +async function seedPublished() { + const created = await h.call("POST", "/v1/homework/assignments", teacherToken(), { + title: "Fractions", + classId: CLASS_ID, + subjectId: SUBJECT_ID, + dueAt: DUE, + closesAt: CLOSES, + items: [ + { marks: 2, question: { type: "multiple_choice", stem: "1/2 + 1/2?", options: [{ label: "1", isCorrect: true }, { label: "2", isCorrect: false }] } }, + { marks: 5, question: { type: "short_answer", stem: "Explain a fraction", answerKey: SECRET_KEY } }, + ], + }); + expect(created.status).toBe(201); + const id = created.body.id as string; + expect((await h.call("POST", `/v1/homework/assignments/${id}/publish`, teacherToken())).status).toBe(200); + const view = await h.call("GET", `/v1/homework/assignments/${id}`, teacherToken()); + return { id, items: view.body.items as any[] }; +} + +async function startAndAnswer(assignmentId: string, items: any[], student = STUDENT) { + const opened = await h.call("POST", `/v1/homework/assignments/${assignmentId}/open`, studentToken(student), { classId: CLASS_ID }); + const submissionId = opened.body.submission.id as string; + const correct = items[0].question.options.find((o: any) => o.isCorrect); + await h.call("PUT", `/v1/homework/submissions/${submissionId}/answers/${items[0].id}`, studentToken(student), { selectedOptionId: correct.id }); + const saved = await h.call("PUT", `/v1/homework/submissions/${submissionId}/answers/${items[1].id}`, studentToken(student), { responseText: "MY-ANSWER-TEXT" }); + return { submissionId, shortAnswerId: saved.body.id as string }; +} + +describe("assignments", () => { + it("publishing writes the assignment.published outbox row", async () => { + await seedPublished(); + const rows = await h.store.tx(SCHOOL_A, (db) => db.outbox.find({ topic: "assignment.published" })); + expect(rows).toHaveLength(1); + expect(rows[0].payload).toMatchObject({ title: "Fractions", classId: CLASS_ID }); + expect(rows[0].publishedAt).toBeNull(); + }); + + it("refuses a student creating an assignment", async () => { + const res = await h.call("POST", "/v1/homework/assignments", studentToken(), {}); + expect(res.status).toBe(403); + }); + + it("returns the standard envelope for invalid input", async () => { + const res = await h.call("POST", "/v1/homework/assignments", teacherToken(), { title: "" }); + expect(res.status).toBe(400); + expect(res.body).toMatchObject({ code: "REQUEST_VALIDATION_FAILED" }); + expect(res.body.details.fields.length).toBeGreaterThan(0); + expect(res.body.requestId).toBeTruthy(); + }); + + it("rejects a multiple choice question with no correct option", async () => { + const res = await h.call("POST", "/v1/homework/assignments", teacherToken(), { + title: "T", classId: CLASS_ID, subjectId: SUBJECT_ID, dueAt: DUE, closesAt: CLOSES, + items: [{ marks: 1, question: { type: "multiple_choice", stem: "q", options: [{ label: "a", isCorrect: false }, { label: "b", isCorrect: false }] } }], + }); + expect(res.status).toBe(400); + }); + + it("students never see answer keys or correct flags", async () => { + const { id } = await seedPublished(); + const view = await h.call("GET", `/v1/homework/assignments/${id}`, studentToken()); + const text = JSON.stringify(view.body); + expect(text).not.toContain(SECRET_KEY); + expect(text).not.toContain("answerKey"); + expect(text).not.toContain("isCorrect"); + const list = await h.call("GET", `/v1/homework/assignments?classId=${CLASS_ID}`, studentToken()); + expect(list.body.items).toHaveLength(1); + expect(JSON.stringify(list.body)).not.toContain(SECRET_KEY); + }); + + it("hides drafts from students", async () => { + const created = await h.call("POST", "/v1/homework/assignments", teacherToken(), { + title: "Draft", classId: CLASS_ID, subjectId: SUBJECT_ID, dueAt: DUE, closesAt: CLOSES, + items: [{ marks: 1, question: { type: "research", stem: "r" } }], + }); + expect((await h.call("GET", `/v1/homework/assignments/${created.body.id}`, studentToken())).status).toBe(404); + }); +}); + +describe("submissions", () => { + it("auto-marks multiple choice on submit", async () => { + const { id, items } = await seedPublished(); + const { submissionId } = await startAndAnswer(id, items); + const res = await h.call("POST", `/v1/homework/submissions/${submissionId}/submit`, studentToken()); + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ status: "SUBMITTED", late: false }); + const answers = await h.store.tx(SCHOOL_A, (db) => db.answers.find({ submissionId })); + expect(answers.find((a) => a.itemId === items[0].id)?.marksAwarded).toBe(2); + }); + + it("flags a late submission but still accepts it", async () => { + const { id, items } = await seedPublished(); + const { submissionId } = await startAndAnswer(id, items); + h.setNow("2026-03-06T12:00:01.000Z"); // one second after closesAt + const res = await h.call("POST", `/v1/homework/submissions/${submissionId}/submit`, studentToken()); + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ status: "SUBMITTED", late: true }); + }); + + it("refuses submit while any answer is gate-locked, and allows it after unlock", async () => { + const { id, items } = await seedPublished(); + const { submissionId, shortAnswerId } = await startAndAnswer(id, items); + const aiToken = token("ai-service", "ai"); + expect((await h.call("POST", `/v1/answers/${shortAnswerId}/gate`, aiToken, { locked: true })).status).toBe(200); + + const blocked = await h.call("POST", `/v1/homework/submissions/${submissionId}/submit`, studentToken()); + expect(blocked.status).toBe(409); + expect(blocked.body).toMatchObject({ code: "HOMEWORK_AI_GATE_LOCKED" }); + + await h.call("POST", `/v1/answers/${shortAnswerId}/gate`, aiToken, { locked: false }); + expect((await h.call("POST", `/v1/homework/submissions/${submissionId}/submit`, studentToken())).status).toBe(200); + }); + + it("only the ai service may set the gate", async () => { + const { id, items } = await seedPublished(); + const { shortAnswerId } = await startAndAnswer(id, items); + expect((await h.call("POST", `/v1/answers/${shortAnswerId}/gate`, studentToken(), { locked: false })).status).toBe(403); + }); + + it("another student cannot touch the submission", async () => { + const { id, items } = await seedPublished(); + const { submissionId } = await startAndAnswer(id, items); + expect((await h.call("POST", `/v1/homework/submissions/${submissionId}/submit`, studentToken(STUDENT_2))).status).toBe(404); + }); + + it("refuses opening for the wrong class", async () => { + const { id } = await seedPublished(); + const res = await h.call("POST", `/v1/homework/assignments/${id}/open`, studentToken(), { classId: "class-9z" }); + expect(res.status).toBe(404); + }); + + it("refuses a second submit", async () => { + const { id, items } = await seedPublished(); + const { submissionId } = await startAndAnswer(id, items); + await h.call("POST", `/v1/homework/submissions/${submissionId}/submit`, studentToken()); + expect((await h.call("POST", `/v1/homework/submissions/${submissionId}/submit`, studentToken())).status).toBe(409); + }); +}); + +describe("review", () => { + async function submitted() { + const { id, items } = await seedPublished(); + const { submissionId } = await startAndAnswer(id, items); + await h.call("POST", `/v1/homework/submissions/${submissionId}/submit`, studentToken()); + return submissionId; + } + + it.each(["RETURNED", "REJECTED"])("%s requires a reason", async (decision) => { + const submissionId = await submitted(); + const none = await h.call("POST", `/v1/homework/submissions/${submissionId}/review`, teacherToken(), { decision }); + expect(none.status).toBe(400); + const blank = await h.call("POST", `/v1/homework/submissions/${submissionId}/review`, teacherToken(), { decision, reason: " " }); + expect(blank.status).toBe(400); + const ok = await h.call("POST", `/v1/homework/submissions/${submissionId}/review`, teacherToken(), { decision, reason: "Show your working" }); + expect(ok.body).toMatchObject({ status: decision }); + }); + + it("APPROVED needs no reason", async () => { + const submissionId = await submitted(); + const res = await h.call("POST", `/v1/homework/submissions/${submissionId}/review`, teacherToken(), { decision: "APPROVED" }); + expect(res.body).toMatchObject({ status: "APPROVED" }); + }); + + it("a returned submission goes back to in progress when the student edits it", async () => { + const { id, items } = await seedPublished(); + const { submissionId } = await startAndAnswer(id, items); + await h.call("POST", `/v1/homework/submissions/${submissionId}/submit`, studentToken()); + await h.call("POST", `/v1/homework/submissions/${submissionId}/review`, teacherToken(), { decision: "RETURNED", reason: "Redo" }); + await h.call("PUT", `/v1/homework/submissions/${submissionId}/answers/${items[1].id}`, studentToken(), { responseText: "better" }); + expect((await h.call("POST", `/v1/homework/submissions/${submissionId}/submit`, studentToken())).body).toMatchObject({ status: "SUBMITTED" }); + }); + + it("a student cannot review", async () => { + const submissionId = await submitted(); + expect((await h.call("POST", `/v1/homework/submissions/${submissionId}/review`, studentToken(), { decision: "APPROVED" })).status).toBe(403); + }); +}); + +describe("parent read model", () => { + it("returns status, dates, teacher comment and supplied AI counts, and never answers or keys", async () => { + const { id, items } = await seedPublished(); + const { submissionId } = await startAndAnswer(id, items); + await h.call("POST", `/v1/homework/submissions/${submissionId}/submit`, studentToken()); + await h.call("POST", `/v1/homework/submissions/${submissionId}/review`, teacherToken(), { decision: "RETURNED", reason: "Add a diagram" }); + + const res = await h.call("GET", `/v1/parents/${PARENT}/children/${STUDENT}/homework?aiHints=2&aiPractice=1`, parentToken()); + expect(res.status).toBe(200); + expect(res.body.aiUsage).toEqual({ hints: 2, practice: 1 }); + expect(res.body.items[0]).toMatchObject({ title: "Fractions", status: "RETURNED", teacherComment: "Add a diagram", late: false }); + + const text = JSON.stringify(res.body); + for (const leak of [SECRET_KEY, "MY-ANSWER-TEXT", "answerKey", "answer_key", "responseText", "isCorrect", "transcript"]) { + expect(text).not.toContain(leak); + } + }); + + it("a parent can only ask as themselves", async () => { + const res = await h.call("GET", `/v1/parents/${PARENT}/children/${STUDENT}/homework`, parentToken("parent-2")); + expect(res.status).toBe(403); + }); + + it("a student cannot use the parent endpoint", async () => { + expect((await h.call("GET", `/v1/parents/${PARENT}/children/${STUDENT}/homework`, studentToken())).status).toBe(403); + }); + + it("stores a parent acknowledgement", async () => { + const { id, items } = await seedPublished(); + const { submissionId } = await startAndAnswer(id, items); + const res = await h.call("POST", `/v1/parents/${PARENT}/children/${STUDENT}/homework/${submissionId}/review`, parentToken(), { acknowledged: true, comment: "Seen" }); + expect(res.body).toMatchObject({ acknowledged: true, comment: "Seen" }); + }); +}); + +describe("tenant isolation", () => { + it("school B cannot read, list, publish or open school A homework", async () => { + const { id } = await seedPublished(); + expect((await h.call("GET", `/v1/homework/assignments/${id}`, teacherToken(SCHOOL_B))).status).toBe(404); + expect((await h.call("GET", `/v1/homework/assignments`, teacherToken(SCHOOL_B))).body.items).toEqual([]); + expect((await h.call("POST", `/v1/homework/assignments/${id}/publish`, adminToken(SCHOOL_B))).status).toBe(404); + expect((await h.call("POST", `/v1/homework/assignments/${id}/open`, studentToken(STUDENT, SCHOOL_B), { classId: CLASS_ID })).status).toBe(404); + }); +}); + diff --git a/services/academics/src/features/homework/parent.ts b/services/academics/src/features/homework/parent.ts new file mode 100644 index 0000000..db2060b --- /dev/null +++ b/services/academics/src/features/homework/parent.ts @@ -0,0 +1,46 @@ +import { errors } from "@stackable/service-kit"; +import { PARENT_ROLES, isAdmin, requireRole, type Actor } from "../../lib/auth"; +import type { Db } from "../../store/tables"; + +export interface AiUsage { + hints: number; + practice: number; +} + +// Callers prove the parent-child link at the gateway; here a parent may only ask as themselves. +function requireSelf(actor: Actor, parentId: string): void { + if (isAdmin(actor)) return; + requireRole(actor, PARENT_ROLES); + if (actor.id !== parentId) throw errors.forbidden("You can only view your own children"); +} + +/** Parent read model: status, dates, teacher comment and AI usage counts. Never answers, keys or transcripts. */ +export async function parentHomework(db: Db, actor: Actor, parentId: string, studentId: string, aiUsage: AiUsage) { + requireSelf(actor, parentId); + const submissions = await db.submissions.find({ studentId }); + const assignments = await db.assignments.getMany(submissions.map((s) => s.assignmentId)); + const decisions = await db.reviewDecisions.find({ submissionId: submissions.map((s) => s.id) }, { orderBy: "createdAt" }); + return { + studentId, + aiUsage, + items: submissions.map((s) => { + const a = assignments.find((x) => x.id === s.assignmentId)!; + const latest = decisions.filter((d) => d.submissionId === s.id).pop(); + return { + submissionId: s.id, assignmentId: a.id, title: a.title, status: s.status, dueAt: a.dueAt, closesAt: a.closesAt, + submittedAt: s.submittedAt, late: s.late, teacherComment: latest?.reason ?? null, + }; + }), + }; +} + +/** Lets a parent acknowledge a child's reviewed work and leave a comment; one review per parent and submission. */ +export async function parentReview(db: Db, actor: Actor, parentId: string, studentId: string, submissionId: string, input: { acknowledged: boolean; comment?: string }) { + requireSelf(actor, parentId); + const submission = await db.submissions.get(submissionId); + if (!submission || submission.studentId !== studentId) throw errors.notFound("Submission not found"); + const fields = { acknowledged: input.acknowledged, comment: input.comment ?? null }; + const existing = (await db.parentReviews.find({ submissionId, parentId }, { limit: 1 }))[0]; + const saved = existing ? (await db.parentReviews.update(existing.id, fields))! : await db.parentReviews.insert({ submissionId, parentId, ...fields }); + return { submissionId, acknowledged: saved.acknowledged, comment: saved.comment }; +} diff --git a/services/academics/src/features/homework/routes.ts b/services/academics/src/features/homework/routes.ts new file mode 100644 index 0000000..82fa1e9 --- /dev/null +++ b/services/academics/src/features/homework/routes.ts @@ -0,0 +1,107 @@ +import type { createService } from "@stackable/service-kit"; +import { SERVICE_ROLES, STUDENT_ROLES, TEACHER_ROLES, actorOf, requireRole } from "../../lib/auth"; +import type { Clock } from "../../lib/clock"; +import { externalId, parse, readJson, uuid, object } from "../../lib/validate"; +import type { Store } from "../../store/tables"; +import { closeAssignment, createAssignment, getAssignment, listAssignments, publishAssignment } from "./assignments"; +import { parentHomework, parentReview } from "./parent"; +import { + createAssignmentSchema, gateSchema, listAssignmentsQuery, openSubmissionSchema, parentQuery, parentReviewSchema, reviewSchema, saveAnswerSchema, +} from "./schema"; +import { listSubmissions, openSubmission, reviewSubmission, saveAnswer, setAnswerGate, submitSubmission } from "./submissions"; + +const ids = (c: { req: { param(): Record } }, ...names: string[]) => + parse(object(Object.fromEntries(names.map((n) => [n, uuid()]))), c.req.param()) as Record; + +/** Registers the homework endpoints; every handler parses input, then calls one service function in one transaction. */ +export function homeworkRoutes(app: ReturnType, store: Store, now: Clock): void { + app.post("/v1/homework/assignments", async (c) => { + const actor = actorOf(c); + requireRole(actor, TEACHER_ROLES); + const input = parse(createAssignmentSchema, await readJson(c)); + return c.json(await store.tx(actor.schoolId, (db) => createAssignment(db, actor, input)), 201); + }); + + app.get("/v1/homework/assignments", async (c) => { + const actor = actorOf(c); + const query = parse(listAssignmentsQuery, c.req.query()); + return c.json({ items: await store.tx(actor.schoolId, (db) => listAssignments(db, actor, query)) }); + }); + + app.get("/v1/homework/assignments/:id", async (c) => { + const actor = actorOf(c); + const { id } = ids(c, "id"); + return c.json(await store.tx(actor.schoolId, (db) => getAssignment(db, actor, id))); + }); + + app.post("/v1/homework/assignments/:id/publish", async (c) => { + const actor = actorOf(c); + const { id } = ids(c, "id"); + return c.json(await store.tx(actor.schoolId, (db) => publishAssignment(db, actor, id))); + }); + + app.post("/v1/homework/assignments/:id/close", async (c) => { + const actor = actorOf(c); + const { id } = ids(c, "id"); + return c.json(await store.tx(actor.schoolId, (db) => closeAssignment(db, actor, id))); + }); + + app.get("/v1/homework/assignments/:id/submissions", async (c) => { + const actor = actorOf(c); + const { id } = ids(c, "id"); + return c.json({ items: await store.tx(actor.schoolId, (db) => listSubmissions(db, actor, id)) }); + }); + + app.post("/v1/homework/assignments/:id/open", async (c) => { + const actor = actorOf(c); + requireRole(actor, STUDENT_ROLES); + const { id } = ids(c, "id"); + const { classId } = parse(openSubmissionSchema, await readJson(c)); + return c.json(await store.tx(actor.schoolId, (db) => openSubmission(db, actor, id, classId))); + }); + + app.put("/v1/homework/submissions/:id/answers/:itemId", async (c) => { + const actor = actorOf(c); + const { id, itemId } = ids(c, "id", "itemId"); + const input = parse(saveAnswerSchema, await readJson(c)); + return c.json(await store.tx(actor.schoolId, (db) => saveAnswer(db, actor, id, itemId, input))); + }); + + app.post("/v1/homework/submissions/:id/submit", async (c) => { + const actor = actorOf(c); + const { id } = ids(c, "id"); + return c.json(await store.tx(actor.schoolId, (db) => submitSubmission(db, actor, id, now()))); + }); + + app.post("/v1/homework/submissions/:id/review", async (c) => { + const actor = actorOf(c); + requireRole(actor, TEACHER_ROLES); + const { id } = ids(c, "id"); + const input = parse(reviewSchema, await readJson(c)); + return c.json(await store.tx(actor.schoolId, (db) => reviewSubmission(db, actor, id, input))); + }); + + // Set by the ai service when a hint or practice step locks or unlocks submission. + app.post("/v1/answers/:id/gate", async (c) => { + const actor = actorOf(c); + requireRole(actor, SERVICE_ROLES); + const { id } = ids(c, "id"); + const { locked } = parse(gateSchema, await readJson(c)); + return c.json(await store.tx(actor.schoolId, (db) => setAnswerGate(db, actor, id, locked))); + }); + + app.get("/v1/parents/:parentId/children/:studentId/homework", async (c) => { + const actor = actorOf(c); + const { parentId, studentId } = parse(object({ parentId: externalId(), studentId: externalId() }), c.req.param()); + const q = parse(parentQuery, c.req.query()); + const usage = { hints: q.aiHints ?? 0, practice: q.aiPractice ?? 0 }; + return c.json(await store.tx(actor.schoolId, (db) => parentHomework(db, actor, parentId, studentId, usage))); + }); + + app.post("/v1/parents/:parentId/children/:studentId/homework/:submissionId/review", async (c) => { + const actor = actorOf(c); + const p = parse(object({ parentId: externalId(), studentId: externalId(), submissionId: uuid() }), c.req.param()); + const input = parse(parentReviewSchema, await readJson(c)); + return c.json(await store.tx(actor.schoolId, (db) => parentReview(db, actor, p.parentId, p.studentId, p.submissionId, input))); + }); +} diff --git a/services/academics/src/features/homework/schema.ts b/services/academics/src/features/homework/schema.ts new file mode 100644 index 0000000..0ea960e --- /dev/null +++ b/services/academics/src/features/homework/schema.ts @@ -0,0 +1,30 @@ +import { array, bool, externalId, int, isoDate, num, object, oneOf, optional, string, type Infer } from "../../lib/validate"; +import { ASSIGNMENT_STATUSES, QUESTION_TYPES, REVIEW_DECISIONS } from "./types"; + +const itemSchema = object({ + marks: num({ min: 0, max: 1000 }), + question: object({ + type: oneOf(QUESTION_TYPES), + stem: string(), + answerKey: optional(string()), + options: optional(array(object({ label: string({ max: 500 }), isCorrect: bool() }), { min: 2, max: 10 })), + }), +}); + +export const createAssignmentSchema = object({ + title: string({ max: 200 }), + classId: externalId(), + subjectId: externalId(), + dueAt: isoDate(), + closesAt: isoDate(), + items: array(itemSchema, { min: 1, max: 100 }), +}); +export type CreateAssignmentInput = Infer; + +export const listAssignmentsQuery = object({ classId: optional(externalId()), status: optional(oneOf(ASSIGNMENT_STATUSES)) }); +export const openSubmissionSchema = object({ classId: externalId() }); +export const saveAnswerSchema = object({ responseText: optional(string({ max: 10_000 })), selectedOptionId: optional(externalId()) }); +export const reviewSchema = object({ decision: oneOf(REVIEW_DECISIONS), reason: optional(string({ min: 0, max: 2000 })) }); +export const gateSchema = object({ locked: bool() }); +export const parentQuery = object({ aiHints: optional(int({ coerce: true, max: 1000 })), aiPractice: optional(int({ coerce: true, max: 1000 })) }); +export const parentReviewSchema = object({ acknowledged: bool(), comment: optional(string({ max: 2000 })) }); diff --git a/services/academics/src/features/homework/submissions.ts b/services/academics/src/features/homework/submissions.ts new file mode 100644 index 0000000..df3fc39 --- /dev/null +++ b/services/academics/src/features/homework/submissions.ts @@ -0,0 +1,105 @@ +import { errors } from "@stackable/service-kit"; +import { SERVICE_ROLES, STUDENT_ROLES, TEACHER_ROLES, domainError, isAdmin, requireRole, type Actor } from "../../lib/auth"; +import type { Db } from "../../store/tables"; +import type { Assignment, ReviewDecisionValue, Submission } from "./types"; + +const EDITABLE: Submission["status"][] = ["IN_PROGRESS", "RETURNED"]; +const NEEDS_REASON: ReviewDecisionValue[] = ["RETURNED", "REJECTED"]; + +async function ownSubmission(db: Db, actor: Actor, id: string): Promise<{ submission: Submission; assignment: Assignment }> { + requireRole(actor, STUDENT_ROLES); + const submission = await db.submissions.get(id); + // Another student's submission looks the same as a missing one. + if (!submission || submission.studentId !== actor.id) throw errors.notFound("Submission not found"); + const assignment = (await db.assignments.get(submission.assignmentId))!; + return { submission, assignment }; +} + +/** Starts (or resumes) the student's submission; only an OPEN assignment of the student's class. */ +export async function openSubmission(db: Db, actor: Actor, assignmentId: string, classId: string) { + requireRole(actor, STUDENT_ROLES); + const assignment = await db.assignments.get(assignmentId); + if (!assignment || assignment.classId !== classId || assignment.status !== "OPEN") throw errors.notFound("Assignment not found"); + let submission = (await db.submissions.find({ assignmentId, studentId: actor.id }, { limit: 1 }))[0]; + if (!submission) { + submission = await db.submissions.insert({ assignmentId, studentId: actor.id, status: "IN_PROGRESS", submittedAt: null, late: false }); + } + const answers = await db.answers.find({ submissionId: submission.id }); + return { submission, answers: answers.map(({ id, itemId, responseText, selectedOptionId }) => ({ id, itemId, responseText, selectedOptionId })) }; +} + +/** Saves one answer; a returned submission goes back to IN_PROGRESS when the student edits it. */ +export async function saveAnswer(db: Db, actor: Actor, submissionId: string, itemId: string, input: { responseText?: string; selectedOptionId?: string }) { + const { submission, assignment } = await ownSubmission(db, actor, submissionId); + if (!EDITABLE.includes(submission.status)) throw domainError(409, "HOMEWORK_NOT_EDITABLE", "This submission can no longer be edited"); + if (assignment.status !== "OPEN") throw domainError(409, "HOMEWORK_CLOSED", "This assignment is closed"); + const item = await db.assignmentItems.get(itemId); + if (!item || item.assignmentId !== assignment.id) throw errors.notFound("Item not found"); + if (input.selectedOptionId) { + const options = await db.questionOptions.find({ questionId: item.questionId }); + if (!options.some((o) => o.id === input.selectedOptionId)) throw errors.validation("Unknown option for this question"); + } + const fields = { responseText: input.responseText ?? null, selectedOptionId: input.selectedOptionId ?? null }; + const existing = (await db.answers.find({ submissionId, itemId }, { limit: 1 }))[0]; + const saved = existing ? (await db.answers.update(existing.id, fields))! : await db.answers.insert({ submissionId, itemId, marksAwarded: null, aiGateLocked: false, ...fields }); + if (submission.status === "RETURNED") await db.submissions.update(submissionId, { status: "IN_PROGRESS" }); + return { id: saved.id, itemId, responseText: saved.responseText, selectedOptionId: saved.selectedOptionId }; +} + +/** Sets the AI answer gate on one answer; only the ai service (or a service token) may do this. */ +export async function setAnswerGate(db: Db, actor: Actor, answerId: string, locked: boolean) { + requireRole(actor, SERVICE_ROLES); + const answer = await db.answers.update(answerId, { aiGateLocked: locked }); + if (!answer) throw errors.notFound("Answer not found"); + return { id: answer.id, aiGateLocked: answer.aiGateLocked }; +} + +/** Submits; accepted after closesAt but flagged late, refused while any answer is gate-locked. */ +export async function submitSubmission(db: Db, actor: Actor, submissionId: string, now: Date) { + const { submission, assignment } = await ownSubmission(db, actor, submissionId); + if (!EDITABLE.includes(submission.status)) throw domainError(409, "HOMEWORK_NOT_EDITABLE", "This submission was already submitted"); + if (assignment.status !== "OPEN") throw domainError(409, "HOMEWORK_CLOSED", "This assignment is closed"); + const answers = await db.answers.find({ submissionId }); + const locked = answers.filter((a) => a.aiGateLocked); + if (locked.length > 0) { + throw domainError(409, "HOMEWORK_AI_GATE_LOCKED", "Finish the AI practice steps before submitting", { answerIds: locked.map((a) => a.id) }); + } + + const items = await db.assignmentItems.find({ assignmentId: assignment.id }, { limit: 100 }); + const questions = await db.questions.getMany(items.map((i) => i.questionId)); + const options = await db.questionOptions.find({ questionId: questions.filter((q) => q.type === "multiple_choice").map((q) => q.id) }, { limit: 2000 }); + for (const answer of answers) { + const item = items.find((i) => i.id === answer.itemId)!; + if (questions.find((q) => q.id === item.questionId)?.type !== "multiple_choice") continue; + const correct = options.find((o) => o.questionId === item.questionId && o.isCorrect); + await db.answers.update(answer.id, { marksAwarded: correct && answer.selectedOptionId === correct.id ? item.marks : 0 }); + } + + const late = now.toISOString() > assignment.closesAt; + const updated = await db.submissions.update(submissionId, { status: "SUBMITTED", submittedAt: now.toISOString(), late }); + return { id: submissionId, status: updated!.status, submittedAt: updated!.submittedAt, late }; +} + +/** Records a review; RETURNED and REJECTED must say why so the student and parent can act on it. */ +export async function reviewSubmission(db: Db, actor: Actor, submissionId: string, input: { decision: ReviewDecisionValue; reason?: string }) { + requireRole(actor, TEACHER_ROLES); + const submission = await db.submissions.get(submissionId); + if (!submission) throw errors.notFound("Submission not found"); + const assignment = (await db.assignments.get(submission.assignmentId))!; + if (assignment.teacherId !== actor.id && !isAdmin(actor)) throw errors.forbidden("Not your assignment"); + if (NEEDS_REASON.includes(input.decision) && !input.reason) { + throw errors.validation("A reason is required to return or reject work", { fields: [{ path: "reason", message: "is required" }] }); + } + if (submission.status !== "SUBMITTED") throw domainError(409, "HOMEWORK_NOT_SUBMITTED", "Only submitted work can be reviewed"); + await db.reviewDecisions.insert({ submissionId, decision: input.decision, reason: input.reason || null, reviewerId: actor.id }); + await db.submissions.update(submissionId, { status: input.decision }); + return { id: submissionId, status: input.decision }; +} + +export async function listSubmissions(db: Db, actor: Actor, assignmentId: string) { + requireRole(actor, TEACHER_ROLES); + const assignment = await db.assignments.get(assignmentId); + if (!assignment) throw errors.notFound("Assignment not found"); + if (assignment.teacherId !== actor.id && !isAdmin(actor)) throw errors.forbidden("Not your assignment"); + return db.submissions.find({ assignmentId }); +} diff --git a/services/academics/src/features/homework/types.ts b/services/academics/src/features/homework/types.ts new file mode 100644 index 0000000..3aa565b --- /dev/null +++ b/services/academics/src/features/homework/types.ts @@ -0,0 +1,100 @@ +export const ASSIGNMENT_STATUSES = ["DRAFT", "OPEN", "CLOSED", "ARCHIVED"] as const; +export const QUESTION_TYPES = ["short_answer", "multiple_choice", "research"] as const; +export const SUBMISSION_STATUSES = ["IN_PROGRESS", "SUBMITTED", "APPROVED", "RETURNED", "REJECTED", "MISSED"] as const; +export const REVIEW_DECISIONS = ["APPROVED", "RETURNED", "REJECTED"] as const; + +export type AssignmentStatus = (typeof ASSIGNMENT_STATUSES)[number]; +export type HomeworkQuestionType = (typeof QUESTION_TYPES)[number]; +export type SubmissionStatus = (typeof SUBMISSION_STATUSES)[number]; +export type ReviewDecisionValue = (typeof REVIEW_DECISIONS)[number]; + +export interface Question { + id: string; + schoolId: string; + type: HomeworkQuestionType; + stem: string; + marks: number; + answerKey: string | null; + createdBy: string; + createdAt: string; + updatedAt: string; +} + +export interface QuestionOption { + id: string; + schoolId: string; + questionId: string; + position: number; + label: string; + isCorrect: boolean; + createdAt: string; +} + +export interface Assignment { + id: string; + schoolId: string; + classId: string; + subjectId: string; + teacherId: string; + title: string; + dueAt: string; + closesAt: string; + status: AssignmentStatus; + createdAt: string; + updatedAt: string; +} + +export interface AssignmentItem { + id: string; + schoolId: string; + assignmentId: string; + questionId: string; + position: number; + marks: number; + createdAt: string; +} + +export interface Submission { + id: string; + schoolId: string; + assignmentId: string; + studentId: string; + status: SubmissionStatus; + submittedAt: string | null; + late: boolean; + createdAt: string; + updatedAt: string; +} + +export interface Answer { + id: string; + schoolId: string; + submissionId: string; + itemId: string; + responseText: string | null; + selectedOptionId: string | null; + marksAwarded: number | null; + aiGateLocked: boolean; + createdAt: string; + updatedAt: string; +} + +export interface ReviewDecision { + id: string; + schoolId: string; + submissionId: string; + decision: ReviewDecisionValue; + reason: string | null; + reviewerId: string; + createdAt: string; +} + +export interface ParentReview { + id: string; + schoolId: string; + submissionId: string; + parentId: string; + acknowledged: boolean; + comment: string | null; + createdAt: string; +} diff --git a/services/academics/src/lib/auth.ts b/services/academics/src/lib/auth.ts new file mode 100644 index 0000000..06e3a6f --- /dev/null +++ b/services/academics/src/lib/auth.ts @@ -0,0 +1,48 @@ +import { AppError, errors } from "@stackable/service-kit"; +import type { ServiceTokenClaims } from "@stackable/contracts"; + +export const TEACHER_ROLES = ["teacher", "admin"] as const; +export const STUDENT_ROLES = ["student", "pupil"] as const; +export const PARENT_ROLES = ["parent"] as const; +export const ADMIN_ROLES = ["admin"] as const; +export const SERVICE_ROLES = ["ai", "service"] as const; // the ai service sets the answer gate + +export interface Actor { + id: string; + schoolId: string; + role: string; + /** True only when the gateway verified the school security code for this request. */ + canRelease: boolean; +} + +/** Builds an application error with a DOMAIN_CONDITION code. */ +export function domainError(status: 400 | 403 | 404 | 409, code: string, message: string, details: Record = {}) { + return new AppError(status, code, message, details); +} + +// The contracts schema strips unknown claims, so `rel` is read from the payload the middleware already verified. +function releaseClaim(authorization: string | undefined): boolean { + const token = authorization?.startsWith("Bearer ") ? authorization.slice("Bearer ".length) : ""; + try { + const payload = JSON.parse(Buffer.from(token.split(".")[1] ?? "", "base64url").toString("utf8")); + return payload.rel === true; + } catch { + return false; + } +} + +/** Reads the verified actor from a request that already passed requireServiceToken. */ +export function actorOf(c: { + var: { claims: ServiceTokenClaims }; + req: { header(name: string): string | undefined }; +}): Actor { + const { sub, schoolId, role } = c.var.claims; + if (!role) throw errors.forbidden("Token has no role"); + return { id: sub, schoolId, role, canRelease: releaseClaim(c.req.header("authorization")) }; +} + +export function requireRole(actor: Actor, roles: readonly string[]): void { + if (!roles.includes(actor.role)) throw errors.forbidden("Your role cannot do this"); +} + +export const isAdmin = (actor: Actor): boolean => actor.role === "admin"; diff --git a/services/academics/src/lib/clock.ts b/services/academics/src/lib/clock.ts new file mode 100644 index 0000000..4ed8359 --- /dev/null +++ b/services/academics/src/lib/clock.ts @@ -0,0 +1,2 @@ +/** Injected so tests and the deadline worker can move time; production uses the server clock. */ +export type Clock = () => Date; diff --git a/services/academics/src/lib/outbox.ts b/services/academics/src/lib/outbox.ts new file mode 100644 index 0000000..ab8ed37 --- /dev/null +++ b/services/academics/src/lib/outbox.ts @@ -0,0 +1,6 @@ +import type { Db } from "../store/tables"; + +/** Writes an event in the caller's transaction so the fact and the event commit or roll back together. */ +export async function emit(db: Db, topic: string, payload: Record): Promise { + await db.outbox.insert({ topic, payload, publishedAt: null }); +} diff --git a/services/academics/src/lib/validate.ts b/services/academics/src/lib/validate.ts new file mode 100644 index 0000000..71885f9 --- /dev/null +++ b/services/academics/src/lib/validate.ts @@ -0,0 +1,110 @@ +import { errors } from "@stackable/service-kit"; + +// academics cannot import zod (not in its dependencies), so this is a small schema kit with the same envelope. +interface Issue { + path: string; + message: string; +} + +export type Schema = (value: unknown, path: string, issues: Issue[]) => T; +export type Infer = S extends Schema ? T : never; + +const MAX_TEXT_LENGTH = 10_000; // bounds every free-text field so one request cannot store megabytes +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const EXTERNAL_ID_PATTERN = /^[\w.:-]{1,128}$/; // ids owned by other services may not be uuids +const ISO_PATTERN = /^\d{4}-\d{2}-\d{2}T/; + +function bad(issues: Issue[], path: string, message: string): T { + issues.push({ path, message }); + return undefined as T; +} + +export const string = + ({ min = 1, max = MAX_TEXT_LENGTH }: { min?: number; max?: number } = {}): Schema => + (v, p, i) => { + if (typeof v !== "string") return bad(i, p, "must be a string"); + const s = v.trim(); + if (s.length < min || s.length > max) return bad(i, p, `length must be ${min} to ${max}`); + return s; + }; + +export const uuid = (): Schema => (v, p, i) => + typeof v === "string" && UUID_PATTERN.test(v) ? v : bad(i, p, "must be a uuid"); + +export const externalId = (): Schema => (v, p, i) => + typeof v === "string" && EXTERNAL_ID_PATTERN.test(v) ? v : bad(i, p, "must be a valid id"); + +export const num = + ({ min = -1e9, max = 1e9 }: { min?: number; max?: number } = {}): Schema => + (v, p, i) => { + if (typeof v !== "number" || !Number.isFinite(v)) return bad(i, p, "must be a number"); + return v < min || v > max ? bad(i, p, `must be between ${min} and ${max}`) : v; + }; + +/** coerce accepts numeric strings so the same schema serves query parameters. */ +export const int = + ({ min = 0, max = 1_000_000, coerce = false }: { min?: number; max?: number; coerce?: boolean } = {}): Schema => + (v, p, i) => { + const n = coerce && typeof v === "string" && v.trim() !== "" ? Number(v) : v; + if (typeof n !== "number" || !Number.isInteger(n)) return bad(i, p, "must be an integer"); + return n < min || n > max ? bad(i, p, `must be between ${min} and ${max}`) : n; + }; + +export const bool = (): Schema => (v, p, i) => + typeof v === "boolean" ? v : bad(i, p, "must be true or false"); + +/** Accepts an ISO 8601 timestamp and returns it normalised to UTC. */ +export const isoDate = (): Schema => (v, p, i) => { + if (typeof v !== "string" || !ISO_PATTERN.test(v) || Number.isNaN(Date.parse(v))) { + return bad(i, p, "must be an ISO 8601 timestamp"); + } + return new Date(v).toISOString(); +}; + +export const oneOf = + (values: V): Schema => + (v, p, i) => + typeof v === "string" && values.includes(v) ? (v as V[number]) : bad(i, p, `must be one of ${values.join(", ")}`); + +export const optional = + (schema: Schema): Schema => + (v, p, i) => + v === undefined || v === null ? undefined : schema(v, p, i); + +export const array = + (item: Schema, { min = 0, max = 500 }: { min?: number; max?: number } = {}): Schema => + (v, p, i) => { + if (!Array.isArray(v)) return bad(i, p, "must be an array"); + if (v.length < min || v.length > max) return bad(i, p, `must have ${min} to ${max} items`); + return v.map((x, idx) => item(x, `${p}[${idx}]`, i)); + }; + +export const any = (): Schema => (v) => v; + +export function object>>(shape: S): Schema<{ [K in keyof S]: Infer }> { + return (v, p, i) => { + if (typeof v !== "object" || v === null || Array.isArray(v)) return bad(i, p, "must be an object"); + const out: Record = {}; + for (const key of Object.keys(shape)) { + out[key] = shape[key]((v as Record)[key], p ? `${p}.${key}` : key, i); + } + return out as { [K in keyof S]: Infer }; + }; +} + +/** Runs a schema over untrusted input and throws the standard 400 envelope listing every failing field. */ +export function parse(schema: Schema, input: unknown): T { + const issues: Issue[] = []; + const value = schema(input, "", issues); + if (issues.length > 0) throw errors.validation("Request validation failed", { fields: issues }); + return value; +} + +/** Reads a JSON body; a malformed body is a 400, never a 500. */ +export async function readJson(c: { req: { json(): Promise } }): Promise { + try { + return await c.req.json(); + } catch { + throw errors.validation("Malformed JSON body"); + } +} diff --git a/services/academics/src/main.ts b/services/academics/src/main.ts new file mode 100644 index 0000000..1d51d8b --- /dev/null +++ b/services/academics/src/main.ts @@ -0,0 +1,30 @@ +import { serve } from "@hono/node-server"; +import pg from "pg"; +import { buildApp } from "./app"; +import { autoSubmitDue } from "./features/cats/deadline-worker"; +import { createMemoryStore } from "./store/memory"; +import { createPgStore } from "./store/pg"; + +const DEFAULT_PORT = 4002; +const DEADLINE_SWEEP_MS = 15_000; // an expired attempt waits at most this long to be auto-submitted +const databaseUrl = process.env.DATABASE_URL; + +// Only dev may run without a database; production must fail at boot, not at first request. +if (!databaseUrl && process.env.NODE_ENV === "production") { + throw new Error("DATABASE_URL is required in production"); +} + +const pool = databaseUrl ? new pg.Pool({ connectionString: databaseUrl }) : undefined; +const store = pool ? createPgStore(pool) : createMemoryStore(); +const app = buildApp(store, pool, process.env.npm_package_version ?? "0.0.0"); +const server = serve({ fetch: app.fetch, port: Number(process.env.PORT ?? DEFAULT_PORT) }); + +// Run exactly one academics instance with this sweep enabled (see README). +const sweep = setInterval(() => { + autoSubmitDue(store, new Date()).catch((err) => console.error(JSON.stringify({ level: "error", msg: "deadline sweep failed", err: String(err) }))); +}, DEADLINE_SWEEP_MS); + +process.on("SIGTERM", () => { + clearInterval(sweep); + server.close(() => void pool?.end()); +}); diff --git a/services/academics/src/store/memory.ts b/services/academics/src/store/memory.ts new file mode 100644 index 0000000..4b39e93 --- /dev/null +++ b/services/academics/src/store/memory.ts @@ -0,0 +1,102 @@ +import { buildDb, TABLES, type Db, type Store } from "./tables"; +import { DEFAULT_FIND_LIMIT, type FindOptions, type NewRow, type Repo, type Row, type TableDef, type Where } from "./types"; + +type Tables = Map>>; + +export interface MemoryStoreOptions { + /** Test hook: throw from here to simulate a failing write inside a transaction. */ + onInsert?: (table: string) => void; +} + +function matches(row: Record, where: Where>): boolean { + return Object.entries(where).every(([key, want]) => { + if (want === undefined) return true; + const have = row[key] ?? null; + return Array.isArray(want) ? want.includes(have) : have === want; + }); +} + +function repoFor(def: TableDef, rows: Map>, schoolId: string, opts: MemoryStoreOptions): Repo { + const stamps = def.keys.includes("createdAt" as never); + const updates = def.keys.includes("updatedAt" as never); + // Reads mirror row-level security: another school's rows are simply not visible. + const own = () => [...rows.values()].filter((r) => r.schoolId === schoolId); + const out = (r: Row) => structuredClone(r) as unknown as T; + + return { + async insert(row: NewRow) { + opts.onInsert?.(def.name); + const now = new Date().toISOString(); + const full = { + ...structuredClone(row), + id: (row as { id?: string }).id ?? crypto.randomUUID(), + schoolId, + ...(stamps ? { createdAt: now } : {}), + ...(updates ? { updatedAt: now } : {}), + } as unknown as Row & Record; + rows.set(full.id, full); + return out(full); + }, + async get(id) { + const r = rows.get(id); + return r && r.schoolId === schoolId ? out(r) : undefined; + }, + async getMany(ids) { + return own().filter((r) => ids.includes(r.id)).map(out); + }, + async find(where = {}, o: FindOptions = {}) { + let list = own().filter((r) => matches(r, where as Where>)); + for (const [key, bound] of Object.entries(o.lt ?? {})) { + list = list.filter((r) => r[key] != null && (r[key] as string | number) < (bound as string | number)); + } + const order = o.orderBy ?? ("createdAt" in (list[0] ?? {}) ? "createdAt" : "id"); + const key = (r: Row & Record) => r[order] as string | number; + list.sort((a, b) => (key(a) < key(b) ? -1 : key(a) > key(b) ? 1 : a.id.localeCompare(b.id))); + return list.slice(0, o.limit ?? DEFAULT_FIND_LIMIT).map(out); + }, + async update(id, patch) { + const r = rows.get(id); + if (!r || r.schoolId !== schoolId) return undefined; + Object.assign(r, structuredClone(patch), updates ? { updatedAt: new Date().toISOString() } : {}); + return out(r); + }, + async updateMany(where, patch) { + const hit = own().filter((r) => matches(r, where as Where>)); + for (const r of hit) Object.assign(r, structuredClone(patch), updates ? { updatedAt: new Date().toISOString() } : {}); + return hit.length; + }, + }; +} + +/** In-memory store with the same contract as the pg store; transactions are serialised and roll back on throw. */ +export function createMemoryStore(opts: MemoryStoreOptions = {}): Store { + const data: Tables = new Map(Object.values(TABLES).map((d) => [d.name, new Map()])); + let queue: Promise = Promise.resolve(); + + const snapshot = () => new Map([...data].map(([name, rows]) => [name, structuredClone(rows)])); + + async function run(schoolId: string, fn: (db: Db) => Promise): Promise { + const before = snapshot(); + const db = buildDb((def) => repoFor(def, data.get(def.name)!, schoolId, opts)); + try { + return await fn(db); + } catch (err) { + for (const [name, rows] of before) data.set(name, rows); + throw err; + } + } + + return { + tx(schoolId, fn) { + const result = queue.then(() => run(schoolId, fn)); + queue = result.catch(() => undefined); + return result; + }, + async dueSchoolIds(now) { + const due = [...data.get("attempts")!.values()].filter( + (a) => a.status === "IN_PROGRESS" && typeof a.dueAt === "string" && a.dueAt < now.toISOString(), + ); + return [...new Set(due.map((a) => a.schoolId))]; + }, + }; +} diff --git a/services/academics/src/store/pg.test.ts b/services/academics/src/store/pg.test.ts new file mode 100644 index 0000000..e75e934 --- /dev/null +++ b/services/academics/src/store/pg.test.ts @@ -0,0 +1,55 @@ +import { readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { buildInsert, buildSelect, buildUpdate } from "./pg"; +import { TABLES } from "./tables"; +import type { Row, TableDef } from "./types"; + +const school = "11111111-1111-4111-8111-111111111111"; +const attempts = TABLES.attempts as unknown as TableDef; +const outbox = TABLES.outbox as unknown as TableDef; + +describe("sql builders", () => { + it("scopes every select by school and parameterises values", () => { + const q = buildSelect(attempts, school, { status: "IN_PROGRESS", id: ["a", "b"] }, { lt: { dueAt: "2026-01-01" } }); + expect(q.text).toBe( + "select * from academics.attempts where school_id = $1 and status = $2 and id = any($3) and due_at < $4 order by created_at, id limit $5", + ); + expect(q.values).toEqual([school, "IN_PROGRESS", ["a", "b"], "2026-01-01", 500]); + }); + + it("turns null into IS NULL", () => { + expect(buildSelect(attempts, school, { submittedAt: null }, {}).text).toContain("submitted_at is null"); + }); + + it("refuses a column that is not in the table definition", () => { + expect(() => buildSelect(attempts, school, { "id; drop table x": 1 }, {})).toThrow(/unknown column/); + expect(() => buildInsert(attempts, { evil: 1 })).toThrow(/unknown column/); + }); + + it("serialises json columns and bumps updated_at", () => { + expect(buildInsert(outbox, { topic: "t", payload: { a: 1 } }).values).toEqual(["t", '{"a":1}']); + const update = buildUpdate(attempts, school, { id: "x" }, { status: "MARKED" }); + expect(update.text).toBe("update academics.attempts set status = $1, updated_at = now() where school_id = $2 and id = $3 returning *"); + }); +}); + +describe("migrations match the table registry", () => { + const dir = join(__dirname, "..", "..", "migrations"); + const sql = readdirSync(dir).filter((f) => f.endsWith(".sql")).map((f) => readFileSync(join(dir, f), "utf8").split("-- +goose Down")[0]).join("\n"); + const snake = (k: string) => k.replace(/[A-Z]/g, (m) => `_${m.toLowerCase()}`); + + function columnsOf(table: string): string[] { + const body = new RegExp(`create table academics\\.${table} \\(([\\s\\S]*?)\\n\\);`).exec(sql)?.[1] ?? ""; + return body.split("\n").map((l) => l.trim()).filter((l) => /^[a-z_]+ /.test(l) && !/^(unique|check|primary|foreign|constraint)\b/.test(l)).map((l) => l.split(" ")[0]); + } + + it.each(Object.entries(TABLES))("%s", (_key, def) => { + expect(columnsOf(def.name).sort()).toEqual(def.keys.map(snake).sort()); + }); + + it("every tenant table has row-level security", () => { + for (const def of Object.values(TABLES)) expect(sql).toContain(`'${def.name}'`); + expect(sql).toContain("current_setting(''app.school_id'', true)::uuid"); + }); +}); diff --git a/services/academics/src/store/pg.ts b/services/academics/src/store/pg.ts new file mode 100644 index 0000000..084e088 --- /dev/null +++ b/services/academics/src/store/pg.ts @@ -0,0 +1,134 @@ +import type { Pool, PoolClient } from "pg"; +import { AppError, withSchool } from "@stackable/service-kit"; +import { buildDb, type Store } from "./tables"; +import { DEFAULT_FIND_LIMIT, type FindOptions, type NewRow, type Repo, type Row, type TableDef, type Where } from "./types"; + +const UNIQUE_VIOLATION = "23505"; +const SCHEMA = "academics"; + +const toSnake = (key: string) => key.replace(/[A-Z]/g, (m) => `_${m.toLowerCase()}`); +const toCamel = (col: string) => col.replace(/_([a-z])/g, (_, ch: string) => ch.toUpperCase()); + +interface Query { + text: string; + values: unknown[]; +} + +// Column names only ever come from the table definition, never from request data. +function column(def: TableDef, key: string): string { + if (!def.keys.includes(key as never)) throw new Error(`unknown column ${key} on ${def.name}`); + return toSnake(key); +} + +function encode(def: TableDef, key: string, value: unknown): unknown { + if (value === undefined || value === null) return null; + return def.json.includes(key as never) ? JSON.stringify(value) : value; +} + +function buildWhere(def: TableDef, schoolId: string, where: Where>, values: unknown[]): string { + values.push(schoolId); + const parts = [`school_id = $${values.length}`]; // also enforced by RLS; this is the second lock + for (const [key, want] of Object.entries(where)) { + if (want === undefined) continue; + const col = column(def, key); + if (want === null) parts.push(`${col} is null`); + else if (Array.isArray(want)) { + values.push(want); + parts.push(`${col} = any($${values.length})`); + } else { + values.push(encode(def, key, want)); + parts.push(`${col} = $${values.length}`); + } + } + return parts.join(" and "); +} + +export function buildInsert(def: TableDef, row: Record): Query { + const keys = Object.keys(row).filter((k) => row[k] !== undefined); + const cols = keys.map((k) => column(def, k)); + return { + text: `insert into ${SCHEMA}.${def.name} (${cols.join(", ")}) values (${keys.map((_, i) => `$${i + 1}`).join(", ")}) returning *`, + values: keys.map((k) => encode(def, k, row[k])), + }; +} + +export function buildSelect(def: TableDef, schoolId: string, where: Where>, opts: FindOptions>): Query { + const values: unknown[] = []; + const parts = [buildWhere(def, schoolId, where, values)]; + for (const [key, bound] of Object.entries(opts.lt ?? {})) { + values.push(bound); + parts.push(`${column(def, key)} < $${values.length}`); + } + const order = column(def, opts.orderBy ?? (def.keys.includes("createdAt" as never) ? "createdAt" : "id")); + values.push(opts.limit ?? DEFAULT_FIND_LIMIT); + return { + text: `select * from ${SCHEMA}.${def.name} where ${parts.join(" and ")} order by ${order}, id limit $${values.length}`, + values, + }; +} + +export function buildUpdate(def: TableDef, schoolId: string, where: Where>, patch: Record): Query { + const values: unknown[] = []; + const sets = Object.keys(patch) + .filter((k) => patch[k] !== undefined) + .map((k) => { + values.push(encode(def, k, patch[k])); + return `${column(def, k)} = $${values.length}`; + }); + if (def.keys.includes("updatedAt" as never)) sets.push("updated_at = now()"); + return { + text: `update ${SCHEMA}.${def.name} set ${sets.join(", ")} where ${buildWhere(def, schoolId, where, values)} returning *`, + values, + }; +} + +function fromDb(raw: Record): T { + const out: Record = {}; + for (const [col, value] of Object.entries(raw)) out[toCamel(col)] = value instanceof Date ? value.toISOString() : value; + return out as T; +} + +function repoFor(def: TableDef, client: PoolClient, schoolId: string): Repo { + const d = def as unknown as TableDef; + const run = async (q: Query) => (await client.query(q.text, q.values)).rows.map((r) => fromDb(r)); + const w = (x: unknown) => x as Where>; + + return { + async insert(row: NewRow) { + try { + const [created] = await run(buildInsert(d, { ...row, id: (row as { id?: string }).id ?? crypto.randomUUID(), schoolId })); + return created; + } catch (err) { + if ((err as { code?: string }).code === UNIQUE_VIOLATION) throw new AppError(409, "RESOURCE_ALREADY_EXISTS", "Resource already exists"); + throw err; + } + }, + async get(id) { + return (await run(buildSelect(d, schoolId, { id }, { limit: 1 })))[0] as T | undefined; + }, + async getMany(ids) { + return ids.length === 0 ? [] : ((await run(buildSelect(d, schoolId, { id: ids }, {}))) as T[]); + }, + async find(where = {}, opts: FindOptions = {}) { + return (await run(buildSelect(d, schoolId, w(where), opts as FindOptions>))) as T[]; + }, + async update(id, patch) { + return ((await run(buildUpdate(d, schoolId, { id }, patch)))[0] ?? undefined) as T | undefined; + }, + async updateMany(where, patch) { + return (await run(buildUpdate(d, schoolId, w(where), patch))).length; + }, + }; +} + +/** Postgres store: each tx is one withSchool transaction, so RLS sees app.school_id on every query. */ +export function createPgStore(pool: Pool): Store { + return { + tx: (schoolId, fn) => withSchool(pool, schoolId, (client) => fn(buildDb((def) => repoFor(def, client, schoolId)))), + async dueSchoolIds(now) { + // A security-definer function is the only way past RLS, and it returns ids only. + const { rows } = await pool.query(`select school_id from ${SCHEMA}.due_attempt_school_ids($1)`, [now.toISOString()]); + return rows.map((r: { school_id: string }) => r.school_id); + }, + }; +} diff --git a/services/academics/src/store/tables.ts b/services/academics/src/store/tables.ts new file mode 100644 index 0000000..7ba024e --- /dev/null +++ b/services/academics/src/store/tables.ts @@ -0,0 +1,53 @@ +import type { Row, OutboxRow, Repo, TableDef } from "./types"; +import type { + Answer, Assignment, AssignmentItem, ParentReview, Question, QuestionOption, ReviewDecision, Submission, +} from "../features/homework/types"; +import type { + Accommodation, Assessment, AssessmentQuestion, Attempt, AttemptAnswer, CalcSpec, PaperScan, +} from "../features/cats/types"; +import type { AssessmentResult, GradeBand, GradingSystem, PassMark } from "../features/grading/types"; + +function table(name: string, keys: readonly (keyof T & string)[], json: readonly (keyof T & string)[] = []): TableDef { + return { name, keys, json }; +} + +/** Every table in the academics schema; the migration test keeps these keys equal to the SQL columns. */ +export const TABLES = { + questions: table("questions", ["id", "schoolId", "type", "stem", "marks", "answerKey", "createdBy", "createdAt", "updatedAt"]), + questionOptions: table("question_options", ["id", "schoolId", "questionId", "position", "label", "isCorrect", "createdAt"]), + assignments: table("assignments", ["id", "schoolId", "classId", "subjectId", "teacherId", "title", "dueAt", "closesAt", "status", "createdAt", "updatedAt"]), + assignmentItems: table("assignment_items", ["id", "schoolId", "assignmentId", "questionId", "position", "marks", "createdAt"]), + submissions: table("assignment_submissions", ["id", "schoolId", "assignmentId", "studentId", "status", "submittedAt", "late", "createdAt", "updatedAt"]), + answers: table("answers", ["id", "schoolId", "submissionId", "itemId", "responseText", "selectedOptionId", "marksAwarded", "aiGateLocked", "createdAt", "updatedAt"]), + reviewDecisions: table("review_decisions", ["id", "schoolId", "submissionId", "decision", "reason", "reviewerId", "createdAt"]), + parentReviews: table("parent_reviews", ["id", "schoolId", "submissionId", "parentId", "acknowledged", "comment", "createdAt"]), + outbox: table("outbox", ["id", "schoolId", "topic", "payload", "createdAt", "publishedAt"], ["payload"]), + assessments: table("assessments", ["id", "schoolId", "title", "type", "classId", "subjectId", "teacherId", "durationMinutes", "opensAt", "closesAt", "status", "createdAt", "updatedAt"]), + assessmentQuestions: table("assessment_questions", ["id", "schoolId", "assessmentId", "position", "type", "stem", "marks", "choices", "answerKey", "createdAt"], ["choices", "answerKey"]), + calcSpecs: table("calc_specs", ["id", "schoolId", "questionId", "expectedValue", "tolerance", "createdAt"]), + accommodations: table("assessment_accommodations", ["id", "schoolId", "assessmentId", "studentId", "extraMinutes", "createdAt"]), + attempts: table("attempts", ["id", "schoolId", "assessmentId", "studentId", "status", "startedAt", "dueAt", "submittedAt", "extraMinutes", "score", "createdAt", "updatedAt"]), + attemptAnswers: table("attempt_answers", ["id", "schoolId", "attemptId", "questionId", "response", "marksAwarded", "createdAt", "updatedAt"], ["response"]), + paperScans: table("paper_scans", ["id", "schoolId", "attemptId", "objectKey", "createdBy", "createdAt"]), + gradingSystems: table("grading_systems", ["id", "schoolId", "name", "effectiveFrom", "active", "createdAt", "updatedAt"]), + gradeBands: table("grade_bands", ["id", "schoolId", "systemId", "minPct", "maxPct", "grade", "points", "remark", "createdAt"]), + passMarks: table("pass_marks", ["id", "schoolId", "systemId", "subjectId", "passPct", "createdAt"]), + results: table("assessment_results", ["id", "schoolId", "assessmentId", "studentId", "attemptId", "systemId", "raw", "total", "pct", "grade", "points", "remark", "isPass", "releasedAt", "createdAt", "updatedAt"]), +}; + +type RowOf = D extends TableDef ? R : never; + +/** What a service sees inside a transaction: one tenant-scoped repo per table. */ +export type Db = { [K in keyof typeof TABLES]: Repo> }; + +export function buildDb(make: (def: TableDef) => Repo): Db { + const entries = Object.entries(TABLES).map(([key, def]) => [key, make(def as unknown as TableDef)]); + return Object.fromEntries(entries) as Db; +} + +export interface Store { + /** Runs fn in one transaction scoped to a school; a throw rolls back every write. */ + tx(schoolId: string, fn: (db: Db) => Promise): Promise; + /** Schools that have in-progress attempts past their deadline; the only cross-tenant read. */ + dueSchoolIds(now: Date): Promise; +} diff --git a/services/academics/src/store/types.ts b/services/academics/src/store/types.ts new file mode 100644 index 0000000..fad2287 --- /dev/null +++ b/services/academics/src/store/types.ts @@ -0,0 +1,42 @@ +export type Row = { id: string; schoolId: string }; + +/** An array value means "any of"; null means IS NULL. */ +export type Where = { [K in keyof T]?: T[K] | T[K][] }; + +export const DEFAULT_FIND_LIMIT = 500; // every list is bounded + +export interface FindOptions { + limit?: number; + orderBy?: keyof T & string; + /** Rows whose column is strictly before the given value. */ + lt?: Partial; +} + +export type NewRow = Omit & { id?: string }; + +/** One tenant's view of one table; the school is fixed by the transaction that created it. */ +export interface Repo { + insert(row: NewRow): Promise; + get(id: string): Promise; + getMany(ids: string[]): Promise; + find(where?: Where, opts?: FindOptions): Promise; + update(id: string, patch: Partial): Promise; + updateMany(where: Where, patch: Partial): Promise; +} + +export interface TableDef { + name: string; + keys: readonly (keyof T & string)[]; + json: readonly (keyof T & string)[]; + /** Phantom field that carries the row type. */ + readonly _row?: T; +} + +export interface OutboxRow { + id: string; + schoolId: string; + topic: string; + payload: Record; + createdAt: string; + publishedAt: string | null; +} diff --git a/services/academics/src/test-support.ts b/services/academics/src/test-support.ts new file mode 100644 index 0000000..e54c6de --- /dev/null +++ b/services/academics/src/test-support.ts @@ -0,0 +1,79 @@ +import { createHmac } from "node:crypto"; +import { vi } from "vitest"; +import { buildApp } from "./app"; +import { createMemoryStore, type MemoryStoreOptions } from "./store/memory"; + +export const SCHOOL_A = "11111111-1111-4111-8111-111111111111"; +export const SCHOOL_B = "22222222-2222-4222-8222-222222222222"; +export const TEACHER = "teacher-1"; +export const STUDENT = "student-1"; +export const STUDENT_2 = "student-2"; +export const PARENT = "parent-1"; +export const CLASS_ID = "class-7a"; +export const SUBJECT_ID = "subject-math"; +const SECRET = "test-secret-test-secret-test-secret-1234"; + +const b64 = (value: unknown) => Buffer.from(JSON.stringify(value)).toString("base64url"); + +/** Signs an HS256 service token with node:crypto so tests can set the `rel` claim the shared signer lacks. */ +export function token(sub: string, role: string, schoolId = SCHOOL_A, extra: Record = {}): string { + const header = b64({ alg: "HS256", typ: "JWT" }); + const now = Math.floor(Date.now() / 1000); + const payload = b64({ iss: "stackable-gateway", aud: "academics", sub, role, schoolId, iat: now, exp: now + 300, ...extra }); + const signature = createHmac("sha256", SECRET).update(`${header}.${payload}`).digest("base64url"); + return `${header}.${payload}.${signature}`; +} + +export const teacherToken = (schoolId = SCHOOL_A) => token(TEACHER, "teacher", schoolId); +export const adminToken = (schoolId = SCHOOL_A) => token("admin-1", "admin", schoolId); +export const releaserToken = (schoolId = SCHOOL_A) => token(TEACHER, "teacher", schoolId, { rel: true }); +export const studentToken = (id = STUDENT, schoolId = SCHOOL_A) => token(id, "student", schoolId); +export const parentToken = (id = PARENT, schoolId = SCHOOL_A) => token(id, "parent", schoolId); + +export function useTestEnv(): void { + process.env.SERVICE_TOKEN_SECRET = SECRET; + vi.spyOn(console, "log").mockImplementation(() => undefined); +} + +export interface Harness { + store: ReturnType; + setNow(iso: string): void; + call(method: string, path: string, bearer: string | undefined, body?: unknown): Promise<{ status: number; body: any }>; +} + +/** An app on the in-memory store with a movable server clock. */ +export function harness(startIso = "2026-03-01T08:00:00.000Z", opts: MemoryStoreOptions = {}): Harness { + let current = new Date(startIso); + const store = createMemoryStore(opts); + const app = buildApp(store, undefined, "test", () => current); + return { + store, + setNow: (iso) => { + current = new Date(iso); + }, + async call(method, path, bearer, body) { + const res = await app.request(path, { + method, + headers: { ...(bearer ? { authorization: `Bearer ${bearer}` } : {}), "content-type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + return { status: res.status, body: await res.json().catch(() => undefined) }; + }, + }; +} + +/** Creates and activates a whole-number grading system (A 80-100 ... E 0-39, pass 40). */ +export async function seedGrading(h: Harness, schoolId = SCHOOL_A): Promise { + const created = await h.call("POST", "/v1/grading/systems", adminToken(schoolId), { + name: "Default", + effectiveFrom: "2026-01-01T00:00:00Z", + bands: [ + { minPct: 80, maxPct: 100, grade: "A", points: 12, remark: "Excellent" }, + { minPct: 60, maxPct: 79, grade: "B", points: 9 }, + { minPct: 40, maxPct: 59, grade: "C", points: 6 }, + { minPct: 0, maxPct: 39, grade: "E", points: 1 }, + ], + passMarks: [{ passPct: 40 }], + }); + await h.call("POST", `/v1/grading/systems/${created.body.id}/activate`, adminToken(schoolId)); +} diff --git a/services/academics/tsconfig.json b/services/academics/tsconfig.json new file mode 100644 index 0000000..cc2c43a --- /dev/null +++ b/services/academics/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2023", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "types": ["node"] + }, + "include": ["src"] +} diff --git a/services/ai/README.md b/services/ai/README.md new file mode 100644 index 0000000..02903ed --- /dev/null +++ b/services/ai/README.md @@ -0,0 +1,47 @@ +# @stackable/ai + +AI tutor service (TypeScript, Hono). Hints, the answer gate, practice sets, parent usage counts and study tools. Contract: `packages/contracts/openapi/ai.yaml`. Default port 4003. + +## Run + +``` +pnpm --filter @stackable/ai dev # tsx watch +pnpm --filter @stackable/ai typecheck +pnpm test # from the repo root +``` + +| Env | Meaning | +|---|---| +| `SERVICE_TOKEN_SECRET` | Shared HS256 secret (32+ chars) | +| `DATABASE_URL` | Postgres; without it the in-memory store is used (dev only, required in production) | +| `GROQ_API_KEY`, `GROQ_MODEL` | Switches from the fake provider to Groq once the key is set | +| `AI_PROVIDER=fake` | Forces the fake provider even when a key exists | +| `PORT` | Default 4003 | + +## Layout + +- `gate.ts`, `allowance.ts`, `tutorGuard.ts`, `points.ts`, `policy.ts`: pure modules, no I/O, fully tested. +- `provider.ts`, `fakeProvider.ts`, `groqProvider.ts`: the `AiProvider` interface and its two implementations. +- `store.ts` (interface and in-memory), `pgStore.ts` (Postgres via `withSchool`), `service.ts` (use cases), `app.ts` (routes, validation, roles). +- `migrations/`: goose SQL, schema `ai`, RLS on `school_id` for every table. + +## Answer gate (SDD P2-12) + +States: OPEN, ASSISTED (hints 1 to cap), PRACTICE_REQUIRED, PRACTICE_OPEN, DONE, LOCKED_REDO. Limits come from `AiPolicy` (`hintsPerQuestion` 3, `practiceItems` 5, points, base allowance), never from constants. `locked(state)` is true for PRACTICE_REQUIRED, PRACTICE_OPEN and LOCKED_REDO; the academics service must not unlock later content while it is true. + +## Rules the service enforces + +- Answer keys: only teacher and system tokens may send `answerKey` (hint) or `correct` (gate). It is used for the leak check and never stored in `ai_messages` or returned. Practice keys live in `ai.practice_items.answer_key` and are never returned. +- Every provider reply is leak-checked; a leak is regenerated once with the rewrite prompt, then replaced by a safe hint. +- Provider failure returns 502 `UPSTREAM_FAILED` with a friendly message and does not spend the hint. +- Allowance: AI replies per school-local day (UTC+3) against a limit from the points tier. Over the limit returns 429 `AI_ALLOWANCE_EXHAUSTED`. A tier never changes the hint cap. +- `GET /v1/usage/summary` returns counts and points only. The gateway must check the parent-child link before calling it. +- Study tools are always `aiGenerated: true, needsTeacherReview: true`. Quiz answers are returned to teacher and system tokens only. + +## Errors + +Standard envelope `{ error, code, details, requestId }`. Codes used here besides the service-kit ones: `AI_ALLOWANCE_EXHAUSTED` (429), `AI_OUTPUT_INVALID` (502). + +## Not covered by tests + +`pgStore.ts` and the migration have not been run against a database in this environment. diff --git a/services/ai/migrations/00001_create_ai_schema.sql b/services/ai/migrations/00001_create_ai_schema.sql new file mode 100644 index 0000000..56cf9e9 --- /dev/null +++ b/services/ai/migrations/00001_create_ai_schema.sql @@ -0,0 +1,146 @@ +-- +goose Up +create schema if not exists ai; + +create table ai.ai_policies ( + id uuid primary key default gen_random_uuid(), + school_id uuid not null unique, + hints_per_question int not null default 3 check (hints_per_question >= 0), + practice_items int not null default 5 check (practice_items >= 0), + unaided_points int not null default 10 check (unaided_points >= 0), + practice_set_points int not null default 5 check (practice_set_points >= 0), + daily_allowance_base int not null default 10 check (daily_allowance_base >= 0), + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create table ai.allowance_tiers ( + id uuid primary key default gen_random_uuid(), + school_id uuid not null, + min_points int not null check (min_points >= 0), + daily_requests int not null check (daily_requests >= 0), + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + unique (school_id, min_points) +); + +create table ai.ai_sessions ( + id uuid primary key default gen_random_uuid(), + school_id uuid not null, + student_id uuid not null, + question_id text not null, + kind text not null check (kind in ('tutor', 'tool')), + state jsonb not null, + hints_used int not null default 0, + practice_completed boolean not null default false, + version int not null default 0, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + unique (school_id, student_id, question_id), + unique (school_id, id) +); + +create table ai.ai_messages ( + id uuid primary key default gen_random_uuid(), + school_id uuid not null, + session_id uuid not null references ai.ai_sessions (id), + role text not null check (role in ('student', 'assistant')), + -- Never holds an answer key; the service stores only the safe reply. + content text not null, + leak_flagged boolean not null default false, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create table ai.practice_sets ( + id uuid primary key default gen_random_uuid(), + school_id uuid not null, + session_id uuid not null references ai.ai_sessions (id), + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +-- answer_key is read only by the ai service role, never by a student-facing path. +create table ai.practice_items ( + id uuid primary key default gen_random_uuid(), + school_id uuid not null, + set_id uuid not null references ai.practice_sets (id), + position int not null check (position >= 0), + question text not null, + answer_key text not null, + difficulty int not null check (difficulty between 1 and 5), + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + unique (set_id, position) +); + +-- Append-only: points are never edited, only added. +create table ai.achievement_ledger ( + id uuid primary key default gen_random_uuid(), + school_id uuid not null, + student_id uuid not null, + question_id text not null, + reason text not null check (reason in ('UNAIDED_CORRECT', 'PRACTICE_SET_DONE')), + points int not null check (points >= 0), + created_at timestamptz not null default now(), + unique (school_id, student_id, question_id, reason) +); + +create index ai_messages_session_idx on ai.ai_messages (session_id); +create index ai_messages_school_created_idx on ai.ai_messages (school_id, created_at); +create index practice_sets_session_idx on ai.practice_sets (session_id); +create index practice_items_set_idx on ai.practice_items (set_id); +create index ai_sessions_student_idx on ai.ai_sessions (school_id, student_id); +create index achievement_ledger_student_idx on ai.achievement_ledger (school_id, student_id); + +-- +goose StatementBegin +create function ai.reject_ledger_change() returns trigger language plpgsql as $$ +begin + raise exception 'achievement_ledger is append-only'; +end; +$$; +-- +goose StatementEnd + +create trigger achievement_ledger_append_only + before update or delete on ai.achievement_ledger + for each row execute function ai.reject_ledger_change(); + +alter table ai.ai_policies enable row level security; +alter table ai.allowance_tiers enable row level security; +alter table ai.ai_sessions enable row level security; +alter table ai.ai_messages enable row level security; +alter table ai.practice_sets enable row level security; +alter table ai.practice_items enable row level security; +alter table ai.achievement_ledger enable row level security; + +create policy tenant_isolation on ai.ai_policies + using (school_id = current_setting('app.school_id', true)::uuid) + with check (school_id = current_setting('app.school_id', true)::uuid); +create policy tenant_isolation on ai.allowance_tiers + using (school_id = current_setting('app.school_id', true)::uuid) + with check (school_id = current_setting('app.school_id', true)::uuid); +create policy tenant_isolation on ai.ai_sessions + using (school_id = current_setting('app.school_id', true)::uuid) + with check (school_id = current_setting('app.school_id', true)::uuid); +create policy tenant_isolation on ai.ai_messages + using (school_id = current_setting('app.school_id', true)::uuid) + with check (school_id = current_setting('app.school_id', true)::uuid); +create policy tenant_isolation on ai.practice_sets + using (school_id = current_setting('app.school_id', true)::uuid) + with check (school_id = current_setting('app.school_id', true)::uuid); +create policy tenant_isolation on ai.practice_items + using (school_id = current_setting('app.school_id', true)::uuid) + with check (school_id = current_setting('app.school_id', true)::uuid); +create policy tenant_isolation on ai.achievement_ledger + using (school_id = current_setting('app.school_id', true)::uuid) + with check (school_id = current_setting('app.school_id', true)::uuid); + +-- +goose Down +drop table ai.achievement_ledger; +drop table ai.practice_items; +drop table ai.practice_sets; +drop table ai.ai_messages; +drop table ai.ai_sessions; +drop table ai.allowance_tiers; +drop table ai.ai_policies; +drop function ai.reject_ledger_change(); +drop schema ai; diff --git a/services/ai/package.json b/services/ai/package.json new file mode 100644 index 0000000..90c73b3 --- /dev/null +++ b/services/ai/package.json @@ -0,0 +1,24 @@ +{ + "name": "@stackable/ai", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "tsx watch src/main.ts", + "start": "tsx src/main.ts", + "typecheck": "tsc --noEmit", + "test": "vitest run --root ../.." + }, + "dependencies": { + "@hono/node-server": "^1.19.0", + "@stackable/contracts": "workspace:*", + "@stackable/service-kit": "workspace:*", + "pg": "^8.16.0" + }, + "devDependencies": { + "@types/node": "^24.0.0", + "@types/pg": "^8.15.0", + "tsx": "^4.20.0", + "typescript": "^5.9.0" + } +} diff --git a/services/ai/src/allowance.test.ts b/services/ai/src/allowance.test.ts new file mode 100644 index 0000000..0b4fbc6 --- /dev/null +++ b/services/ai/src/allowance.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import { allowanceStatus, dailyLimit, dayWindow } from "./allowance"; +import type { AllowanceTier } from "./policy"; + +const tiers: AllowanceTier[] = [ + { minPoints: 100, dailyRequests: 25 }, + { minPoints: 50, dailyRequests: 15 }, + { minPoints: 200, dailyRequests: 40 }, +]; + +describe("dailyLimit", () => { + it("uses the base below the first tier", () => expect(dailyLimit(49, tiers, 10)).toBe(10)); + it("picks the highest reached tier regardless of list order", () => { + expect(dailyLimit(50, tiers, 10)).toBe(15); + expect(dailyLimit(150, tiers, 10)).toBe(25); + expect(dailyLimit(5000, tiers, 10)).toBe(40); + }); + it("uses the base with no tiers", () => expect(dailyLimit(999, [], 7)).toBe(7)); + it("only returns a request count, never a hint cap", () => { + expect(typeof dailyLimit(200, tiers, 10)).toBe("number"); + }); +}); + +describe("allowanceStatus", () => { + it("is AVAILABLE while below the limit", () => { + expect(allowanceStatus(10, 9)).toEqual({ status: "AVAILABLE", limit: 10, remaining: 1 }); + }); + it("is EXHAUSTED at and over the limit", () => { + expect(allowanceStatus(10, 10).status).toBe("EXHAUSTED"); + expect(allowanceStatus(10, 12).remaining).toBe(0); + }); +}); + +describe("dayWindow", () => { + it("starts at local midnight (UTC+3 means 21:00 UTC the day before)", () => { + const { from, to } = dayWindow(new Date("2026-03-10T12:00:00Z")); + expect(from.toISOString()).toBe("2026-03-09T21:00:00.000Z"); + expect(to.toISOString()).toBe("2026-03-10T21:00:00.000Z"); + }); + it("rolls to a new day after local midnight", () => { + const before = dayWindow(new Date("2026-03-10T20:59:59Z")); + const after = dayWindow(new Date("2026-03-10T21:00:00Z")); + expect(after.from.getTime()).toBe(before.to.getTime()); + }); +}); diff --git a/services/ai/src/allowance.ts b/services/ai/src/allowance.ts new file mode 100644 index 0000000..5ff690a --- /dev/null +++ b/services/ai/src/allowance.ts @@ -0,0 +1,35 @@ +import type { AllowanceTier } from "./policy"; + +export type AllowanceStatus = "AVAILABLE" | "EXHAUSTED"; + +export interface Allowance { + status: AllowanceStatus; + limit: number; + remaining: number; +} + +export const EAST_AFRICA_UTC_OFFSET_MINUTES = 180; // schools reset the allowance at local midnight +const MS_PER_MINUTE = 60_000; +const MS_PER_DAY = 86_400_000; + +/** Daily request limit: the highest tier whose min points the student has reached, else the base. */ +export function dailyLimit(points: number, tiers: readonly AllowanceTier[], base: number): number { + const sorted = [...tiers].sort((a, b) => a.minPoints - b.minPoints); + let limit = base; + for (const tier of sorted) if (points >= tier.minPoints) limit = tier.dailyRequests; + return limit; +} + +/** The school-local day containing `now`, as UTC instants [from, to). */ +export function dayWindow(now: Date, offsetMinutes = EAST_AFRICA_UTC_OFFSET_MINUTES): { from: Date; to: Date } { + const shifted = now.getTime() + offsetMinutes * MS_PER_MINUTE; + const startLocal = Math.floor(shifted / MS_PER_DAY) * MS_PER_DAY; + const from = new Date(startLocal - offsetMinutes * MS_PER_MINUTE); + return { from, to: new Date(from.getTime() + MS_PER_DAY) }; +} + +/** AVAILABLE while today's used requests are below the limit. */ +export function allowanceStatus(limit: number, usedToday: number): Allowance { + const remaining = Math.max(0, limit - usedToday); + return { status: remaining > 0 ? "AVAILABLE" : "EXHAUSTED", limit, remaining }; +} diff --git a/services/ai/src/app.test.ts b/services/ai/src/app.test.ts new file mode 100644 index 0000000..aacce8c --- /dev/null +++ b/services/ai/src/app.test.ts @@ -0,0 +1,323 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { signServiceToken } from "@stackable/service-kit"; +import { buildApp } from "./app"; +import { FakeProvider } from "./fakeProvider"; +import { MemoryStore } from "./store"; + +const KEY = "photosynthesis"; + +beforeEach(() => { + process.env.SERVICE_TOKEN_SECRET = "test-secret-test-secret-test-secret-1234"; + vi.spyOn(console, "log").mockImplementation(() => undefined); +}); +afterEach(() => { + delete process.env.SERVICE_TOKEN_SECRET; + vi.restoreAllMocks(); +}); + +function setup(options: { now?: () => Date } = {}) { + const store = new MemoryStore(); + const provider = new FakeProvider(); + const app = buildApp({ store, provider, version: "1", now: options.now }); + const call = async (method: string, path: string, who: { sub: string; role: string; school?: string }, body?: unknown) => { + const token = await signServiceToken({ audience: "ai", sub: who.sub, schoolId: who.school ?? "school-a", role: who.role }); + const res = await app.request(path, { + method, + headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + const text = await res.text(); + return { status: res.status, text, json: JSON.parse(text) as Record }; + }; + return { app, store, provider, call }; +} + +const student = { sub: "stu-1", role: "student" }; +const teacher = { sub: "tch-1", role: "teacher" }; +const parent = { sub: "par-1", role: "parent" }; +const hintBody = { questionId: "q1", questionText: "How do plants make food?" }; + +describe("POST /v1/tutor/hint", () => { + it("gives a hint and moves the gate to ASSISTED", async () => { + const { call } = setup(); + const res = await call("POST", "/v1/tutor/hint", student, hintBody); + expect(res.status).toBe(200); + expect(res.json).toMatchObject({ state: { kind: "ASSISTED", hints: 1 }, locked: false, leakFlagged: false }); + expect(typeof res.json.hint).toBe("string"); + expect(res.json.allowance).toMatchObject({ status: "AVAILABLE" }); + }); + + it("locks on the fourth hint without calling the provider", async () => { + const { call, provider } = setup(); + for (let i = 0; i < 3; i++) expect((await call("POST", "/v1/tutor/hint", student, hintBody)).status).toBe(200); + const callsBefore = provider.calls.length; + const res = await call("POST", "/v1/tutor/hint", student, hintBody); + expect(res.json).toMatchObject({ hint: null, state: { kind: "LOCKED_REDO" }, locked: true }); + expect(provider.calls.length).toBe(callsBefore); + expect((await call("POST", "/v1/tutor/hint", student, hintBody)).status).toBe(409); + }); + + it("refuses an answer key from a student", async () => { + const { call, provider } = setup(); + const res = await call("POST", "/v1/tutor/hint", student, { ...hintBody, answerKey: KEY }); + expect(res.status).toBe(403); + expect(res.json.code).toBe("AUTH_FORBIDDEN"); + expect(provider.calls).toHaveLength(0); + }); + + it("refuses a parent and a missing token", async () => { + const { call, app } = setup(); + expect((await call("POST", "/v1/tutor/hint", parent, hintBody)).status).toBe(403); + expect((await app.request("/v1/tutor/hint", { method: "POST" })).status).toBe(401); + }); + + it("returns 400 with the failing fields for a bad body", async () => { + const { call } = setup(); + const res = await call("POST", "/v1/tutor/hint", student, { questionId: "", answerState: "MAYBE" }); + expect(res.status).toBe(400); + expect(res.json.code).toBe("REQUEST_VALIDATION_FAILED"); + const paths = res.json.details.fields.map((f: { path: string }) => f.path); + expect(paths).toEqual(expect.arrayContaining(["questionId", "questionText", "answerState"])); + }); + + it("regenerates a leaking reply once and keeps the key out of storage and responses", async () => { + const { call, provider, store } = setup(); + provider.queue(`The answer is ${KEY}.`, "What do plants need from the sun?"); + const res = await call("POST", "/v1/tutor/hint", teacher, { ...hintBody, studentId: "stu-1", answerKey: KEY }); + expect(res.status).toBe(200); + expect(res.json).toMatchObject({ hint: "What do plants need from the sun?", leakFlagged: true }); + expect(provider.calls.map((c) => c.purpose)).toEqual(["hint", "rewrite"]); + expect(res.text).not.toContain(KEY); + expect(JSON.stringify(store.messages)).not.toContain(KEY); + expect(store.messages.some((m) => m.leakFlagged)).toBe(true); + }); + + it("falls back to a safe hint when the rewrite leaks too", async () => { + const { call, provider, store } = setup(); + provider.queue(`It is ${KEY}`, `Definitely ${KEY}`); + const res = await call("POST", "/v1/tutor/hint", teacher, { ...hintBody, studentId: "stu-1", answerKey: KEY }); + expect(res.status).toBe(200); + expect(res.json.leakFlagged).toBe(true); + expect(res.text).not.toContain(KEY); + expect(JSON.stringify(store.messages)).not.toContain(KEY); + }); + + it("returns a friendly 502 envelope when the provider fails and does not spend the hint", async () => { + const { call, provider } = setup(); + provider.failNext(1); + const res = await call("POST", "/v1/tutor/hint", student, hintBody); + expect(res.status).toBe(502); + expect(res.json).toMatchObject({ code: "UPSTREAM_FAILED" }); + expect(res.json.error).toMatch(/try again/i); + expect(typeof res.json.requestId).toBe("string"); + expect(res.text).not.toMatch(/fake provider failure/); + const next = await call("POST", "/v1/tutor/hint", student, hintBody); + expect(next.json.state).toMatchObject({ kind: "ASSISTED", hints: 1 }); + }); +}); + +describe("helped question to practice to done", () => { + async function toPracticeRequired(ctx: ReturnType) { + await ctx.call("POST", "/v1/tutor/hint", student, hintBody); + return ctx.call("POST", "/v1/gate/answer", teacher, { questionId: "q1", studentId: "stu-1", correct: true }); + } + + it("runs the whole path and awards points once", async () => { + const ctx = setup(); + const required = await toPracticeRequired(ctx); + expect(required.json).toMatchObject({ state: { kind: "PRACTICE_REQUIRED" }, locked: true, pointsAwarded: 0 }); + + const practice = await ctx.call("POST", "/v1/tutor/practice", student, hintBody); + expect(practice.status).toBe(200); + expect(practice.json.items).toHaveLength(5); + expect(practice.text).not.toMatch(/answer/i); + expect(practice.json.locked).toBe(true); + + // The fake provider's item i has the answer 2*i+30. + let last: Record = {}; + for (let i = 0; i < 5; i++) { + const wrong = await ctx.call("POST", "/v1/gate/answer", student, { questionId: "q1", practiceAnswer: "nope" }); + expect(wrong.json).toMatchObject({ correct: false, locked: true }); + last = (await ctx.call("POST", "/v1/gate/answer", student, { questionId: "q1", practiceAnswer: String(2 * i + 30) })).json; + } + expect(last).toMatchObject({ state: { kind: "DONE", unaided: false }, locked: false, pointsAwarded: 5 }); + + const summary = await ctx.call("GET", "/v1/usage/summary?studentId=stu-1", parent); + expect(summary.json).toEqual({ studentId: "stu-1", questionsHelped: 1, hintsUsed: 1, practiceDone: 1, points: 5 }); + }); + + it("awards unaided points for a correct answer with no help", async () => { + const { call } = setup(); + const res = await call("POST", "/v1/gate/answer", teacher, { questionId: "q9", studentId: "stu-1", correct: true }); + expect(res.json).toMatchObject({ state: { kind: "DONE", unaided: true }, pointsAwarded: 10 }); + const again = await call("POST", "/v1/gate/answer", teacher, { questionId: "q9", studentId: "stu-1", correct: true }); + expect(again.status).toBe(409); + }); + + it("rejects practice before it is required and a verdict from a student", async () => { + const { call } = setup(); + expect((await call("POST", "/v1/tutor/practice", student, hintBody)).status).toBe(409); + const res = await call("POST", "/v1/gate/answer", student, { questionId: "q1", correct: true }); + expect(res.status).toBe(403); + }); + + it("serves the same practice set again instead of regenerating", async () => { + const ctx = setup(); + await toPracticeRequired(ctx); + const first = await ctx.call("POST", "/v1/tutor/practice", student, hintBody); + const calls = ctx.provider.calls.length; + const second = await ctx.call("POST", "/v1/tutor/practice", student, hintBody); + expect(second.json.items).toEqual(first.json.items); + expect(ctx.provider.calls.length).toBe(calls); + }); + + it("applies the same hint cap to practice items and checks leaks against the item key", async () => { + const ctx = setup(); + await toPracticeRequired(ctx); + await ctx.call("POST", "/v1/tutor/practice", student, hintBody); + // Item 1's key is 30; the first reply leaks it, the rewrite does not. + ctx.provider.queue("It makes thirty.", "Add the tens first."); + const hint = await ctx.call("POST", "/v1/tutor/hint", student, hintBody); + expect(hint.json).toMatchObject({ hint: "Add the tens first.", leakFlagged: true, state: { kind: "PRACTICE_OPEN", hints: 1 } }); + await ctx.call("POST", "/v1/tutor/hint", student, hintBody); + await ctx.call("POST", "/v1/tutor/hint", student, hintBody); + const locked = await ctx.call("POST", "/v1/tutor/hint", student, hintBody); + expect(locked.json).toMatchObject({ state: { kind: "LOCKED_REDO" }, locked: true }); + }); + + it("regenerates unusable practice output once, then returns a 502", async () => { + const ctx = setup(); + await toPracticeRequired(ctx); + ctx.provider.queue("not json", JSON.stringify({ items: [{ question: "Q", answer: "1", difficulty: 3 }] })); + const res = await ctx.call("POST", "/v1/tutor/practice", student, hintBody); + expect(res.status).toBe(502); + expect(res.json.code).toBe("AI_OUTPUT_INVALID"); + }); + + it("drops duplicate and out-of-range practice items", async () => { + const ctx = setup(); + await toPracticeRequired(ctx); + const items = [ + { question: "How do plants make food?", answer: "a", difficulty: 3 }, + { question: "Dup?", answer: "b", difficulty: 3 }, + { question: "dup", answer: "c", difficulty: 3 }, + { question: "Hard?", answer: "d", difficulty: 5 }, + ...[1, 2, 3, 4, 5].map((n) => ({ question: `Fresh ${n}?`, answer: String(n), difficulty: 3 })), + ]; + ctx.provider.queue(JSON.stringify({ items })); + const res = await ctx.call("POST", "/v1/tutor/practice", student, hintBody); + expect(res.status).toBe(200); + const questions = res.json.items.map((i: { question: string }) => i.question); + expect(questions).toEqual(["Dup?", "Fresh 1?", "Fresh 2?", "Fresh 3?", "Fresh 4?"]); + }); +}); + +describe("GET /v1/usage/summary", () => { + it("returns counts only, with no message content", async () => { + const { call } = setup(); + await call("POST", "/v1/tutor/hint", student, { ...hintBody, studentMessage: "my secret worry" }); + const res = await call("GET", "/v1/usage/summary?studentId=stu-1", parent); + expect(Object.keys(res.json).sort()).toEqual(["hintsUsed", "points", "practiceDone", "questionsHelped", "studentId"]); + expect(res.text).not.toContain("secret"); + }); + + it("lets a student read only their own summary and needs studentId", async () => { + const { call } = setup(); + expect((await call("GET", "/v1/usage/summary?studentId=stu-2", student)).status).toBe(403); + expect((await call("GET", "/v1/usage/summary?studentId=stu-1", student)).status).toBe(200); + expect((await call("GET", "/v1/usage/summary", parent)).status).toBe(400); + }); +}); + +describe("tenant isolation", () => { + it("keeps sessions, points and counts per school", async () => { + const { call } = setup(); + await call("POST", "/v1/tutor/hint", student, hintBody); + await call("POST", "/v1/gate/answer", teacher, { questionId: "q2", studentId: "stu-1", correct: true }); + + const other = { sub: "stu-1", role: "student", school: "school-b" }; + const first = await call("POST", "/v1/tutor/hint", other, hintBody); + expect(first.json.state).toMatchObject({ kind: "ASSISTED", hints: 1 }); + const summary = await call("GET", "/v1/usage/summary?studentId=stu-1", { sub: "par-9", role: "parent", school: "school-b" }); + expect(summary.json).toMatchObject({ questionsHelped: 1, points: 0 }); + const home = await call("GET", "/v1/usage/summary?studentId=stu-1", parent); + expect(home.json).toMatchObject({ points: 10 }); + }); +}); + +describe("daily allowance", () => { + it("returns 429 when exhausted and resets on a new day", async () => { + let now = new Date("2026-03-10T08:00:00Z"); + const ctx = setup({ now: () => now }); + ctx.store.policies.set("school-a", { hintsPerQuestion: 9, practiceItems: 5, unaidedPoints: 10, practiceSetPoints: 5, dailyAllowanceBase: 2 }); + for (let i = 0; i < 2; i++) expect((await ctx.call("POST", "/v1/tutor/hint", student, hintBody)).status).toBe(200); + const blocked = await ctx.call("POST", "/v1/tutor/hint", student, hintBody); + expect(blocked.status).toBe(429); + expect(blocked.json.code).toBe("AI_ALLOWANCE_EXHAUSTED"); + + now = new Date("2026-03-11T08:00:00Z"); + expect((await ctx.call("POST", "/v1/tutor/hint", student, hintBody)).status).toBe(200); + }); + + it("gives more requests from a points tier without raising the hint cap", async () => { + const ctx = setup(); + ctx.store.policies.set("school-a", { hintsPerQuestion: 1, practiceItems: 5, unaidedPoints: 10, practiceSetPoints: 5, dailyAllowanceBase: 1 }); + ctx.store.tiers.set("school-a", [{ minPoints: 10, dailyRequests: 5 }]); + await ctx.call("POST", "/v1/tutor/hint", student, hintBody); + expect((await ctx.call("POST", "/v1/tutor/hint", student, { ...hintBody, questionId: "q2" })).status).toBe(429); + + await ctx.call("POST", "/v1/gate/answer", teacher, { questionId: "q3", studentId: "stu-1", correct: true }); + expect((await ctx.call("POST", "/v1/tutor/hint", student, { ...hintBody, questionId: "q4" })).status).toBe(200); + // Cap stays at 1 hint: the second hint on a question locks it. + const second = await ctx.call("POST", "/v1/tutor/hint", student, { ...hintBody, questionId: "q4" }); + expect(second.json.state.kind).toBe("LOCKED_REDO"); + }); +}); + +describe("POST /v1/tools/:kind", () => { + it("returns flashcards flagged for teacher review", async () => { + const { call } = setup(); + const res = await call("POST", "/v1/tools/flashcards", student, { text: "Cells are the unit of life." }); + expect(res.status).toBe(200); + expect(res.json).toMatchObject({ kind: "flashcards", aiGenerated: true, needsTeacherReview: true }); + expect(res.json.content.length).toBeGreaterThan(0); + }); + + it("returns a summary", async () => { + const { call } = setup(); + const res = await call("POST", "/v1/tools/summary", student, { text: "Long text." }); + expect(res.json.content).toMatchObject({ summary: expect.any(String), keyPoints: expect.any(Array) }); + }); + + it("includes quiz answers for teachers only", async () => { + const { call } = setup(); + const s = await call("POST", "/v1/tools/quiz", student, { text: "Cells." }); + const t = await call("POST", "/v1/tools/quiz", teacher, { text: "Cells." }); + expect(s.json.content[0]).not.toHaveProperty("answerIndex"); + expect(t.json.content[0]).toHaveProperty("answerIndex", 1); + }); + + it("returns 404 for an unknown tool and 502 for unusable output", async () => { + const { call, provider } = setup(); + expect((await call("POST", "/v1/tools/essay", student, { text: "x" })).status).toBe(404); + provider.queue("nope", "still nope"); + const res = await call("POST", "/v1/tools/flashcards", student, { text: "x" }); + expect(res.status).toBe(502); + expect(res.json.code).toBe("AI_OUTPUT_INVALID"); + }); + + it("counts toward the daily allowance and returns 502 on provider failure", async () => { + const { call, provider } = setup(); + provider.failNext(1); + expect((await call("POST", "/v1/tools/summary", student, { text: "x" })).status).toBe(502); + }); +}); + +describe("probes", () => { + it("/ready is degraded, not 503, with no database and the fake provider", async () => { + const { app } = setup(); + const res = await app.request("/ready"); + expect(res.status).toBe(200); + expect(await res.json()).toMatchObject({ status: "degraded" }); + }); +}); diff --git a/services/ai/src/app.ts b/services/ai/src/app.ts new file mode 100644 index 0000000..90a3c01 --- /dev/null +++ b/services/ai/src/app.ts @@ -0,0 +1,124 @@ +import type { Pool } from "pg"; +import { createService, errors, requireServiceToken, type ReadyCheck } from "@stackable/service-kit"; +// hono is not a direct dependency, so the context type is borrowed from the middleware signature. +type Ctx = Parameters>[0]; +import type { AiProvider } from "./provider"; +import { AiService, type Actor, type ToolKind } from "./service"; +import type { Store } from "./store"; +import { Validator } from "./validate"; + +export const SERVICE_NAME = "ai"; + +const QUESTION_ID_MAX = 100; +const QUESTION_TEXT_MAX = 4000; +const STUDENT_MESSAGE_MAX = 2000; +const ANSWER_MAX = 500; +const STUDY_TEXT_MAX = 8000; +const TOOL_COUNT_MAX = 20; +const TOOL_COUNT_DEFAULT = 5; +const TOOL_KINDS: readonly ToolKind[] = ["flashcards", "summary", "quiz"]; + +export interface AppDeps { + store: Store; + provider: AiProvider; + /** Optional so dev can run on the in-memory store without a database. */ + pool?: Pool; + version: string; + now?: () => Date; +} + +async function readBody(c: Ctx): Promise { + let raw: unknown; + try { + raw = await c.req.json(); + } catch { + throw errors.validation("Malformed JSON body"); + } + return Validator.from(raw); +} + +function actorOf(c: Ctx): Actor { + const claims = c.var.claims; + return { schoolId: claims.schoolId, userId: claims.sub, role: claims.role }; +} + +/** Builds the ai app: validation and role checks here, rules in AiService. */ +export function buildApp({ store, provider, pool, version, now }: AppDeps) { + const service = new AiService(store, provider, now); + const checks: ReadyCheck[] = [ + pool + ? { name: "database", run: () => pool.query("select 1") } + : { name: "database", required: false, run: () => Promise.reject(new Error("DATABASE_URL not set")) }, + { + name: "provider", + required: false, + run: () => (provider.enabled ? Promise.resolve() : Promise.reject(new Error(`${provider.name} disabled`))), + }, + ]; + const app = createService({ name: SERVICE_NAME, version, checks }); + const guard = requireServiceToken(SERVICE_NAME); + + app.post("/v1/tutor/hint", guard, async (c) => { + const v = await readBody(c); + const input = { + questionId: v.string("questionId", QUESTION_ID_MAX), + questionText: v.string("questionText", QUESTION_TEXT_MAX), + answerState: v.oneOf("answerState", ["UNANSWERED", "WRONG"] as const, "UNANSWERED"), + helpsUsed: v.int("helpsUsed", 0, 100, 0), + studentMessage: v.optionalString("studentMessage", STUDENT_MESSAGE_MAX), + answerKey: v.optionalString("answerKey", ANSWER_MAX), + studentId: v.optionalString("studentId", QUESTION_ID_MAX), + }; + v.done(); + return c.json(await service.requestHint(actorOf(c), input)); + }); + + app.post("/v1/tutor/practice", guard, async (c) => { + const v = await readBody(c); + const input = { + questionId: v.string("questionId", QUESTION_ID_MAX), + questionText: v.string("questionText", QUESTION_TEXT_MAX), + difficulty: v.has("difficulty") ? v.int("difficulty", 1, 5, 3) : undefined, + studentId: v.optionalString("studentId", QUESTION_ID_MAX), + }; + v.done(); + return c.json(await service.createPractice(actorOf(c), input)); + }); + + app.post("/v1/gate/answer", guard, async (c) => { + const v = await readBody(c); + const input = { + questionId: v.string("questionId", QUESTION_ID_MAX), + correct: v.has("correct") ? v.bool("correct") : undefined, + practiceAnswer: v.optionalString("practiceAnswer", ANSWER_MAX), + studentId: v.optionalString("studentId", QUESTION_ID_MAX), + }; + v.done(); + return c.json(await service.submitAnswer(actorOf(c), input)); + }); + + app.get("/v1/usage/summary", guard, async (c) => { + const studentId = c.req.query("studentId"); + if (!studentId || studentId.length > QUESTION_ID_MAX) { + throw errors.validation("Request validation failed", { + fields: [{ path: "studentId", message: "is required" }], + }); + } + return c.json(await service.usageSummary(actorOf(c), studentId)); + }); + + app.post("/v1/tools/:kind", guard, async (c) => { + const kind = c.req.param("kind") as ToolKind; + if (!TOOL_KINDS.includes(kind)) throw errors.notFound("Unknown study tool"); + const v = await readBody(c); + const input = { + text: v.string("text", STUDY_TEXT_MAX), + count: v.int("count", 1, TOOL_COUNT_MAX, TOOL_COUNT_DEFAULT), + studentId: v.optionalString("studentId", QUESTION_ID_MAX), + }; + v.done(); + return c.json(await service.runTool(actorOf(c), kind, input)); + }); + + return app; +} diff --git a/services/ai/src/fakeProvider.ts b/services/ai/src/fakeProvider.ts new file mode 100644 index 0000000..d97ee3a --- /dev/null +++ b/services/ai/src/fakeProvider.ts @@ -0,0 +1,62 @@ +import { ProviderError, type AiProvider, type CompleteInput, type Completion } from "./provider"; + +export type Script = string | ((input: CompleteInput) => string); + +const FAKE_PRACTICE_COUNT = 10; // more than any policy asks for; the service takes the first N + +function defaultReply(input: CompleteInput): string { + switch (input.purpose) { + case "practice": + return JSON.stringify({ + items: Array.from({ length: FAKE_PRACTICE_COUNT }, (_, i) => ({ + question: `Practice ${i + 1}: what is ${i + 10} plus ${i + 20}?`, + answer: String(2 * i + 30), + difficulty: 3, + })), + }); + case "flashcards": + return JSON.stringify({ cards: [{ front: "Term", back: "Meaning" }, { front: "Idea", back: "Explanation" }] }); + case "summary": + return JSON.stringify({ summary: "A short summary of the text.", keyPoints: ["First point", "Second point"] }); + case "quiz": + return JSON.stringify({ + questions: [{ question: "Which option is right?", options: ["A", "B", "C", "D"], answerIndex: 1 }], + }); + default: + return "Think about what the question is asking. What is the first step you could take?"; + } +} + +/** Deterministic provider for dev and tests: scripted replies first, then built-in defaults. */ +export class FakeProvider implements AiProvider { + readonly name = "fake"; + readonly enabled = true; + readonly calls: CompleteInput[] = []; + private scripts: Script[]; + private failuresLeft: number; + + constructor(options: { scripts?: Script[]; failures?: number } = {}) { + this.scripts = [...(options.scripts ?? [])]; + this.failuresLeft = options.failures ?? 0; + } + + /** Makes the next `count` calls fail; Infinity fails them all. */ + failNext(count: number): void { + this.failuresLeft = count; + } + + queue(...scripts: Script[]): void { + this.scripts.push(...scripts); + } + + async complete(input: CompleteInput): Promise { + this.calls.push(input); + if (this.failuresLeft > 0) { + this.failuresLeft -= 1; + throw new ProviderError("fake provider failure", 503); + } + const script = this.scripts.shift(); + const text = script === undefined ? defaultReply(input) : typeof script === "string" ? script : script(input); + return { text, usage: { inputTokens: input.messages.length * 10, outputTokens: Math.ceil(text.length / 4) } }; + } +} diff --git a/services/ai/src/gate.test.ts b/services/ai/src/gate.test.ts new file mode 100644 index 0000000..33ccf75 --- /dev/null +++ b/services/ai/src/gate.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from "vitest"; +import { locked, transition, type GateEvent, type GateState } from "./gate"; +import { DEFAULT_POLICY, type AiPolicy } from "./policy"; + +const policy = DEFAULT_POLICY; +const hint: GateEvent = { type: "HINT" }; +const right: GateEvent = { type: "ANSWER", correct: true }; +const wrong: GateEvent = { type: "ANSWER", correct: false }; +const start: GateEvent = { type: "START_PRACTICE" }; + +const OPEN: GateState = { kind: "OPEN" }; +const ASSISTED = (hints: number): GateState => ({ kind: "ASSISTED", hints }); +const REQUIRED: GateState = { kind: "PRACTICE_REQUIRED" }; +const PRACTICE = (done: number, hints = 0): GateState => ({ kind: "PRACTICE_OPEN", done, hints }); +const DONE_UNAIDED: GateState = { kind: "DONE", unaided: true }; +const DONE_HELPED: GateState = { kind: "DONE", unaided: false }; +const LOCKED: GateState = { kind: "LOCKED_REDO" }; + +type Row = [string, GateState, GateEvent, GateState, boolean, string | null]; + +const table: Row[] = [ + ["open + correct", OPEN, right, DONE_UNAIDED, true, "UNAIDED_CORRECT"], + ["open + wrong stays", OPEN, wrong, OPEN, true, null], + ["open + first hint", OPEN, hint, ASSISTED(1), true, null], + ["open + start practice rejected", OPEN, start, OPEN, false, null], + ["assisted 1 + hint", ASSISTED(1), hint, ASSISTED(2), true, null], + ["assisted 2 + hint", ASSISTED(2), hint, ASSISTED(3), true, null], + ["assisted 3 + 4th hint locks", ASSISTED(3), hint, LOCKED, true, null], + ["assisted + correct needs practice", ASSISTED(2), right, REQUIRED, true, null], + ["assisted + wrong stays", ASSISTED(2), wrong, ASSISTED(2), true, null], + ["required + start opens practice", REQUIRED, start, PRACTICE(0), true, null], + ["required + hint rejected", REQUIRED, hint, REQUIRED, false, null], + ["required + answer rejected", REQUIRED, right, REQUIRED, false, null], + ["practice + wrong stays", PRACTICE(2, 1), wrong, PRACTICE(2, 1), true, null], + ["practice + correct moves on and resets hints", PRACTICE(2, 2), right, PRACTICE(3, 0), true, null], + ["practice + hint counts", PRACTICE(1, 0), hint, PRACTICE(1, 1), true, null], + ["practice + 4th hint on an item locks", PRACTICE(1, 3), hint, LOCKED, true, null], + ["practice + fifth correct is done", PRACTICE(4), right, DONE_HELPED, true, "PRACTICE_SET_DONE"], + ["practice + start rejected", PRACTICE(1), start, PRACTICE(1), false, null], + ["done + anything rejected (hint)", DONE_UNAIDED, hint, DONE_UNAIDED, false, null], + ["done + anything rejected (answer)", DONE_HELPED, right, DONE_HELPED, false, null], + ["locked + hint rejected (no AI)", LOCKED, hint, LOCKED, false, null], + ["locked + wrong stays", LOCKED, wrong, LOCKED, true, null], + ["locked + correct redo finishes with no points", LOCKED, right, DONE_HELPED, true, null], + ["locked + start rejected", LOCKED, start, LOCKED, false, null], +]; + +describe("gate transition table", () => { + it.each(table)("%s", (_name, from, event, to, accepted, award) => { + const result = transition(from, event, policy); + expect(result.state).toEqual(to); + expect(result.accepted).toBe(accepted); + expect(result.award).toBe(award); + }); +}); + +describe("gate rules", () => { + it("walks the full helped path: three hints, correct, practice, done", () => { + let s: GateState = OPEN; + for (const e of [hint, hint, hint, right, start]) s = transition(s, e, policy).state; + expect(s).toEqual(PRACTICE(0)); + for (let i = 0; i < policy.practiceItems - 1; i++) s = transition(s, right, policy).state; + const last = transition(s, right, policy); + expect(last.state).toEqual(DONE_HELPED); + expect(last.award).toBe("PRACTICE_SET_DONE"); + }); + + it("a fourth hint always locks, whatever the answers in between", () => { + let s: GateState = OPEN; + for (const e of [hint, wrong, hint, wrong, hint, wrong]) s = transition(s, e, policy).state; + expect(transition(s, hint, policy).state).toEqual(LOCKED); + }); + + it("locked() is true for practice and locked redo, false for open, assisted and done", () => { + expect(locked(LOCKED)).toBe(true); + expect(locked(PRACTICE(0))).toBe(true); + expect(locked(REQUIRED)).toBe(true); + for (const s of [OPEN, ASSISTED(2), DONE_UNAIDED, DONE_HELPED]) expect(locked(s)).toBe(false); + }); +}); + +describe("policy values change behaviour", () => { + const custom = (over: Partial): AiPolicy => ({ ...policy, ...over }); + + it("hintsPerQuestion = 1 locks on the second hint", () => { + const p = custom({ hintsPerQuestion: 1 }); + const one = transition(OPEN, hint, p).state; + expect(one).toEqual(ASSISTED(1)); + expect(transition(one, hint, p).state).toEqual(LOCKED); + }); + + it("hintsPerQuestion = 0 locks on the first hint", () => { + expect(transition(OPEN, hint, custom({ hintsPerQuestion: 0 })).state).toEqual(LOCKED); + }); + + it("hintsPerQuestion = 5 allows five hints", () => { + const p = custom({ hintsPerQuestion: 5 }); + expect(transition(ASSISTED(4), hint, p).state).toEqual(ASSISTED(5)); + expect(transition(ASSISTED(5), hint, p).state).toEqual(LOCKED); + }); + + it("practiceItems = 2 finishes on the second correct item", () => { + const p = custom({ practiceItems: 2 }); + expect(transition(PRACTICE(0), right, p).state).toEqual(PRACTICE(1)); + expect(transition(PRACTICE(1), right, p).award).toBe("PRACTICE_SET_DONE"); + }); + + it("practiceItems = 0 finishes as soon as practice starts", () => { + const r = transition(REQUIRED, start, custom({ practiceItems: 0 })); + expect(r.state).toEqual(DONE_HELPED); + expect(r.award).toBe("PRACTICE_SET_DONE"); + }); +}); diff --git a/services/ai/src/gate.ts b/services/ai/src/gate.ts new file mode 100644 index 0000000..530b84d --- /dev/null +++ b/services/ai/src/gate.ts @@ -0,0 +1,75 @@ +import type { AiPolicy } from "./policy"; + +export type GateState = + | { kind: "OPEN" } + | { kind: "ASSISTED"; hints: number } + | { kind: "PRACTICE_REQUIRED" } + | { kind: "PRACTICE_OPEN"; done: number; hints: number } + | { kind: "DONE"; unaided: boolean } + | { kind: "LOCKED_REDO" }; + +export type GateEvent = { type: "HINT" } | { type: "ANSWER"; correct: boolean } | { type: "START_PRACTICE" }; + +export type GateAward = "UNAIDED_CORRECT" | "PRACTICE_SET_DONE"; + +export interface GateResult { + state: GateState; + /** False when the event is not allowed in this state; the state is returned unchanged. */ + accepted: boolean; + award: GateAward | null; +} + +export const OPEN_STATE: GateState = { kind: "OPEN" }; + +const same = (state: GateState): GateResult => ({ state, accepted: false, award: null }); +const moved = (state: GateState, award: GateAward | null = null): GateResult => ({ state, accepted: true, award }); + +function hint(state: GateState, policy: AiPolicy): GateResult { + const used = state.kind === "ASSISTED" || state.kind === "PRACTICE_OPEN" ? state.hints : 0; + // A hint beyond the cap locks the question instead of helping. + if (state.kind === "OPEN" || state.kind === "ASSISTED" || state.kind === "PRACTICE_OPEN") { + if (used + 1 > policy.hintsPerQuestion) return moved({ kind: "LOCKED_REDO" }); + if (state.kind === "PRACTICE_OPEN") return moved({ ...state, hints: used + 1 }); + return moved({ kind: "ASSISTED", hints: used + 1 }); + } + return same(state); +} + +function answer(state: GateState, correct: boolean, policy: AiPolicy): GateResult { + switch (state.kind) { + case "OPEN": + return correct ? moved({ kind: "DONE", unaided: true }, "UNAIDED_CORRECT") : moved(state); + case "ASSISTED": + return correct ? moved({ kind: "PRACTICE_REQUIRED" }) : moved(state); + case "PRACTICE_OPEN": { + if (!correct) return moved(state); + const done = state.done + 1; + if (done >= policy.practiceItems) return moved({ kind: "DONE", unaided: false }, "PRACTICE_SET_DONE"); + return moved({ kind: "PRACTICE_OPEN", done, hints: 0 }); + } + case "LOCKED_REDO": + // The redo is answered without AI; a correct redo finishes with no points. + return correct ? moved({ kind: "DONE", unaided: false }) : moved(state); + default: + return same(state); + } +} + +/** Pure answer-gate transition (SDD P2-12); every limit comes from the policy. */ +export function transition(state: GateState, event: GateEvent, policy: AiPolicy): GateResult { + if (event.type === "HINT") return hint(state, policy); + if (event.type === "ANSWER") return answer(state, event.correct, policy); + if (state.kind !== "PRACTICE_REQUIRED") return same(state); + if (policy.practiceItems <= 0) return moved({ kind: "DONE", unaided: false }, "PRACTICE_SET_DONE"); + return moved({ kind: "PRACTICE_OPEN", done: 0, hints: 0 }); +} + +/** True while nothing after this question may unlock; the academics service reads this. */ +export function locked(state: GateState): boolean { + return state.kind === "LOCKED_REDO" || state.kind === "PRACTICE_OPEN" || state.kind === "PRACTICE_REQUIRED"; +} + +/** Whether the AI may still help in this state. */ +export function aiAvailable(state: GateState): boolean { + return state.kind === "OPEN" || state.kind === "ASSISTED" || state.kind === "PRACTICE_OPEN"; +} diff --git a/services/ai/src/groqProvider.test.ts b/services/ai/src/groqProvider.test.ts new file mode 100644 index 0000000..cc7fe1b --- /dev/null +++ b/services/ai/src/groqProvider.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it, vi } from "vitest"; +import { GROQ_URL, GroqProvider, DEFAULT_GROQ_MODEL } from "./groqProvider"; +import { ProviderError } from "./provider"; + +const input = { + system: "be a tutor", + messages: [{ role: "user" as const, content: "help" }], + maxTokens: 100, +}; + +const reply = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status }); + +describe("GroqProvider", () => { + it("is disabled without a key and never calls the network", async () => { + const fetchFn = vi.fn(); + const p = new GroqProvider({ fetchFn: fetchFn as unknown as typeof fetch }); + expect(p.enabled).toBe(false); + await expect(p.complete(input)).rejects.toBeInstanceOf(ProviderError); + expect(fetchFn).not.toHaveBeenCalled(); + }); + + it("maps the request to chat completions and the response to text and usage", async () => { + const fetchFn = vi.fn(async () => + reply({ choices: [{ message: { content: "Try halving it." } }], usage: { prompt_tokens: 12, completion_tokens: 5 } }), + ); + const p = new GroqProvider({ apiKey: "k", model: "m1", fetchFn: fetchFn as unknown as typeof fetch }); + const out = await p.complete(input); + + expect(out).toEqual({ text: "Try halving it.", usage: { inputTokens: 12, outputTokens: 5 } }); + const [url, init] = fetchFn.mock.calls[0] as unknown as [string, RequestInit]; + expect(url).toBe(GROQ_URL); + expect(init.method).toBe("POST"); + expect((init.headers as Record).authorization).toBe("Bearer k"); + expect(JSON.parse(init.body as string)).toMatchObject({ + model: "m1", + max_tokens: 100, + messages: [ + { role: "system", content: "be a tutor" }, + { role: "user", content: "help" }, + ], + }); + }); + + it("uses the default model when none is set", async () => { + const fetchFn = vi.fn(async () => reply({ choices: [{ message: { content: "x" } }] })); + await new GroqProvider({ apiKey: "k", fetchFn: fetchFn as unknown as typeof fetch }).complete(input); + const init = (fetchFn.mock.calls[0] as unknown as [string, RequestInit])[1]; + expect(JSON.parse(init.body as string).model).toBe(DEFAULT_GROQ_MODEL); + }); + + it("turns a non-2xx response into a ProviderError with the status", async () => { + const fetchFn = vi.fn(async () => reply({ error: "rate" }, 429)); + const p = new GroqProvider({ apiKey: "k", fetchFn: fetchFn as unknown as typeof fetch }); + await expect(p.complete(input)).rejects.toMatchObject({ name: "ProviderError", status: 429 }); + }); + + it("turns a network failure and an empty body into ProviderErrors", async () => { + const down = new GroqProvider({ + apiKey: "k", + fetchFn: (async () => Promise.reject(new Error("offline"))) as unknown as typeof fetch, + }); + await expect(down.complete(input)).rejects.toBeInstanceOf(ProviderError); + const empty = new GroqProvider({ apiKey: "k", fetchFn: (async () => reply({ choices: [] })) as unknown as typeof fetch }); + await expect(empty.complete(input)).rejects.toBeInstanceOf(ProviderError); + }); +}); diff --git a/services/ai/src/groqProvider.ts b/services/ai/src/groqProvider.ts new file mode 100644 index 0000000..86f2be1 --- /dev/null +++ b/services/ai/src/groqProvider.ts @@ -0,0 +1,63 @@ +import { ProviderError, type AiProvider, type CompleteInput, type Completion } from "./provider"; + +export const GROQ_URL = "https://api.groq.com/openai/v1/chat/completions"; +export const DEFAULT_GROQ_MODEL = "llama-3.3-70b-versatile"; +const GROQ_TIMEOUT_MS = 15_000; // a slow provider must not hold a student's request open +const TEMPERATURE = 0.3; // low, so hints stay on topic + +export interface GroqOptions { + apiKey?: string; + model?: string; + fetchFn?: typeof fetch; +} + +interface GroqResponse { + choices?: { message?: { content?: string } }[]; + usage?: { prompt_tokens?: number; completion_tokens?: number }; +} + +/** OpenAI-compatible chat completions call; disabled until GROQ_API_KEY is set. */ +export class GroqProvider implements AiProvider { + readonly name = "groq"; + private readonly apiKey: string | undefined; + private readonly model: string; + private readonly fetchFn: typeof fetch; + + constructor(options: GroqOptions = {}) { + this.apiKey = options.apiKey || undefined; + this.model = options.model || DEFAULT_GROQ_MODEL; + this.fetchFn = options.fetchFn ?? fetch; + } + + get enabled(): boolean { + return this.apiKey !== undefined; + } + + async complete(input: CompleteInput): Promise { + if (!this.apiKey) throw new ProviderError("groq provider is disabled: GROQ_API_KEY not set"); + let res: Response; + try { + res = await this.fetchFn(GROQ_URL, { + method: "POST", + headers: { "content-type": "application/json", authorization: `Bearer ${this.apiKey}` }, + body: JSON.stringify({ + model: this.model, + max_tokens: input.maxTokens, + temperature: TEMPERATURE, + messages: [{ role: "system", content: input.system }, ...input.messages], + }), + signal: AbortSignal.timeout(GROQ_TIMEOUT_MS), + }); + } catch (err) { + throw new ProviderError(`groq request failed: ${String(err)}`); + } + if (!res.ok) throw new ProviderError(`groq returned ${res.status}`, res.status); + const data = (await res.json()) as GroqResponse; + const text = data.choices?.[0]?.message?.content; + if (typeof text !== "string" || text === "") throw new ProviderError("groq returned no content"); + return { + text, + usage: { inputTokens: data.usage?.prompt_tokens ?? 0, outputTokens: data.usage?.completion_tokens ?? 0 }, + }; + } +} diff --git a/services/ai/src/main.ts b/services/ai/src/main.ts new file mode 100644 index 0000000..09bfcef --- /dev/null +++ b/services/ai/src/main.ts @@ -0,0 +1,28 @@ +import { serve } from "@hono/node-server"; +import pg from "pg"; +import { buildApp } from "./app"; +import { FakeProvider } from "./fakeProvider"; +import { GroqProvider } from "./groqProvider"; +import { PgStore } from "./pgStore"; +import { MemoryStore } from "./store"; + +const DEFAULT_PORT = 4003; +const databaseUrl = process.env.DATABASE_URL; + +// Only dev may run without a database; production must fail at boot, not at first request. +if (!databaseUrl && process.env.NODE_ENV === "production") { + throw new Error("DATABASE_URL is required in production"); +} + +const pool = databaseUrl ? new pg.Pool({ connectionString: databaseUrl }) : undefined; +const store = pool ? new PgStore(pool) : new MemoryStore(); +// Groq is used once a key exists; until then the fake provider keeps the UI working. +const groq = new GroqProvider({ apiKey: process.env.GROQ_API_KEY, model: process.env.GROQ_MODEL }); +const provider = process.env.AI_PROVIDER === "fake" || !groq.enabled ? new FakeProvider() : groq; + +const app = buildApp({ store, provider, pool, version: process.env.npm_package_version ?? "0.0.0" }); +const server = serve({ fetch: app.fetch, port: Number(process.env.PORT ?? DEFAULT_PORT) }); + +process.on("SIGTERM", () => { + server.close(() => void pool?.end()); +}); diff --git a/services/ai/src/pgStore.ts b/services/ai/src/pgStore.ts new file mode 100644 index 0000000..0188dbb --- /dev/null +++ b/services/ai/src/pgStore.ts @@ -0,0 +1,192 @@ +import type { Pool } from "pg"; +import { withSchool } from "@stackable/service-kit"; +import type { GateState } from "./gate"; +import type { AiPolicy, AllowanceTier } from "./policy"; +import type { LedgerEntry } from "./points"; +import type { MessageRecord, PracticeItem, Session, Store, UsageCounts } from "./store"; + +interface SessionRow { + id: string; + student_id: string; + question_id: string; + kind: "tutor" | "tool"; + state: GateState; + hints_used: number; + practice_completed: boolean; + version: number; +} + +const toSession = (r: SessionRow): Session => ({ + id: r.id, + studentId: r.student_id, + questionId: r.question_id, + kind: r.kind, + state: r.state, + hintsUsed: r.hints_used, + practiceCompleted: r.practice_completed, + version: r.version, +}); + +/** Postgres store; every call runs inside withSchool so row-level security applies. */ +export class PgStore implements Store { + constructor(private readonly pool: Pool) {} + + async getPolicy(schoolId: string): Promise { + return withSchool(this.pool, schoolId, async (db) => { + const { rows } = await db.query( + `select hints_per_question, practice_items, unaided_points, practice_set_points, daily_allowance_base + from ai.ai_policies where school_id = $1`, + [schoolId], + ); + const r = rows[0]; + if (!r) return null; + return { + hintsPerQuestion: r.hints_per_question, + practiceItems: r.practice_items, + unaidedPoints: r.unaided_points, + practiceSetPoints: r.practice_set_points, + dailyAllowanceBase: r.daily_allowance_base, + }; + }); + } + + async listTiers(schoolId: string): Promise { + return withSchool(this.pool, schoolId, async (db) => { + const { rows } = await db.query( + "select min_points, daily_requests from ai.allowance_tiers where school_id = $1 order by min_points limit 100", + [schoolId], + ); + return rows.map((r) => ({ minPoints: r.min_points, dailyRequests: r.daily_requests })); + }); + } + + async getSession(schoolId: string, studentId: string, questionId: string): Promise { + return withSchool(this.pool, schoolId, async (db) => { + const { rows } = await db.query( + `select id, student_id, question_id, kind, state, hints_used, practice_completed, version + from ai.ai_sessions where school_id = $1 and student_id = $2 and question_id = $3`, + [schoolId, studentId, questionId], + ); + return rows[0] ? toSession(rows[0]) : null; + }); + } + + async saveSession(schoolId: string, s: Session, expectedVersion: number | null): Promise { + return withSchool(this.pool, schoolId, async (db) => { + if (expectedVersion === null) { + const res = await db.query( + `insert into ai.ai_sessions (id, school_id, student_id, question_id, kind, state, hints_used, practice_completed, version) + values ($1, $2, $3, $4, $5, $6, $7, $8, $9) + on conflict (school_id, student_id, question_id) do nothing`, + [s.id, schoolId, s.studentId, s.questionId, s.kind, JSON.stringify(s.state), s.hintsUsed, s.practiceCompleted, s.version], + ); + return res.rowCount === 1; + } + const res = await db.query( + `update ai.ai_sessions + set state = $1, hints_used = $2, practice_completed = $3, version = $4, updated_at = now() + where school_id = $5 and id = $6 and version = $7`, + [JSON.stringify(s.state), s.hintsUsed, s.practiceCompleted, s.version, schoolId, s.id, expectedVersion], + ); + return res.rowCount === 1; + }); + } + + async addMessage(schoolId: string, m: MessageRecord): Promise { + await withSchool(this.pool, schoolId, (db) => + db.query( + `insert into ai.ai_messages (id, school_id, session_id, role, content, leak_flagged, created_at) + values ($1, $2, $3, $4, $5, $6, $7)`, + [m.id, schoolId, m.sessionId, m.role, m.content, m.leakFlagged, m.createdAt], + ), + ); + } + + async countReplies(schoolId: string, studentId: string, from: Date, to: Date): Promise { + return withSchool(this.pool, schoolId, async (db) => { + const { rows } = await db.query( + `select count(*)::int as n + from ai.ai_messages m join ai.ai_sessions s on s.id = m.session_id and s.school_id = m.school_id + where m.school_id = $1 and s.student_id = $2 and m.role = 'assistant' + and m.created_at >= $3 and m.created_at < $4`, + [schoolId, studentId, from, to], + ); + return rows[0].n; + }); + } + + async savePracticeSet(schoolId: string, sessionId: string, items: PracticeItem[]): Promise { + await withSchool(this.pool, schoolId, async (db) => { + const setId = crypto.randomUUID(); + await db.query("insert into ai.practice_sets (id, school_id, session_id) values ($1, $2, $3)", [ + setId, + schoolId, + sessionId, + ]); + for (const i of items) { + await db.query( + `insert into ai.practice_items (id, school_id, set_id, position, question, answer_key, difficulty) + values ($1, $2, $3, $4, $5, $6, $7)`, + [i.id, schoolId, setId, i.position, i.question, i.answerKey, i.difficulty], + ); + } + }); + } + + async getPracticeItems(schoolId: string, sessionId: string): Promise { + return withSchool(this.pool, schoolId, async (db) => { + const { rows } = await db.query( + `select i.id, i.position, i.question, i.answer_key, i.difficulty + from ai.practice_items i + where i.school_id = $1 and i.set_id = ( + select id from ai.practice_sets where school_id = $1 and session_id = $2 + order by created_at desc limit 1) + order by i.position limit 50`, + [schoolId, sessionId], + ); + return rows.map((r) => ({ + id: r.id, + position: r.position, + question: r.question, + answerKey: r.answer_key, + difficulty: r.difficulty, + })); + }); + } + + async appendPoints(schoolId: string, e: LedgerEntry): Promise { + return withSchool(this.pool, schoolId, async (db) => { + const res = await db.query( + `insert into ai.achievement_ledger (id, school_id, student_id, question_id, reason, points, created_at) + values ($1, $2, $3, $4, $5, $6, $7) + on conflict (school_id, student_id, question_id, reason) do nothing`, + [e.id, schoolId, e.studentId, e.questionId, e.reason, e.points, e.createdAt], + ); + return res.rowCount === 1; + }); + } + + async sumPoints(schoolId: string, studentId: string): Promise { + return withSchool(this.pool, schoolId, async (db) => { + const { rows } = await db.query( + "select coalesce(sum(points), 0)::int as total from ai.achievement_ledger where school_id = $1 and student_id = $2", + [schoolId, studentId], + ); + return rows[0].total; + }); + } + + async usageCounts(schoolId: string, studentId: string): Promise { + return withSchool(this.pool, schoolId, async (db) => { + const { rows } = await db.query( + `select count(*) filter (where hints_used > 0)::int as questions_helped, + coalesce(sum(hints_used), 0)::int as hints_used, + count(*) filter (where practice_completed)::int as practice_done + from ai.ai_sessions where school_id = $1 and student_id = $2 and kind = 'tutor'`, + [schoolId, studentId], + ); + const r = rows[0]; + return { questionsHelped: r.questions_helped, hintsUsed: r.hints_used, practiceDone: r.practice_done }; + }); + } +} diff --git a/services/ai/src/points.test.ts b/services/ai/src/points.test.ts new file mode 100644 index 0000000..ef64861 --- /dev/null +++ b/services/ai/src/points.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import { DEFAULT_POLICY } from "./policy"; +import { appendEntry, pointsFor, totalPoints, type LedgerEntry } from "./points"; + +const entry = (over: Partial = {}): LedgerEntry => ({ + id: "e1", + studentId: "s1", + questionId: "q1", + reason: "UNAIDED_CORRECT", + points: 10, + createdAt: new Date(0), + ...over, +}); + +describe("points", () => { + it("reads award values from the policy", () => { + expect(pointsFor("UNAIDED_CORRECT", { ...DEFAULT_POLICY, unaidedPoints: 20 })).toBe(20); + expect(pointsFor("PRACTICE_SET_DONE", { ...DEFAULT_POLICY, practiceSetPoints: 7 })).toBe(7); + }); + + it("appends without mutating the old list", () => { + const first: readonly LedgerEntry[] = []; + const { ledger, added } = appendEntry(first, entry()); + expect(added).toBe(true); + expect(first).toHaveLength(0); + expect(ledger).toHaveLength(1); + }); + + it("ignores a repeat of the same student, question and reason", () => { + const one = appendEntry([], entry()).ledger; + const again = appendEntry(one, entry({ id: "e2" })); + expect(again.added).toBe(false); + expect(again.ledger).toHaveLength(1); + }); + + it("allows the same question for a different reason", () => { + const one = appendEntry([], entry()).ledger; + expect(appendEntry(one, entry({ id: "e2", reason: "PRACTICE_SET_DONE", points: 5 })).added).toBe(true); + }); + + it("totals one student only", () => { + const l = [entry(), entry({ id: "e2", questionId: "q2" }), entry({ id: "e3", studentId: "s2" })]; + expect(totalPoints(l, "s1")).toBe(20); + }); +}); diff --git a/services/ai/src/points.ts b/services/ai/src/points.ts new file mode 100644 index 0000000..f6ff9f0 --- /dev/null +++ b/services/ai/src/points.ts @@ -0,0 +1,31 @@ +import type { GateAward } from "./gate"; +import type { AiPolicy } from "./policy"; + +export interface LedgerEntry { + id: string; + studentId: string; + questionId: string; + reason: GateAward; + points: number; + createdAt: Date; +} + +/** Points for an award, taken from the school policy. */ +export function pointsFor(award: GateAward, policy: AiPolicy): number { + return award === "UNAIDED_CORRECT" ? policy.unaidedPoints : policy.practiceSetPoints; +} + +/** Appends an entry; a repeat of the same student, question and reason changes nothing. */ +export function appendEntry( + ledger: readonly LedgerEntry[], + entry: LedgerEntry, +): { ledger: readonly LedgerEntry[]; added: boolean } { + const exists = ledger.some( + (e) => e.studentId === entry.studentId && e.questionId === entry.questionId && e.reason === entry.reason, + ); + return exists ? { ledger, added: false } : { ledger: [...ledger, entry], added: true }; +} + +export function totalPoints(ledger: readonly LedgerEntry[], studentId: string): number { + return ledger.filter((e) => e.studentId === studentId).reduce((sum, e) => sum + e.points, 0); +} diff --git a/services/ai/src/policy.ts b/services/ai/src/policy.ts new file mode 100644 index 0000000..dc8fb13 --- /dev/null +++ b/services/ai/src/policy.ts @@ -0,0 +1,22 @@ +/** School-level settings for the AI tutor; nothing in the gate or allowance is hard-coded. */ +export interface AiPolicy { + hintsPerQuestion: number; + practiceItems: number; + unaidedPoints: number; + practiceSetPoints: number; + dailyAllowanceBase: number; +} + +export const DEFAULT_POLICY: AiPolicy = { + hintsPerQuestion: 3, + practiceItems: 5, + unaidedPoints: 10, + practiceSetPoints: 5, + dailyAllowanceBase: 10, +}; + +/** Higher points buy more AI requests per day; a tier never changes the hint cap. */ +export interface AllowanceTier { + minPoints: number; + dailyRequests: number; +} diff --git a/services/ai/src/provider.ts b/services/ai/src/provider.ts new file mode 100644 index 0000000..7ec8fbb --- /dev/null +++ b/services/ai/src/provider.ts @@ -0,0 +1,37 @@ +export interface ChatMessage { + role: "user" | "assistant"; + content: string; +} + +export type CompletionPurpose = "hint" | "rewrite" | "practice" | "flashcards" | "summary" | "quiz"; + +export interface CompleteInput { + system: string; + messages: ChatMessage[]; + maxTokens: number; + /** Lets the fake provider pick a scripted reply; real providers ignore it. */ + purpose?: CompletionPurpose; +} + +export interface Completion { + text: string; + usage: { inputTokens: number; outputTokens: number }; +} + +export interface AiProvider { + readonly name: string; + /** False when the provider is configured off (for example a missing API key). */ + readonly enabled: boolean; + complete(input: CompleteInput): Promise; +} + +/** A provider call failed; the service turns this into a friendly 502. */ +export class ProviderError extends Error { + readonly status?: number; + + constructor(message: string, status?: number) { + super(message); + this.name = "ProviderError"; + this.status = status; + } +} diff --git a/services/ai/src/service.ts b/services/ai/src/service.ts new file mode 100644 index 0000000..0b2405b --- /dev/null +++ b/services/ai/src/service.ts @@ -0,0 +1,407 @@ +import { AppError, createLogger, errors, type Logger } from "@stackable/service-kit"; +import { allowanceStatus, dailyLimit, dayWindow, type Allowance } from "./allowance"; +import { locked, OPEN_STATE, transition, type GateAward } from "./gate"; +import { DEFAULT_POLICY, type AiPolicy } from "./policy"; +import { pointsFor } from "./points"; +import type { AiProvider, CompleteInput, CompletionPurpose } from "./provider"; +import type { PracticeItem, Session, Store } from "./store"; +import { answersMatch, checkNoAnswerLeak, normalize, rewriteRequest, SAFE_FALLBACK_HINT } from "./tutorGuard"; + +export interface Actor { + schoolId: string; + userId: string; + role: string | undefined; +} + +export type ToolKind = "flashcards" | "summary" | "quiz"; + +export interface HintInput { + questionId: string; + questionText: string; + answerState: "UNANSWERED" | "WRONG"; + helpsUsed?: number; + studentMessage?: string; + answerKey?: string; + studentId?: string; +} + +const STUDENT_ROLES = ["student", "pupil"]; +const KEY_ROLES = ["teacher", "system"]; // only these may send an answer key or a verdict +const HINT_MAX_TOKENS = 300; +const GENERATE_MAX_TOKENS = 1500; +const GENERATION_ATTEMPTS = 2; // one regenerate when the first output is unusable +const DEFAULT_DIFFICULTY = 3; +const DIFFICULTY_SPREAD = 1; // practice items may differ from the question by one level +const FRIENDLY_PROVIDER_ERROR = "The study helper is unavailable right now. Please try again in a moment."; + +const isKeyRole = (role: string | undefined) => role !== undefined && KEY_ROLES.includes(role); + +/** The AI tutor use cases: hints, practice, the answer gate, usage counts and study tools. */ +export class AiService { + private readonly log: Logger = createLogger("ai"); + + constructor( + private readonly store: Store, + private readonly provider: AiProvider, + private readonly now: () => Date = () => new Date(), + ) {} + + // Students act as themselves; teachers and the system act for a named student. + private resolveStudent(actor: Actor, studentId: string | undefined, staffMayOmit = false): string { + if (actor.role !== undefined && STUDENT_ROLES.includes(actor.role)) { + if (studentId !== undefined && studentId !== actor.userId) throw errors.forbidden(); + return actor.userId; + } + if (!isKeyRole(actor.role)) throw errors.forbidden("This action is for students and teachers"); + if (studentId !== undefined) return studentId; + if (staffMayOmit) return actor.userId; + throw errors.validation("studentId is required", { fields: [{ path: "studentId", message: "is required" }] }); + } + + private async policy(schoolId: string): Promise { + return (await this.store.getPolicy(schoolId)) ?? DEFAULT_POLICY; + } + + private async loadSession(actor: Actor, studentId: string, questionId: string) { + const existing = await this.store.getSession(actor.schoolId, studentId, questionId); + const session: Session = existing ?? { + id: crypto.randomUUID(), + studentId, + questionId, + kind: "tutor", + state: OPEN_STATE, + hintsUsed: 0, + practiceCompleted: false, + version: 0, + }; + return { session, existed: existing !== null }; + } + + // Optimistic version check so two tabs cannot both consume the same hint. + private async saveSession(actor: Actor, session: Session, existed: boolean): Promise { + const ok = existed + ? await this.store.saveSession(actor.schoolId, { ...session, version: session.version + 1 }, session.version) + : await this.store.saveSession(actor.schoolId, session, null); + if (!ok) throw errors.conflict("This question changed in another window. Please reload."); + } + + private async checkAllowance(actor: Actor, studentId: string, policy: AiPolicy) { + const [points, tiers] = await Promise.all([ + this.store.sumPoints(actor.schoolId, studentId), + this.store.listTiers(actor.schoolId), + ]); + const limit = dailyLimit(points, tiers, policy.dailyAllowanceBase); + const { from, to } = dayWindow(this.now()); + const used = await this.store.countReplies(actor.schoolId, studentId, from, to); + const before = allowanceStatus(limit, used); + if (before.status === "EXHAUSTED") { + throw new AppError(429, "AI_ALLOWANCE_EXHAUSTED", "You have used all your AI help for today. It resets tomorrow.", { + limit, + }); + } + const after: Allowance = allowanceStatus(limit, used + 1); + return after; + } + + private async call(input: CompleteInput): Promise { + try { + return (await this.provider.complete(input)).text; + } catch (err) { + this.log.error("provider failed", { provider: this.provider.name, purpose: input.purpose, err: String(err) }); + throw errors.upstream(FRIENDLY_PROVIDER_ERROR); + } + } + + private async record(actor: Actor, sessionId: string, role: "student" | "assistant", content: string, flagged = false) { + await this.store.addMessage(actor.schoolId, { + id: crypto.randomUUID(), + sessionId, + role, + content, + leakFlagged: flagged, + createdAt: this.now(), + }); + } + + // Every reply is leak-checked; a leak is rewritten once, then replaced by a safe hint. + private async guardedHint(question: string, key: string | undefined, input: CompleteInput) { + const first = await this.call(input); + if (checkNoAnswerLeak(first, key).ok) return { text: first, flagged: false }; + this.log.warn("tutor reply leaked the answer; regenerating", { purpose: input.purpose }); + try { + const second = await this.call({ + ...input, + purpose: "rewrite", + messages: [{ role: "user", content: rewriteRequest(question) }], + }); + if (checkNoAnswerLeak(second, key).ok) return { text: second, flagged: true }; + } catch (err) { + this.log.warn("rewrite failed; using the safe hint", { err: String(err) }); + } + return { text: SAFE_FALLBACK_HINT, flagged: true }; + } + + private async currentPracticeItem(actor: Actor, session: Session): Promise { + const items = await this.store.getPracticeItems(actor.schoolId, session.id); + const done = session.state.kind === "PRACTICE_OPEN" ? session.state.done : 0; + const item = items[done]; + if (!item) throw errors.conflict("Practice items are missing for this question"); + return item; + } + + /** Gives one hint, or locks the question when the hint cap would be passed. */ + async requestHint(actor: Actor, input: HintInput) { + if (input.answerKey !== undefined && !isKeyRole(actor.role)) { + throw errors.forbidden("An answer key cannot be sent from this path"); + } + const studentId = this.resolveStudent(actor, input.studentId); + const policy = await this.policy(actor.schoolId); + const { session, existed } = await this.loadSession(actor, studentId, input.questionId); + + const result = transition(session.state, { type: "HINT" }, policy); + if (!result.accepted) { + throw errors.conflict("AI help is closed for this question. Answer it on your own.", { state: session.state.kind }); + } + if (result.state.kind === "LOCKED_REDO") { + await this.saveSession(actor, { ...session, state: result.state }, existed); + return { hint: null, state: result.state, locked: locked(result.state), allowance: null, leakFlagged: false }; + } + + const allowance = await this.checkAllowance(actor, studentId, policy); + const practice = session.state.kind === "PRACTICE_OPEN" ? await this.currentPracticeItem(actor, session) : null; + const questionText = practice ? practice.question : input.questionText; + const key = practice ? practice.answerKey : input.answerKey; + const hintNumber = result.state.kind === "ASSISTED" || result.state.kind === "PRACTICE_OPEN" ? result.state.hints : 1; + const studentNote = input.studentMessage?.trim() || "(no message)"; + + const reply = await this.guardedHint(questionText, key, { + purpose: "hint", + maxTokens: HINT_MAX_TOKENS, + system: + `You are a patient school tutor. This is hint ${hintNumber} of ${policy.hintsPerQuestion}. ` + + "Give one short hint or guiding question. Never state the final answer and never show a full worked solution.", + messages: [ + { role: "user", content: `Question: ${questionText}\nStudent status: ${input.answerState}\nStudent says: ${studentNote}` }, + ], + }); + + // The session row goes first because messages reference it. + await this.saveSession(actor, { ...session, state: result.state, hintsUsed: session.hintsUsed + 1 }, existed); + await this.record(actor, session.id, "student", input.studentMessage?.trim() || "[hint requested]"); + await this.record(actor, session.id, "assistant", reply.text, reply.flagged); + return { hint: reply.text, state: result.state, locked: locked(result.state), allowance, leakFlagged: reply.flagged }; + } + + // Reads the first {...} block so fenced or chatty replies still parse. + private parseJson(text: string): Record | null { + const start = text.indexOf("{"); + const end = text.lastIndexOf("}"); + if (start < 0 || end <= start) return null; + try { + const parsed: unknown = JSON.parse(text.slice(start, end + 1)); + return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) + ? (parsed as Record) + : null; + } catch { + return null; + } + } + + private async generate( + purpose: CompletionPurpose, + system: string, + user: string, + extract: (json: Record) => T | null, + ): Promise { + for (let attempt = 0; attempt < GENERATION_ATTEMPTS; attempt++) { + const text = await this.call({ purpose, system, maxTokens: GENERATE_MAX_TOKENS, messages: [{ role: "user", content: user }] }); + const json = this.parseJson(text); + const value = json ? extract(json) : null; + if (value !== null) return value; + this.log.warn("provider output unusable", { purpose, attempt }); + } + throw new AppError(502, "AI_OUTPUT_INVALID", FRIENDLY_PROVIDER_ERROR); + } + + /** Generates the practice set for a helped question and opens it; the keys stay on the server. */ + async createPractice(actor: Actor, input: { questionId: string; questionText: string; difficulty?: number; studentId?: string }) { + const studentId = this.resolveStudent(actor, input.studentId); + const policy = await this.policy(actor.schoolId); + const { session, existed } = await this.loadSession(actor, studentId, input.questionId); + const count = policy.practiceItems; + + if (session.state.kind === "PRACTICE_OPEN") { + const items = await this.store.getPracticeItems(actor.schoolId, session.id); + return { items: items.map(publicItem), state: session.state, locked: true }; + } + const started = transition(session.state, { type: "START_PRACTICE" }, policy); + if (!started.accepted) { + throw errors.conflict("Practice is not required for this question", { state: session.state.kind }); + } + + await this.checkAllowance(actor, studentId, policy); + const wanted = input.difficulty ?? DEFAULT_DIFFICULTY; + const seen = new Set([normalize(input.questionText)]); + const items = await this.generate( + "practice", + `Write ${count} new practice questions similar to the student's question, each with its answer. ` + + `Reply with JSON only: {"items":[{"question":string,"answer":string,"difficulty":1-5}]}. Aim for difficulty ${wanted}.`, + `Question: ${input.questionText}`, + (json) => pickPracticeItems(json, count, wanted, new Set(seen)), + ); + + await this.store.savePracticeSet(actor.schoolId, session.id, items); + await this.record(actor, session.id, "assistant", "[practice set generated]"); + await this.saveSession(actor, { ...session, state: started.state }, existed); + return { items: items.map(publicItem), state: started.state, locked: locked(started.state) }; + } + + /** Applies one submission result to the gate and awards points when it finishes the question. */ + async submitAnswer(actor: Actor, input: { questionId: string; correct?: boolean; practiceAnswer?: string; studentId?: string }) { + if (input.correct !== undefined && !isKeyRole(actor.role)) { + throw errors.forbidden("Only the grading service can report a verdict"); + } + const studentId = this.resolveStudent(actor, input.studentId); + const policy = await this.policy(actor.schoolId); + const { session, existed } = await this.loadSession(actor, studentId, input.questionId); + + let correct: boolean; + if (session.state.kind === "PRACTICE_OPEN") { + if (input.practiceAnswer === undefined) throw errors.validation("practiceAnswer is required", { fields: [{ path: "practiceAnswer", message: "is required" }] }); + const item = await this.currentPracticeItem(actor, session); + correct = answersMatch(input.practiceAnswer, item.answerKey); + } else { + if (input.practiceAnswer !== undefined) throw errors.conflict("There is no practice item to answer", { state: session.state.kind }); + if (input.correct === undefined) throw errors.validation("correct is required", { fields: [{ path: "correct", message: "is required" }] }); + correct = input.correct; + } + + const result = transition(session.state, { type: "ANSWER", correct }, policy); + if (!result.accepted) { + throw errors.conflict("This question cannot take an answer right now", { state: session.state.kind }); + } + await this.saveSession( + actor, + { ...session, state: result.state, practiceCompleted: session.practiceCompleted || result.award === "PRACTICE_SET_DONE" }, + existed, + ); + const pointsAwarded = result.award ? await this.award(actor, studentId, input.questionId, result.award, policy) : 0; + return { state: result.state, locked: locked(result.state), correct, pointsAwarded }; + } + + private async award(actor: Actor, studentId: string, questionId: string, reason: GateAward, policy: AiPolicy) { + const points = pointsFor(reason, policy); + const added = await this.store.appendPoints(actor.schoolId, { + id: crypto.randomUUID(), + studentId, + questionId, + reason, + points, + createdAt: this.now(), + }); + return added ? points : 0; + } + + /** Counts only, never message content, so a parent sees effort without seeing the chat. */ + async usageSummary(actor: Actor, studentId: string) { + if (actor.role === undefined) throw errors.forbidden(); + if (STUDENT_ROLES.includes(actor.role) && studentId !== actor.userId) throw errors.forbidden(); + const [counts, points] = await Promise.all([ + this.store.usageCounts(actor.schoolId, studentId), + this.store.sumPoints(actor.schoolId, studentId), + ]); + return { studentId, ...counts, points }; + } + + /** Runs a study tool; output is flagged AI-generated and needs a teacher before it is published. */ + async runTool(actor: Actor, kind: ToolKind, input: { text: string; count: number; studentId?: string }) { + const ownerId = this.resolveStudent(actor, input.studentId, true); + const policy = await this.policy(actor.schoolId); + await this.checkAllowance(actor, ownerId, policy); + const withAnswers = isKeyRole(actor.role); + const content = await this.generate(kind, TOOL_PROMPTS[kind](input.count), `Material:\n${input.text}`, (json) => + TOOL_EXTRACTORS[kind](json, withAnswers), + ); + + const session: Session = { + id: crypto.randomUUID(), + studentId: ownerId, + questionId: `tool:${kind}:${crypto.randomUUID()}`, + kind: "tool", + state: { kind: "DONE", unaided: false }, + hintsUsed: 0, + practiceCompleted: false, + version: 0, + }; + await this.store.saveSession(actor.schoolId, session, null); + await this.record(actor, session.id, "assistant", `[${kind} generated]`); + return { kind, aiGenerated: true, needsTeacherReview: true, content }; + } +} + +/** The student-facing view of a practice item; the key is left out on purpose. */ +function publicItem(item: PracticeItem) { + return { id: item.id, position: item.position, question: item.question, difficulty: item.difficulty }; +} + +// Keeps unique, in-range items; null makes the caller regenerate. +function pickPracticeItems( + json: Record, + count: number, + wanted: number, + seen: Set, +): PracticeItem[] | null { + if (!Array.isArray(json.items)) return null; + const picked: PracticeItem[] = []; + for (const raw of json.items as unknown[]) { + if (picked.length === count) break; + const r = (raw ?? {}) as Record; + const { question, answer, difficulty } = r; + if (typeof question !== "string" || typeof answer !== "string" || typeof difficulty !== "number") continue; + if (!question.trim() || !answer.trim() || question.length > 500 || answer.length > 200) continue; + if (!Number.isInteger(difficulty) || Math.abs(difficulty - wanted) > DIFFICULTY_SPREAD) continue; + const norm = normalize(question); + if (seen.has(norm)) continue; + seen.add(norm); + picked.push({ id: crypto.randomUUID(), position: picked.length, question, answerKey: answer, difficulty }); + } + return picked.length === count ? picked : null; +} + +const TOOL_PROMPTS: Record string> = { + flashcards: (n) => `Make ${n} flashcards from the material. Reply with JSON only: {"cards":[{"front":string,"back":string}]}`, + summary: () => 'Summarise the material for a student. Reply with JSON only: {"summary":string,"keyPoints":[string]}', + quiz: (n) => + `Write ${n} multiple choice questions from the material. Reply with JSON only: ` + + '{"questions":[{"question":string,"options":[string],"answerIndex":number}]}', +}; + +const isText = (v: unknown): v is string => typeof v === "string" && v.trim().length > 0; + +const TOOL_EXTRACTORS: Record, withAnswers: boolean) => unknown> = { + flashcards: (json) => { + const cards = Array.isArray(json.cards) ? (json.cards as Record[]) : []; + const ok = cards.length > 0 && cards.every((c) => isText(c?.front) && isText(c?.back)); + return ok ? cards.map((c) => ({ front: c.front, back: c.back })) : null; + }, + summary: (json) => { + const points = json.keyPoints; + const ok = isText(json.summary) && Array.isArray(points) && points.every(isText); + return ok ? { summary: json.summary, keyPoints: points } : null; + }, + quiz: (json, withAnswers) => { + const qs = Array.isArray(json.questions) ? (json.questions as Record[]) : []; + const valid = (q: Record) => + isText(q?.question) && + Array.isArray(q.options) && + q.options.length >= 2 && + q.options.every(isText) && + Number.isInteger(q.answerIndex) && + (q.answerIndex as number) >= 0 && + (q.answerIndex as number) < q.options.length; + if (qs.length === 0 || !qs.every(valid)) return null; + // Students get the questions only; the key is for the teacher reviewing the quiz. + return qs.map((q) => ({ question: q.question, options: q.options, ...(withAnswers ? { answerIndex: q.answerIndex } : {}) })); + }, +}; + diff --git a/services/ai/src/store.ts b/services/ai/src/store.ts new file mode 100644 index 0000000..39d4f70 --- /dev/null +++ b/services/ai/src/store.ts @@ -0,0 +1,149 @@ +import type { GateState } from "./gate"; +import type { AiPolicy, AllowanceTier } from "./policy"; +import type { LedgerEntry } from "./points"; + +export interface Session { + id: string; + studentId: string; + questionId: string; + kind: "tutor" | "tool"; + state: GateState; + hintsUsed: number; + practiceCompleted: boolean; + version: number; +} + +export interface MessageRecord { + id: string; + sessionId: string; + role: "student" | "assistant"; + content: string; + leakFlagged: boolean; + createdAt: Date; +} + +/** Practice item with its key; the key stays inside the service and is never returned. */ +export interface PracticeItem { + id: string; + position: number; + question: string; + answerKey: string; + difficulty: number; +} + +export interface UsageCounts { + questionsHelped: number; + hintsUsed: number; + practiceDone: number; +} + +/** Every method takes schoolId first; a store never returns another school's rows. */ +export interface Store { + getPolicy(schoolId: string): Promise; + listTiers(schoolId: string): Promise; + getSession(schoolId: string, studentId: string, questionId: string): Promise; + /** Insert when expectedVersion is null, else update if the stored version matches; false on conflict. */ + saveSession(schoolId: string, session: Session, expectedVersion: number | null): Promise; + addMessage(schoolId: string, message: MessageRecord): Promise; + /** Assistant replies for the student in [from, to); each one is one allowance unit. */ + countReplies(schoolId: string, studentId: string, from: Date, to: Date): Promise; + savePracticeSet(schoolId: string, sessionId: string, items: PracticeItem[]): Promise; + getPracticeItems(schoolId: string, sessionId: string): Promise; + /** False when the same student, question and reason is already in the ledger. */ + appendPoints(schoolId: string, entry: LedgerEntry): Promise; + sumPoints(schoolId: string, studentId: string): Promise; + usageCounts(schoolId: string, studentId: string): Promise; +} + +/** In-memory store for dev and tests; behaves like the pg store, tenant by tenant. */ +export class MemoryStore implements Store { + policies = new Map(); + tiers = new Map(); + sessions: (Session & { schoolId: string })[] = []; + messages: (MessageRecord & { schoolId: string })[] = []; + practice = new Map(); + ledger: (LedgerEntry & { schoolId: string })[] = []; + + async getPolicy(schoolId: string) { + return this.policies.get(schoolId) ?? null; + } + + async listTiers(schoolId: string) { + return this.tiers.get(schoolId) ?? []; + } + + async getSession(schoolId: string, studentId: string, questionId: string) { + const found = this.sessions.find( + (s) => s.schoolId === schoolId && s.studentId === studentId && s.questionId === questionId, + ); + return found ? { ...found } : null; + } + + async saveSession(schoolId: string, session: Session, expectedVersion: number | null) { + const index = this.sessions.findIndex((s) => s.schoolId === schoolId && s.id === session.id); + if (expectedVersion === null) { + if (index >= 0) return false; + this.sessions.push({ ...session, schoolId }); + return true; + } + if (index < 0 || this.sessions[index].version !== expectedVersion) return false; + this.sessions[index] = { ...session, schoolId }; + return true; + } + + async addMessage(schoolId: string, message: MessageRecord) { + this.messages.push({ ...message, schoolId }); + } + + async countReplies(schoolId: string, studentId: string, from: Date, to: Date) { + const ids = new Set( + this.sessions.filter((s) => s.schoolId === schoolId && s.studentId === studentId).map((s) => s.id), + ); + return this.messages.filter( + (m) => + m.schoolId === schoolId && + m.role === "assistant" && + ids.has(m.sessionId) && + m.createdAt >= from && + m.createdAt < to, + ).length; + } + + async savePracticeSet(schoolId: string, sessionId: string, items: PracticeItem[]) { + this.practice.set(`${schoolId}:${sessionId}`, items.map((i) => ({ ...i }))); + } + + async getPracticeItems(schoolId: string, sessionId: string) { + return (this.practice.get(`${schoolId}:${sessionId}`) ?? []).map((i) => ({ ...i })); + } + + async appendPoints(schoolId: string, entry: LedgerEntry) { + const exists = this.ledger.some( + (e) => + e.schoolId === schoolId && + e.studentId === entry.studentId && + e.questionId === entry.questionId && + e.reason === entry.reason, + ); + if (exists) return false; + this.ledger.push({ ...entry, schoolId }); + return true; + } + + async sumPoints(schoolId: string, studentId: string) { + return this.ledger + .filter((e) => e.schoolId === schoolId && e.studentId === studentId) + .reduce((sum, e) => sum + e.points, 0); + } + + async usageCounts(schoolId: string, studentId: string) { + const mine = this.sessions.filter( + (s) => s.schoolId === schoolId && s.studentId === studentId && s.kind === "tutor", + ); + return { + questionsHelped: mine.filter((s) => s.hintsUsed > 0).length, + hintsUsed: mine.reduce((sum, s) => sum + s.hintsUsed, 0), + practiceDone: mine.filter((s) => s.practiceCompleted).length, + }; + } +} diff --git a/services/ai/src/tutorGuard.test.ts b/services/ai/src/tutorGuard.test.ts new file mode 100644 index 0000000..c40b087 --- /dev/null +++ b/services/ai/src/tutorGuard.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from "vitest"; +import { answersMatch, checkNoAnswerLeak, normalize, rewriteRequest, withholdKey } from "./tutorGuard"; + +const leaks = (reply: string, key: string) => !checkNoAnswerLeak(reply, key).ok; + +describe("checkNoAnswerLeak", () => { + it("detects the exact key, ignoring case, space and punctuation", () => { + expect(checkNoAnswerLeak(" PHOTOSYNTHESIS!! ", "photosynthesis")).toEqual({ ok: false, reason: "EXACT" }); + }); + + it("detects the key as a substring of a sentence", () => { + expect(checkNoAnswerLeak("It is called Photosynthesis, by the way.", "photosynthesis")).toEqual({ + ok: false, + reason: "SUBSTRING", + }); + }); + + it("detects a multi-word key with different spacing", () => { + expect(leaks("the Mitochondria\nis the powerhouse", "The mitochondria is the powerhouse")).toBe(true); + }); + + it("detects a number written in digits inside a sentence", () => { + expect(checkNoAnswerLeak("So you get 42 in total.", "42")).toEqual({ ok: false, reason: "NUMBER" }); + }); + + it.each([ + ["The answer is forty-two.", "42"], + ["forty two", "42"], + ["one hundred and five", "105"], + ["Two thousand and six", "2006"], + ["three million", "3000000"], + ])("detects numbers in words: %s", (reply, key) => { + expect(leaks(reply, key)).toBe(true); + }); + + it.each([ + ["It is 1,000 shillings", "1000"], + ["It is 1000.00 shillings", "1000"], + ["about 0.50 of it", "0.5"], + ["about .5 of it", "0.5"], + ])("normalises number formats: %s", (reply, key) => { + expect(leaks(reply, key)).toBe(true); + }); + + it.each([ + ["that is 42cm long", "42"], + ["that is 42 centimetres", "42 cm"], + ["forty-two centimetres", "42 cm"], + ["Ksh 500", "500"], + ["it is 25%", "25"], + ])("detects unit variants: %s vs %s", (reply, key) => { + expect(leaks(reply, key)).toBe(true); + }); + + it("does not flag numbers that only share digits with the key", () => { + expect(leaks("Try 420 or 4.2 or 142", "42")).toBe(false); + }); + + it("does not flag a normal hint", () => { + const hint = "Think about what each side of the equation represents. What happens if you add the same amount to both?"; + expect(leaks(hint, "42")).toBe(false); + expect(leaks(hint, "photosynthesis")).toBe(false); + }); + + it("matches short text keys on whole words only", () => { + expect(leaks("It is a cat", "cat")).toBe(true); + expect(leaks("It is a category", "cat")).toBe(false); + }); + + it("checks every key of a list and skips a blank or missing key", () => { + expect(checkNoAnswerLeak("it is blue", ["red", "blue"]).ok).toBe(false); + expect(checkNoAnswerLeak("anything", " ").ok).toBe(true); + expect(checkNoAnswerLeak("anything", null).ok).toBe(true); + expect(checkNoAnswerLeak("anything", undefined).ok).toBe(true); + }); +}); + +describe("answersMatch", () => { + it("compares across case, punctuation and number format", () => { + expect(answersMatch(" Forty-Two. ", "42")).toBe(true); + expect(answersMatch("1,000", "1000")).toBe(true); + expect(answersMatch("41", "42")).toBe(false); + expect(answersMatch("", "42")).toBe(false); + }); +}); + +describe("normalize", () => { + it("is idempotent", () => { + const once = normalize("One Hundred and Five, 3,500.50!"); + expect(normalize(once)).toBe(once); + }); +}); + +describe("rewriteRequest", () => { + it("asks for a guiding question and carries the question text", () => { + const text = rewriteRequest("What is 6 times 7?"); + expect(text).toContain("What is 6 times 7?"); + expect(text.toLowerCase()).toContain("guiding question"); + }); +}); + +describe("withholdKey", () => { + it("strips answer fields at every depth and keeps the rest", () => { + const question = { + id: "q1", + text: "2+2?", + answer_key: "4", + options: [{ label: "A", correctAnswer: true }, { label: "B" }], + meta: { solution: "add", difficulty: 2 }, + }; + expect(withholdKey(question)).toEqual({ + id: "q1", + text: "2+2?", + options: [{ label: "A" }, { label: "B" }], + meta: { difficulty: 2 }, + }); + expect(JSON.stringify(withholdKey(question))).not.toContain('"4"'); + }); + + it("does not change the original and passes primitives through", () => { + const q = { answerKey: "x", t: 1 }; + withholdKey(q); + expect(q.answerKey).toBe("x"); + expect(withholdKey("plain")).toBe("plain"); + expect(withholdKey(null)).toBeNull(); + }); +}); diff --git a/services/ai/src/tutorGuard.ts b/services/ai/src/tutorGuard.ts new file mode 100644 index 0000000..a5d4a88 --- /dev/null +++ b/services/ai/src/tutorGuard.ts @@ -0,0 +1,134 @@ +export type LeakReason = "EXACT" | "SUBSTRING" | "NUMBER"; +export type LeakCheck = { ok: true } | { ok: false; reason: LeakReason }; + +const MIN_SUBSTRING_KEY_LENGTH = 4; // shorter keys only match whole words, or "a" would flag everything +export const SAFE_FALLBACK_HINT = + "Let us slow down. Re-read the question, tell me what you already know, and think about what the first step could be."; + +const UNITS: Record = { + zero: 0, one: 1, two: 2, three: 3, four: 4, five: 5, six: 6, seven: 7, eight: 8, nine: 9, ten: 10, + eleven: 11, twelve: 12, thirteen: 13, fourteen: 14, fifteen: 15, sixteen: 16, seventeen: 17, + eighteen: 18, nineteen: 19, twenty: 20, thirty: 30, forty: 40, fifty: 50, sixty: 60, seventy: 70, + eighty: 80, ninety: 90, +}; +const SCALES: Record = { thousand: 1_000, million: 1_000_000 }; + +function isNumberWord(t: string): boolean { + return t in UNITS || t === "hundred" || t in SCALES; +} + +/** Lowercases and strips punctuation and thousands separators, keeping decimal points. */ +function clean(text: string): string { + return text + .normalize("NFKC") + .toLowerCase() + .replace(/(?<=\d),(?=\d{3}(?!\d))/g, "") + .replace(/(? String(Number(n))); +} + +/** Full normalisation used for every comparison in this module. */ +export function normalize(text: string): string { + return canonicalNumbers(wordsToDigits(clean(text))); +} + +const NUMBER_TOKEN = /(? { + return new Set(normalized.match(NUMBER_TOKEN) ?? []); +} + +// A key like "42" or "42 cm" is numeric: one number plus optional unit words. +function numericKey(normalizedKey: string): string | null { + const m = /^(\d+(?:\.\d+)?)(?:\s*[a-z]+(?:\s+[a-z]+)?)?$/.exec(normalizedKey); + return m ? m[1] : null; +} + +function leakOne(normalizedReply: string, rawKey: string): LeakCheck { + const key = normalize(rawKey); + if (!key) return { ok: true }; + if (normalizedReply === key) return { ok: false, reason: "EXACT" }; + const number = numericKey(key); + if (number !== null) { + return numbersIn(normalizedReply).has(number) ? { ok: false, reason: "NUMBER" } : { ok: true }; + } + const found = + key.length >= MIN_SUBSTRING_KEY_LENGTH ? normalizedReply.includes(key) : ` ${normalizedReply} `.includes(` ${key} `); + return found ? { ok: false, reason: "SUBSTRING" } : { ok: true }; +} + +/** Says whether a tutor reply gives the answer away; `ok` is true when it does not. */ +export function checkNoAnswerLeak(reply: string, answerKey: string | readonly string[] | null | undefined): LeakCheck { + if (answerKey === null || answerKey === undefined) return { ok: true }; + const normalizedReply = normalize(reply); + for (const key of typeof answerKey === "string" ? [answerKey] : answerKey) { + const result = leakOne(normalizedReply, key); + if (!result.ok) return result; + } + return { ok: true }; +} + +/** Compares a student's practice answer with its key, ignoring case, punctuation and number format. */ +export function answersMatch(given: string, key: string): boolean { + const g = normalize(given); + return g !== "" && g === normalize(key); +} + +/** Prompt that asks the provider to redo a leaking reply as a guiding question. */ +export function rewriteRequest(questionText: string): string { + return [ + "Your previous reply gave away the answer. Rewrite it now.", + "Reply with one short guiding question or hint. Do not state the final answer, the final number, or a full worked solution.", + `Question: ${questionText}`, + ].join("\n"); +} + +const KEY_FIELDS = new Set(["answer_key", "answerkey", "answer", "correctanswer", "correct_answer", "solution"]); + +/** Returns a copy of the question without any answer fields, at every depth. */ +export function withholdKey(question: T): T { + if (Array.isArray(question)) return question.map((q) => withholdKey(q)) as T; + if (typeof question !== "object" || question === null) return question; + const out: Record = {}; + for (const [k, v] of Object.entries(question)) { + if (!KEY_FIELDS.has(k.toLowerCase())) out[k] = withholdKey(v); + } + return out as T; +} diff --git a/services/ai/src/validate.ts b/services/ai/src/validate.ts new file mode 100644 index 0000000..439dcd9 --- /dev/null +++ b/services/ai/src/validate.ts @@ -0,0 +1,71 @@ +import { errors } from "@stackable/service-kit"; + +interface Issue { + path: string; + message: string; +} + +/** Collects every field problem, then throws one 400 envelope listing them all. */ +export class Validator { + private issues: Issue[] = []; + + private constructor(private readonly body: Record) {} + + static from(raw: unknown): Validator { + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) { + throw errors.validation("Body must be a JSON object"); + } + return new Validator(raw as Record); + } + + has(key: string): boolean { + return this.body[key] !== undefined; + } + + string(key: string, max: number, min = 1): string { + const v = this.body[key]; + if (typeof v !== "string" || v.trim().length < min || v.length > max) { + this.issues.push({ path: key, message: `must be a string of ${min} to ${max} characters` }); + return ""; + } + return v; + } + + optionalString(key: string, max: number): string | undefined { + return this.has(key) ? this.string(key, max) : undefined; + } + + int(key: string, min: number, max: number, fallback: number): number { + const v = this.body[key]; + if (v === undefined) return fallback; + if (typeof v !== "number" || !Number.isInteger(v) || v < min || v > max) { + this.issues.push({ path: key, message: `must be an integer from ${min} to ${max}` }); + return fallback; + } + return v; + } + + bool(key: string): boolean { + const v = this.body[key]; + if (typeof v !== "boolean") { + this.issues.push({ path: key, message: "must be true or false" }); + return false; + } + return v; + } + + oneOf(key: string, values: readonly T[], fallback: T): T { + const v = this.body[key]; + if (v === undefined) return fallback; + if (typeof v !== "string" || !values.includes(v as T)) { + this.issues.push({ path: key, message: `must be one of ${values.join(", ")}` }); + return fallback; + } + return v as T; + } + + /** Throws the 400 envelope when any field failed. */ + done(): void { + if (this.issues.length > 0) throw errors.validation("Request validation failed", { fields: this.issues }); + } +} diff --git a/services/ai/tsconfig.json b/services/ai/tsconfig.json new file mode 100644 index 0000000..cc2c43a --- /dev/null +++ b/services/ai/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2023", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "types": ["node"] + }, + "include": ["src"] +} diff --git a/services/communication/README.md b/services/communication/README.md new file mode 100644 index 0000000..6845ddc --- /dev/null +++ b/services/communication/README.md @@ -0,0 +1,65 @@ +# communication + +Go service that is the only sender of messages. The caller sends already-resolved recipients; this service +dedupes to one message per parent (listing their child ids), queues one delivery each, and a background worker +sends them with fallback `push -> sms -> email`. + +## Run + +``` +go run ./cmd/communication +``` + +Without `DATABASE_URL` it runs with an in-memory store and fake adapters (dev only); `/ready` reports `degraded`. + +## Environment + +| Variable | Default | Meaning | +|---|---|---| +| `PORT` | `4006` | HTTP port | +| `SERVICE_TOKEN_SECRET` | required | HS256 secret; tokens use issuer `stackable-gateway`, audience `communication` | +| `DATABASE_URL` | unset | Postgres URL; required when `APP_ENV=production` | +| `APP_ENV` | unset | `production` refuses the memory store, fake adapters and the fake callback verifier | +| `WORKER_INTERVAL_SECONDS` | `5` | Worker tick | +| `QUIET_HOURS_TZ` | `Africa/Nairobi` | Time zone quiet hours are read in | +| `RESEND_API_KEY`, `RESEND_FROM_EMAIL` | unset | Email adapter; disabled unless both set | +| `AT_USERNAME`, `AT_API_KEY`, `AT_SENDER_ID` | unset | Africa's Talking SMS adapter (sender id optional); disabled unless username and key set | + +Push has no provider yet: a fake in dev, a disabled stub in production. + +## Endpoints + +All `/v1` routes need a service token; the school comes from the token, the user from its subject. +Errors use `{ error, code, details, requestId }`. Full contract: `packages/contracts/openapi/communication.yaml`. + +| Route | Purpose | +|---|---| +| `POST /v1/notifications` | Create. 201 when new, 200 with the original when `idempotencyKey` repeats | +| `GET /v1/inbox?limit&cursor` | The caller's notifications, newest first | +| `POST /v1/inbox/{id}/read` | Mark read (idempotent) | +| `GET` / `PUT /v1/preferences` | Per-type channels and quiet hours; PUT upserts the listed types (`*` = default) | +| `GET /v1/notifications/{id}/deliveries` | Count per status | +| `POST /v1/callbacks/{provider}` | Public provider report (`fake`, `resend`, `africastalking`), stored once per provider event id | + +Rules in the worker: channels are filtered by `channelsAllowed` and the user's preference (exact type, else `*`). +Quiet hours are skipped (delivery waits) unless `urgent` or type `safety`. If every channel fails, or none is left, +the delivery becomes `UNDELIVERED` and a `delivery.undelivered` outbox row is written in the same transaction. + +## Migrations + +`goose -dir migrations postgres "$DATABASE_URL" up`. RLS is enabled (not forced) on every table. The worker and the +public callback run without a school, so the service role must own the tables or have BYPASSRLS. +`internal/notifications/pg_store.go` and the migration have NOT been run against a real database; only the memory store is tested. + +## Test + +``` +go vet ./... && go test ./... +``` + +Quick check: + +``` +curl -X POST localhost:4006/v1/notifications -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ + -d '{"type":"fees","title":"Fee due","body":"Please pay","idempotencyKey":"k1","recipients":[{"userId":"","childIds":[""]}]}' +``` diff --git a/services/communication/cmd/communication/main.go b/services/communication/cmd/communication/main.go new file mode 100644 index 0000000..a8b4ef6 --- /dev/null +++ b/services/communication/cmd/communication/main.go @@ -0,0 +1,113 @@ +package main + +import ( + "context" + "errors" + "log/slog" + "os" + "os/signal" + "strconv" + "syscall" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + "github.com/stackable/communication/internal/delivery" + "github.com/stackable/communication/internal/notifications" + kit "github.com/stackable/kyfaru-kit" +) + +const ( + serviceName = "communication" + defaultPort = "4006" + tokenIssuer = "stackable-gateway" + defaultTZ = "Africa/Nairobi" // quiet hours are in school local time + defaultTickSec = 5 +) + +// version is set at build time with -ldflags "-X main.version=...". +var version = "dev" + +func main() { + logger := kit.NewLogger(serviceName) + if err := run(logger); err != nil { + logger.Error("fatal", "err", err) + os.Exit(1) + } +} + +func run(logger *slog.Logger) error { + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + tokens, err := kit.NewServiceTokenVerifierFromEnv(tokenIssuer, serviceName) + if err != nil { + return err + } + store, checks, closeDB, err := buildStore(ctx) + if err != nil { + return err + } + defer closeDB() + + loc, err := time.LoadLocation(envOr("QUIET_HOURS_TZ", defaultTZ)) + if err != nil { + return err + } + tick, _ := strconv.Atoi(envOr("WORKER_INTERVAL_SECONDS", strconv.Itoa(defaultTickSec))) + worker := delivery.NewWorker(store, buildAdapters(), delivery.WorkerConfig{Interval: time.Duration(tick) * time.Second, Location: loc}, logger) + go worker.Run(ctx) + + var verifier notifications.SignatureVerifier = notifications.FakeVerifier{} + if os.Getenv("APP_ENV") == "production" { + verifier = notifications.FakeVerifier{Reject: true} // no real signature scheme is wired yet, so refuse in production + } + router := kit.NewRouter(kit.Config{Name: serviceName, Version: version, Logger: logger, Checks: checks}) + notifications.NewHandler(notifications.NewService(store), verifier).Routes(router, tokens) + + return kit.Serve(ctx, ":"+envOr("PORT", defaultPort), router, logger) +} + +func envOr(name, fallback string) string { + if v := os.Getenv(name); v != "" { + return v + } + return fallback +} + +// buildAdapters uses real providers when configured; outside production the fake fills any gap. +func buildAdapters() map[string]delivery.ChannelAdapter { + resend, at := delivery.NewResendFromEnv(), delivery.NewAfricasTalkingFromEnv() + prod := os.Getenv("APP_ENV") == "production" + pick := func(real delivery.ChannelAdapter, enabled bool) delivery.ChannelAdapter { + if enabled { + return real + } + if prod { + return delivery.DisabledAdapter{} + } + return delivery.NewFakeAdapter() + } + return map[string]delivery.ChannelAdapter{ + notifications.ChannelPush: pick(nil, false), // push has no provider yet + notifications.ChannelSMS: pick(at, at.Enabled()), + notifications.ChannelEmail: pick(resend, resend.Enabled()), + } +} + +// buildStore uses Postgres when DATABASE_URL is set and an in-memory store only outside production. +func buildStore(ctx context.Context) (notifications.Store, []kit.Check, func(), error) { + url := os.Getenv("DATABASE_URL") + if url == "" { + if os.Getenv("APP_ENV") == "production" { + return nil, nil, nil, errors.New("DATABASE_URL is required in production") + } + unset := func(context.Context) error { return errors.New("DATABASE_URL not set") } + return notifications.NewMemoryStore(), []kit.Check{{Name: "database", Required: false, Fn: unset}}, func() {}, nil + } + pool, err := pgxpool.New(ctx, url) + if err != nil { + return nil, nil, nil, err + } + checks := []kit.Check{{Name: "database", Required: true, Fn: pool.Ping}} + return notifications.NewPgStore(pool), checks, pool.Close, nil +} diff --git a/services/communication/go.mod b/services/communication/go.mod new file mode 100644 index 0000000..cbda4e9 --- /dev/null +++ b/services/communication/go.mod @@ -0,0 +1,11 @@ +module github.com/stackable/communication + +go 1.25.0 + +require ( + github.com/go-chi/chi/v5 v5.3.2 + github.com/jackc/pgx/v5 v5.11.0 + github.com/stackable/kyfaru-kit v0.0.0 +) + +replace github.com/stackable/kyfaru-kit => ../../packages/kyfaru-kit diff --git a/services/communication/go.sum b/services/communication/go.sum new file mode 100644 index 0000000..a785dba --- /dev/null +++ b/services/communication/go.sum @@ -0,0 +1,30 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-chi/chi/v5 v5.3.2 h1:5YQkICvTCSZ25hoRsyJazN0scjzKGiu4VAUc7H1o1nY= +github.com/go-chi/chi/v5 v5.3.2/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg= +github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= +golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/services/communication/internal/delivery/adapter.go b/services/communication/internal/delivery/adapter.go new file mode 100644 index 0000000..4b102a0 --- /dev/null +++ b/services/communication/internal/delivery/adapter.go @@ -0,0 +1,74 @@ +package delivery + +import ( + "context" + "errors" + "sync" +) + +// ErrDisabled is returned by an adapter whose credentials are not configured. +var ErrDisabled = errors.New("channel adapter disabled") + +// ErrNoAddress is returned when the recipient has no email or phone for the channel. +var ErrNoAddress = errors.New("recipient has no address for channel") + +// Message is one send attempt on one channel. +type Message struct { + SchoolID string + DeliveryID string + NotificationID string + UserID string + Channel string + Type string + Title string + Body string + ChildIDs []string + Email string + Phone string +} + +// ChannelAdapter sends one message through a provider and returns the provider's reference. +type ChannelAdapter interface { + Send(ctx context.Context, msg Message) (providerRef string, err error) +} + +// FakeAdapter always succeeds unless FailWith was set; it records every call for tests. +type FakeAdapter struct { + mu sync.Mutex + err error + calls []Message +} + +// NewFakeAdapter returns an adapter that succeeds. +func NewFakeAdapter() *FakeAdapter { return &FakeAdapter{} } + +// FailWith makes every following Send return err; nil restores success. +func (f *FakeAdapter) FailWith(err error) { + f.mu.Lock() + defer f.mu.Unlock() + f.err = err +} + +// Send implements ChannelAdapter. +func (f *FakeAdapter) Send(_ context.Context, msg Message) (string, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.calls = append(f.calls, msg) + if f.err != nil { + return "", f.err + } + return "fake-" + msg.DeliveryID, nil +} + +// Calls returns the messages sent so far. +func (f *FakeAdapter) Calls() []Message { + f.mu.Lock() + defer f.mu.Unlock() + return append([]Message{}, f.calls...) +} + +// DisabledAdapter stands in for a channel with no provider; it always fails so the worker falls back. +type DisabledAdapter struct{} + +// Send implements ChannelAdapter. +func (DisabledAdapter) Send(context.Context, Message) (string, error) { return "", ErrDisabled } diff --git a/services/communication/internal/delivery/africastalking.go b/services/communication/internal/delivery/africastalking.go new file mode 100644 index 0000000..d2c0e09 --- /dev/null +++ b/services/communication/internal/delivery/africastalking.go @@ -0,0 +1,81 @@ +package delivery + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "os" + "strings" +) + +const africasTalkingBaseURL = "https://api.africastalking.com" + +// AfricasTalkingAdapter sends SMS; it is disabled when AT_USERNAME or AT_API_KEY is unset. +// The request shape follows the public bulk SMS API and has not been run against the live service. +type AfricasTalkingAdapter struct { + username string + apiKey string + senderID string + baseURL string + client *http.Client +} + +// NewAfricasTalkingFromEnv reads AT_USERNAME, AT_API_KEY and the optional AT_SENDER_ID. +func NewAfricasTalkingFromEnv() *AfricasTalkingAdapter { + return &AfricasTalkingAdapter{ + username: os.Getenv("AT_USERNAME"), apiKey: os.Getenv("AT_API_KEY"), senderID: os.Getenv("AT_SENDER_ID"), + baseURL: africasTalkingBaseURL, client: &http.Client{Timeout: providerHTTPLimit}, + } +} + +// Enabled reports whether credentials are present. +func (a *AfricasTalkingAdapter) Enabled() bool { return a.username != "" && a.apiKey != "" } + +// Send posts one SMS and returns the provider message id. +func (a *AfricasTalkingAdapter) Send(ctx context.Context, msg Message) (string, error) { + if !a.Enabled() { + return "", ErrDisabled + } + if msg.Phone == "" { + return "", ErrNoAddress + } + form := url.Values{"username": {a.username}, "to": {msg.Phone}, "message": {msg.Title + ": " + msg.Body}} + if a.senderID != "" { + form.Set("from", a.senderID) + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, a.baseURL+"/version1/messaging", strings.NewReader(form.Encode())) + if err != nil { + return "", fmt.Errorf("build sms request: %w", err) + } + req.Header.Set("apiKey", a.apiKey) + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + resp, err := a.client.Do(req) + if err != nil { + return "", fmt.Errorf("sms request: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode/100 != 2 { + return "", fmt.Errorf("africastalking returned status %d", resp.StatusCode) + } + var out struct { + SMSMessageData struct { + Recipients []struct { + StatusCode int `json:"statusCode"` + MessageID string `json:"messageId"` + Status string `json:"status"` + } `json:"Recipients"` + } `json:"SMSMessageData"` + } + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil || len(out.SMSMessageData.Recipients) == 0 { + return "", fmt.Errorf("africastalking response has no recipients") + } + rec := out.SMSMessageData.Recipients[0] + // 100-102 are the accepted codes; anything else is a rejection + if rec.StatusCode < 100 || rec.StatusCode > 102 { + return "", fmt.Errorf("africastalking rejected sms: %s", rec.Status) + } + return rec.MessageID, nil +} diff --git a/services/communication/internal/delivery/delivery_test.go b/services/communication/internal/delivery/delivery_test.go new file mode 100644 index 0000000..608ae58 --- /dev/null +++ b/services/communication/internal/delivery/delivery_test.go @@ -0,0 +1,263 @@ +package delivery + +import ( + "context" + "errors" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stackable/communication/internal/notifications" +) + +const ( + school = "11111111-1111-4111-8111-111111111111" + user = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" +) + +type rig struct { + store *notifications.MemoryStore + adapters map[string]*FakeAdapter + worker *Worker + now time.Time +} + +// newRig builds a worker over the memory store with one fake adapter per channel; clock is 12:00 UTC. +func newRig(t *testing.T) *rig { + t.Helper() + r := &rig{store: notifications.NewMemoryStore(), adapters: map[string]*FakeAdapter{}, now: time.Date(2026, 3, 2, 12, 0, 0, 0, time.UTC)} + m := map[string]ChannelAdapter{} + for _, c := range notifications.ChannelOrder { + r.adapters[c] = NewFakeAdapter() + m[c] = r.adapters[c] + } + r.worker = NewWorker(r.store, m, WorkerConfig{Now: func() time.Time { return r.now }}, slog.New(slog.NewJSONHandler(io.Discard, nil))) + return r +} + +func (r *rig) send(t *testing.T, notifType string, urgent bool, channels []string) string { + t.Helper() + id := notifications.NewID() + _, _, err := r.store.CreateNotification(context.Background(), notifications.NewNotification{ + Notification: notifications.Notification{ID: id, Type: notifType, Title: "T", Body: "B", Urgent: urgent, IdempotencyKey: id}, + SchoolID: school, + Recipients: []notifications.Recipient{{UserID: user, ChannelsAllowed: channels, Email: "p@example.com", Phone: "+254700000000"}}, + }) + if err != nil { + t.Fatal(err) + } + return id +} + +func (r *rig) tick(t *testing.T) { + t.Helper() + if _, err := r.worker.RunOnce(context.Background()); err != nil { + t.Fatal(err) + } +} + +func (r *rig) delivery(id string) notifications.DeliveryRow { return r.store.Deliveries(school, id)[0] } + +var all = []string{"push", "sms", "email"} + +func TestFirstChannelIsPush(t *testing.T) { + r := newRig(t) + id := r.send(t, "fees", false, all) + r.tick(t) + d := r.delivery(id) + if d.Status != "SENT" || d.Channel != "push" || d.Attempt != 1 || len(r.adapters["sms"].Calls()) != 0 { + t.Fatalf("%+v", d) + } +} + +func TestFallbackChainOrder(t *testing.T) { + r := newRig(t) + r.adapters["push"].FailWith(errors.New("no device")) + r.adapters["sms"].FailWith(errors.New("no credit")) + id := r.send(t, "fees", false, all) + r.tick(t) + d := r.delivery(id) + if d.Status != "SENT" || d.Channel != "email" || d.Attempt != 3 { + t.Fatalf("want SENT via email after 3 attempts, got %+v", d) + } + if len(r.adapters["push"].Calls()) != 1 || len(r.adapters["sms"].Calls()) != 1 || len(r.adapters["email"].Calls()) != 1 { + t.Fatal("each channel must be tried exactly once, in order") + } +} + +func TestUndeliveredWritesOutbox(t *testing.T) { + r := newRig(t) + for _, a := range r.adapters { + a.FailWith(errors.New("down")) + } + id := r.send(t, "fees", false, all) + r.tick(t) + d := r.delivery(id) + if d.Status != "UNDELIVERED" || d.Attempt != 3 || d.Error != "down" { + t.Fatalf("%+v", d) + } + out := r.store.Outbox() + if len(out) != 1 || out[0].Topic != "delivery.undelivered" || out[0].SchoolID != school { + t.Fatalf("outbox %+v", out) + } + r.tick(t) + if len(r.store.Outbox()) != 1 { + t.Fatal("a finished delivery must not be retried") + } +} + +func TestChannelsAllowedFilter(t *testing.T) { + r := newRig(t) + id := r.send(t, "fees", false, []string{"sms", "email"}) + r.tick(t) + d := r.delivery(id) + if d.Channel != "sms" || len(r.adapters["push"].Calls()) != 0 { + t.Fatalf("%+v", d) + } +} + +func TestPreferencesFilterChannels(t *testing.T) { + r := newRig(t) + _ = r.store.SavePreferences(context.Background(), school, user, []notifications.Preference{{Type: "*", Channels: []string{"email"}}}) + id := r.send(t, "fees", false, all) + r.tick(t) + if d := r.delivery(id); d.Channel != "email" || d.Attempt != 1 || len(r.adapters["push"].Calls()) != 0 { + t.Fatalf("%+v", d) + } +} + +func TestExactTypePreferenceBeatsWildcard(t *testing.T) { + r := newRig(t) + _ = r.store.SavePreferences(context.Background(), school, user, []notifications.Preference{ + {Type: "*", Channels: []string{"email"}}, {Type: "fees", Channels: []string{"sms"}}}) + id := r.send(t, "fees", false, all) + r.tick(t) + if d := r.delivery(id); d.Channel != "sms" { + t.Fatalf("%+v", d) + } +} + +func TestNoAllowedChannelIsUndelivered(t *testing.T) { + r := newRig(t) + _ = r.store.SavePreferences(context.Background(), school, user, []notifications.Preference{{Type: "*", Channels: []string{"push"}}}) + id := r.send(t, "fees", false, []string{"email"}) + r.tick(t) + if d := r.delivery(id); d.Status != "UNDELIVERED" || len(r.store.Outbox()) != 1 { + t.Fatalf("%+v", d) + } +} + +func quiet(r *rig, hour int) { + start, end := "22:00", "06:00" + _ = r.store.SavePreferences(context.Background(), school, user, []notifications.Preference{{Type: "*", Channels: all, QuietStart: &start, QuietEnd: &end}}) + r.now = time.Date(2026, 3, 2, hour, 30, 0, 0, time.UTC) +} + +func TestQuietHoursDeferThenSend(t *testing.T) { + r := newRig(t) + quiet(r, 23) + id := r.send(t, "fees", false, all) + r.tick(t) + if d := r.delivery(id); d.Status != "QUEUED" || len(r.adapters["push"].Calls()) != 0 { + t.Fatalf("must wait during quiet hours: %+v", d) + } + r.now = time.Date(2026, 3, 3, 7, 0, 0, 0, time.UTC) + r.tick(t) + if d := r.delivery(id); d.Status != "SENT" { + t.Fatalf("must send after quiet hours: %+v", d) + } +} + +func TestQuietHoursWrapPastMidnight(t *testing.T) { + r := newRig(t) + quiet(r, 3) + id := r.send(t, "fees", false, all) + r.tick(t) + if d := r.delivery(id); d.Status != "QUEUED" { + t.Fatalf("03:30 is inside 22:00-06:00: %+v", d) + } +} + +func TestQuietHoursOverriddenByUrgentAndSafety(t *testing.T) { + for name, tc := range map[string]struct { + notifType string + urgent bool + }{"urgent": {"fees", true}, "safety": {"safety", false}} { + t.Run(name, func(t *testing.T) { + r := newRig(t) + quiet(r, 23) + id := r.send(t, tc.notifType, tc.urgent, all) + r.tick(t) + if d := r.delivery(id); d.Status != "SENT" { + t.Fatalf("%+v", d) + } + }) + } +} + +func TestClaimPreventsDoubleSend(t *testing.T) { + r := newRig(t) + id := r.send(t, "fees", false, all) + jobs, _ := r.store.ClaimQueued(context.Background(), 10, time.Minute) + again, _ := r.store.ClaimQueued(context.Background(), 10, time.Minute) + if len(jobs) != 1 || len(again) != 0 { + t.Fatalf("claims %d %d", len(jobs), len(again)) + } + _ = id +} + +func TestRunStopsOnContextCancel(t *testing.T) { + r := newRig(t) + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { r.worker.Run(ctx); close(done) }() + cancel() + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("worker did not stop") + } +} + +func TestResendAdapter(t *testing.T) { + var gotAuth, gotKey string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + gotAuth, gotKey = req.Header.Get("Authorization"), req.Header.Get("Idempotency-Key") + _, _ = w.Write([]byte(`{"id":"em_1"}`)) + })) + defer srv.Close() + a := &ResendAdapter{apiKey: "k", from: "a@b.c", baseURL: srv.URL, client: srv.Client()} + ref, err := a.Send(context.Background(), Message{DeliveryID: "d1", Email: "p@x.com", Title: "t", Body: "b"}) + if err != nil || ref != "em_1" || gotAuth != "Bearer k" || gotKey != "d1" { + t.Fatalf("ref=%q err=%v auth=%q key=%q", ref, err, gotAuth, gotKey) + } + if _, err := (&ResendAdapter{}).Send(context.Background(), Message{Email: "p@x.com"}); !errors.Is(err, ErrDisabled) { + t.Fatalf("unset adapter must be disabled, got %v", err) + } + if _, err := a.Send(context.Background(), Message{}); !errors.Is(err, ErrNoAddress) { + t.Fatalf("want ErrNoAddress, got %v", err) + } +} + +func TestAfricasTalkingAdapter(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + _ = req.ParseForm() + if req.Header.Get("apiKey") != "k" || req.Form.Get("to") != "+254700000000" { + w.WriteHeader(400) + return + } + _, _ = w.Write([]byte(`{"SMSMessageData":{"Recipients":[{"statusCode":101,"messageId":"AT_1","status":"Success"}]}}`)) + })) + defer srv.Close() + a := &AfricasTalkingAdapter{username: "u", apiKey: "k", baseURL: srv.URL, client: srv.Client()} + ref, err := a.Send(context.Background(), Message{Phone: "+254700000000", Title: "t", Body: "b"}) + if err != nil || ref != "AT_1" { + t.Fatalf("ref=%q err=%v", ref, err) + } + if _, err := (&AfricasTalkingAdapter{}).Send(context.Background(), Message{Phone: "1"}); !errors.Is(err, ErrDisabled) { + t.Fatalf("got %v", err) + } +} diff --git a/services/communication/internal/delivery/resend.go b/services/communication/internal/delivery/resend.go new file mode 100644 index 0000000..c3fa5dd --- /dev/null +++ b/services/communication/internal/delivery/resend.go @@ -0,0 +1,71 @@ +package delivery + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "os" + "time" +) + +const ( + resendBaseURL = "https://api.resend.com" + providerHTTPLimit = 10 * time.Second // a hung provider must not stall the worker +) + +// ResendAdapter sends email through Resend; it is disabled when the key or sender is unset. +type ResendAdapter struct { + apiKey string + from string + baseURL string + client *http.Client +} + +// NewResendFromEnv reads RESEND_API_KEY and RESEND_FROM_EMAIL. +func NewResendFromEnv() *ResendAdapter { + return &ResendAdapter{ + apiKey: os.Getenv("RESEND_API_KEY"), from: os.Getenv("RESEND_FROM_EMAIL"), + baseURL: resendBaseURL, client: &http.Client{Timeout: providerHTTPLimit}, + } +} + +// Enabled reports whether credentials are present. +func (a *ResendAdapter) Enabled() bool { return a.apiKey != "" && a.from != "" } + +// Send posts one email and returns Resend's email id. +func (a *ResendAdapter) Send(ctx context.Context, msg Message) (string, error) { + if !a.Enabled() { + return "", ErrDisabled + } + if msg.Email == "" { + return "", ErrNoAddress + } + body, err := json.Marshal(map[string]any{"from": a.from, "to": []string{msg.Email}, "subject": msg.Title, "text": msg.Body}) + if err != nil { + return "", fmt.Errorf("marshal resend body: %w", err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, a.baseURL+"/emails", bytes.NewReader(body)) + if err != nil { + return "", fmt.Errorf("build resend request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+a.apiKey) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Idempotency-Key", msg.DeliveryID) // a retried send must not email the parent twice + resp, err := a.client.Do(req) + if err != nil { + return "", fmt.Errorf("resend request: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode/100 != 2 { + return "", fmt.Errorf("resend returned status %d", resp.StatusCode) + } + var out struct { + ID string `json:"id"` + } + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil || out.ID == "" { + return "", fmt.Errorf("resend response has no id") + } + return out.ID, nil +} diff --git a/services/communication/internal/delivery/worker.go b/services/communication/internal/delivery/worker.go new file mode 100644 index 0000000..3bc1445 --- /dev/null +++ b/services/communication/internal/delivery/worker.go @@ -0,0 +1,155 @@ +package delivery + +import ( + "context" + "fmt" + "log/slog" + "time" + + _ "time/tzdata" // embeds the zone database so Africa/Nairobi loads on any host + + "github.com/stackable/communication/internal/notifications" +) + +const claimLease = 2 * time.Minute // a crashed worker's claim expires after this + +// WorkerConfig tunes the delivery loop. +type WorkerConfig struct { + Interval time.Duration + Batch int + Location *time.Location + Now func() time.Time +} + +// Worker turns QUEUED deliveries into sends. +type Worker struct { + store notifications.Store + adapters map[string]ChannelAdapter + cfg WorkerConfig + logger *slog.Logger +} + +// NewWorker fills config defaults: 5s interval, 50 per batch, UTC, real clock. +func NewWorker(store notifications.Store, adapters map[string]ChannelAdapter, cfg WorkerConfig, logger *slog.Logger) *Worker { + if cfg.Interval <= 0 { + cfg.Interval = 5 * time.Second + } + if cfg.Batch <= 0 { + cfg.Batch = 50 + } + if cfg.Location == nil { + cfg.Location = time.UTC + } + if cfg.Now == nil { + cfg.Now = time.Now + } + return &Worker{store: store, adapters: adapters, cfg: cfg, logger: logger} +} + +// Run ticks until ctx is cancelled. +func (w *Worker) Run(ctx context.Context) { + t := time.NewTicker(w.cfg.Interval) + defer t.Stop() + for { + if _, err := w.RunOnce(ctx); err != nil && ctx.Err() == nil { + w.logger.Error("delivery tick failed", "err", err) + } + select { + case <-ctx.Done(): + return + case <-t.C: + } + } +} + +// RunOnce claims one batch and processes it; it returns how many deliveries were claimed. +func (w *Worker) RunOnce(ctx context.Context) (int, error) { + jobs, err := w.store.ClaimQueued(ctx, w.cfg.Batch, claimLease) + if err != nil { + return 0, err + } + for _, job := range jobs { + if err := w.process(ctx, job); err != nil { + w.logger.Error("process delivery", "deliveryId", job.DeliveryID, "err", err) + } + } + return len(jobs), nil +} + +// buildChain returns the channels to try, in fallback order, after the recipient and preference filters. +func buildChain(job notifications.Job, pref *notifications.Preference) []string { + var chain []string + for _, c := range notifications.ChannelOrder { + if contains(job.ChannelsAllowed, c) && (pref == nil || contains(pref.Channels, c)) { + chain = append(chain, c) + } + } + return chain +} + +func contains(list []string, v string) bool { + for _, x := range list { + if x == v { + return true + } + } + return false +} + +// inQuietHours handles ranges that wrap midnight, such as 22:00 to 06:00. +func inQuietHours(pref *notifications.Preference, now time.Time) bool { + if pref == nil || pref.QuietStart == nil || pref.QuietEnd == nil { + return false + } + start, end, cur := minutes(*pref.QuietStart), minutes(*pref.QuietEnd), now.Hour()*60+now.Minute() + if start == end { + return false + } + if start < end { + return cur >= start && cur < end + } + return cur >= start || cur < end +} + +func minutes(hhmm string) int { + var h, m int + _, _ = fmt.Sscanf(hhmm, "%d:%d", &h, &m) // values were validated as HH:MM when saved + return h*60 + m +} + +func (w *Worker) process(ctx context.Context, job notifications.Job) error { + pref, err := w.store.FindPreference(ctx, job.SchoolID, job.UserID, job.Type) + if err != nil { + _ = w.store.ReleaseDelivery(ctx, job.SchoolID, job.DeliveryID) + return fmt.Errorf("find preference: %w", err) + } + chain := buildChain(job, pref) + quiet := inQuietHours(pref, w.cfg.Now().In(w.cfg.Location)) + if len(chain) > 0 && quiet && !job.Urgent && job.Type != notifications.TypeSafety { + return w.store.ReleaseDelivery(ctx, job.SchoolID, job.DeliveryID) // retried on a later tick once quiet hours end + } + + attempt, lastChannel, lastErr := job.Attempt, "", "no channel allowed" + for _, ch := range chain { + adapter, ok := w.adapters[ch] + if !ok { + adapter = DisabledAdapter{} + } + attempt++ + ref, err := adapter.Send(ctx, Message{ + SchoolID: job.SchoolID, DeliveryID: job.DeliveryID, NotificationID: job.NotificationID, UserID: job.UserID, + Channel: ch, Type: job.Type, Title: job.Title, Body: job.Body, ChildIDs: job.ChildIDs, Email: job.Email, Phone: job.Phone, + }) + if err == nil { + return w.store.FinishDelivery(ctx, job.SchoolID, job.DeliveryID, + notifications.Result{Status: notifications.StatusSent, Channel: ch, Attempt: attempt, ProviderRef: ref}) + } + if ctx.Err() != nil { + return w.store.ReleaseDelivery(context.WithoutCancel(ctx), job.SchoolID, job.DeliveryID) // shutdown is not a failed send + } + w.logger.Warn("channel failed", "deliveryId", job.DeliveryID, "channel", ch, "err", err) + lastChannel, lastErr = ch, err.Error() + } + return w.store.FinishDelivery(ctx, job.SchoolID, job.DeliveryID, + notifications.Result{Status: notifications.StatusUndelivered, Channel: lastChannel, Attempt: attempt, Error: lastErr}) +} diff --git a/services/communication/internal/notifications/callbacks.go b/services/communication/internal/notifications/callbacks.go new file mode 100644 index 0000000..e3ff179 --- /dev/null +++ b/services/communication/internal/notifications/callbacks.go @@ -0,0 +1,112 @@ +package notifications + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + + kit "github.com/stackable/kyfaru-kit" +) + +// SignatureVerifier checks that a provider callback really came from the provider. +type SignatureVerifier interface { + Verify(provider string, headers map[string]string, body []byte) error +} + +// FakeVerifier accepts every callback unless Reject is set; it is for dev and tests only. +type FakeVerifier struct{ Reject bool } + +// Verify implements SignatureVerifier. +func (f FakeVerifier) Verify(string, map[string]string, []byte) error { + if f.Reject { + return errors.New("signature rejected") + } + return nil +} + +// parsers turn each provider's callback body into a CallbackEvent; an empty status means "not a final report". +// Headers are lower-cased by the handler. +var parsers = map[string]func(headers map[string]string, body []byte) (CallbackEvent, error){ + "fake": parseFake, + "resend": parseResend, + "africastalking": parseAfricasTalking, +} + +func parseFake(_ map[string]string, body []byte) (CallbackEvent, error) { + var p struct { + ID string `json:"id"` + ProviderRef string `json:"providerRef"` + Status string `json:"status"` + } + if err := json.Unmarshal(body, &p); err != nil { + return CallbackEvent{}, err + } + return CallbackEvent{EventID: p.ID, ProviderRef: p.ProviderRef, Status: p.Status}, nil +} + +// parseResend maps Resend webhook types; the event id is the svix-id header Resend sends. +func parseResend(headers map[string]string, body []byte) (CallbackEvent, error) { + var p struct { + Type string `json:"type"` + Data struct { + EmailID string `json:"email_id"` + } `json:"data"` + } + if err := json.Unmarshal(body, &p); err != nil { + return CallbackEvent{}, err + } + status := "" + switch p.Type { + case "email.delivered": + status = StatusDelivered + case "email.bounced", "email.failed", "email.complained": + status = StatusFailed + } + return CallbackEvent{EventID: headers["svix-id"], ProviderRef: p.Data.EmailID, Status: status}, nil +} + +// parseAfricasTalking maps the delivery report fields of Africa's Talking. +func parseAfricasTalking(_ map[string]string, body []byte) (CallbackEvent, error) { + var p struct { + ID string `json:"id"` + Status string `json:"status"` + } + if err := json.Unmarshal(body, &p); err != nil { + return CallbackEvent{}, err + } + status := "" + switch strings.ToLower(p.Status) { + case "success": + status = StatusDelivered + case "failed", "rejected", "absentsubscriber": + status = StatusFailed + } + return CallbackEvent{EventID: p.ID, ProviderRef: p.ID, Status: status}, nil +} + +// RecordCallback stores one provider delivery report. +// It exists so providers can retry the same callback safely; repeats change nothing. +func (s *Service) RecordCallback(ctx context.Context, provider string, headers map[string]string, body []byte) (CallbackOutcome, error) { + parse, ok := parsers[provider] + if !ok { + return CallbackOutcome{}, kit.NotFound("Unknown provider.") + } + ev, err := parse(headers, body) + if err != nil { + return CallbackOutcome{}, kit.Validation("Callback body is not valid for this provider.", nil) + } + ev.Provider, ev.Payload = provider, body + if ev.EventID == "" || ev.ProviderRef == "" { + return CallbackOutcome{}, kit.Validation("Callback has no event id or provider reference.", nil) + } + if ev.Status != StatusDelivered && ev.Status != StatusFailed { + return CallbackOutcome{}, nil // progress reports such as "sent" carry nothing to store + } + out, err := s.store.RecordCallback(ctx, ev) + if err != nil { + return CallbackOutcome{}, fmt.Errorf("record callback: %w", err) + } + return out, nil +} diff --git a/services/communication/internal/notifications/handler.go b/services/communication/internal/notifications/handler.go new file mode 100644 index 0000000..f46f807 --- /dev/null +++ b/services/communication/internal/notifications/handler.go @@ -0,0 +1,166 @@ +package notifications + +import ( + "io" + "net/http" + "strconv" + "strings" + + "github.com/go-chi/chi/v5" + kit "github.com/stackable/kyfaru-kit" +) + +const maxCallbackBytes = 1 << 20 // 1 MiB, same cap as every other body + +// Handler exposes the notification routes. +type Handler struct { + svc *Service + verifier SignatureVerifier +} + +// NewHandler builds a Handler; the verifier guards the public callback route. +func NewHandler(svc *Service, verifier SignatureVerifier) *Handler { + return &Handler{svc: svc, verifier: verifier} +} + +// Routes mounts the /v1 routes; everything except callbacks needs a service token. +func (h *Handler) Routes(r chi.Router, tokens *kit.ServiceTokenVerifier) { + r.Post("/v1/callbacks/{provider}", kit.Handle(h.callback)) + r.Group(func(g chi.Router) { + g.Use(kit.RequireServiceToken(tokens)) + g.Post("/v1/notifications", kit.Handle(h.create)) + g.Get("/v1/notifications/{id}/deliveries", kit.Handle(h.deliveries)) + g.Get("/v1/inbox", kit.Handle(h.inbox)) + g.Post("/v1/inbox/{id}/read", kit.Handle(h.markRead)) + g.Get("/v1/preferences", kit.Handle(h.getPreferences)) + g.Put("/v1/preferences", kit.Handle(h.putPreferences)) + }) +} + +// actor returns the school and user the service token was issued for. +func actor(r *http.Request) (schoolID, userID string, err error) { + claims, ok := kit.ClaimsFrom(r.Context()) + if !ok { + return "", "", kit.Unauthorized("Missing service token.") + } + return claims.SchoolID, claims.Subject, nil +} + +func (h *Handler) create(w http.ResponseWriter, r *http.Request) error { + schoolID, _, err := actor(r) + if err != nil { + return err + } + var in CreateInput + if err := kit.DecodeJSON(w, r, &in); err != nil { + return err + } + n, created, err := h.svc.Create(r.Context(), schoolID, in) + if err != nil { + return err + } + status := http.StatusOK + if created { + status = http.StatusCreated + } + kit.WriteJSON(w, status, n) + return nil +} + +func (h *Handler) inbox(w http.ResponseWriter, r *http.Request) error { + schoolID, userID, err := actor(r) + if err != nil { + return err + } + limit := 0 + if raw := r.URL.Query().Get("limit"); raw != "" { + if limit, err = strconv.Atoi(raw); err != nil || limit < 1 { + return kit.Validation("limit must be between 1 and 100.", map[string]string{"limit": "1-100"}) + } + } + page, err := h.svc.Inbox(r.Context(), schoolID, userID, limit, r.URL.Query().Get("cursor")) + if err != nil { + return err + } + kit.WriteJSON(w, http.StatusOK, page) + return nil +} + +func (h *Handler) markRead(w http.ResponseWriter, r *http.Request) error { + schoolID, userID, err := actor(r) + if err != nil { + return err + } + if err := h.svc.MarkRead(r.Context(), schoolID, userID, chi.URLParam(r, "id")); err != nil { + return err + } + kit.WriteJSON(w, http.StatusOK, map[string]bool{"read": true}) + return nil +} + +func (h *Handler) getPreferences(w http.ResponseWriter, r *http.Request) error { + schoolID, userID, err := actor(r) + if err != nil { + return err + } + prefs, err := h.svc.Preferences(r.Context(), schoolID, userID) + if err != nil { + return err + } + kit.WriteJSON(w, http.StatusOK, map[string]any{"preferences": prefs}) + return nil +} + +func (h *Handler) putPreferences(w http.ResponseWriter, r *http.Request) error { + schoolID, userID, err := actor(r) + if err != nil { + return err + } + var in struct { + Preferences []Preference `json:"preferences"` + } + if err := kit.DecodeJSON(w, r, &in); err != nil { + return err + } + prefs, err := h.svc.SavePreferences(r.Context(), schoolID, userID, in.Preferences) + if err != nil { + return err + } + kit.WriteJSON(w, http.StatusOK, map[string]any{"preferences": prefs}) + return nil +} + +func (h *Handler) deliveries(w http.ResponseWriter, r *http.Request) error { + schoolID, _, err := actor(r) + if err != nil { + return err + } + id := chi.URLParam(r, "id") + counts, err := h.svc.DeliveryCounts(r.Context(), schoolID, id) + if err != nil { + return err + } + kit.WriteJSON(w, http.StatusOK, map[string]any{"notificationId": id, "counts": counts}) + return nil +} + +func (h *Handler) callback(w http.ResponseWriter, r *http.Request) error { + body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxCallbackBytes)) + if err != nil { + return kit.Validation("Request body could not be read.", nil) + } + provider := chi.URLParam(r, "provider") + headers := map[string]string{} + for name := range r.Header { + headers[strings.ToLower(name)] = r.Header.Get(name) + } + if err := h.verifier.Verify(provider, headers, body); err != nil { + return kit.Unauthorized("Invalid callback signature.") + } + out, err := h.svc.RecordCallback(r.Context(), provider, headers, body) + if err != nil { + return err + } + kit.WriteJSON(w, http.StatusOK, map[string]any{"received": true, "matched": out.Matched, "duplicate": out.Duplicate}) + return nil +} diff --git a/services/communication/internal/notifications/memory_store.go b/services/communication/internal/notifications/memory_store.go new file mode 100644 index 0000000..36ddde8 --- /dev/null +++ b/services/communication/internal/notifications/memory_store.go @@ -0,0 +1,322 @@ +package notifications + +import ( + "context" + "encoding/json" + "sort" + "strings" + "sync" + "time" +) + +type recipientRow struct { + Recipient + schoolID string + readAt *time.Time +} + +// DeliveryRow is one delivery as the memory store holds it. +type DeliveryRow struct { + ID string + SchoolID string + NotificationID string + UserID string + Channel string + Status string + Attempt int + ProviderRef string + Error string + claimedAt time.Time +} + +// OutboxRow is one event waiting to be published. +type OutboxRow struct { + SchoolID string + Topic string + Payload []byte +} + +type notificationRow struct { + Notification + schoolID string +} + +// MemoryStore is an in-process Store for tests and local runs without a database. +type MemoryStore struct { + mu sync.Mutex + now func() time.Time + notifs map[string]*notificationRow + byKey map[string]string + recipients map[string][]*recipientRow + deliveries []*DeliveryRow + prefs map[string]Preference + callbacks map[string]bool + outbox []OutboxRow +} + +// NewMemoryStore returns an empty MemoryStore using the real clock. +func NewMemoryStore() *MemoryStore { return NewMemoryStoreWithClock(time.Now) } + +// NewMemoryStoreWithClock lets tests control created_at and claim leases. +func NewMemoryStoreWithClock(now func() time.Time) *MemoryStore { + return &MemoryStore{ + now: now, + notifs: map[string]*notificationRow{}, + byKey: map[string]string{}, + recipients: map[string][]*recipientRow{}, + prefs: map[string]Preference{}, + callbacks: map[string]bool{}, + } +} + +// key joins parts with a NUL so ("a","bc") and ("ab","c") never collide. +func key(parts ...string) string { + return strings.Join(parts, "\x00") +} + +// CreateNotification stores the notification once per (school, idempotency key). +func (m *MemoryStore) CreateNotification(_ context.Context, n NewNotification) (Notification, bool, error) { + m.mu.Lock() + defer m.mu.Unlock() + if id, ok := m.byKey[key(n.SchoolID, n.IdempotencyKey)]; ok { + return m.notifs[id].Notification, false, nil + } + n.CreatedAt = m.now() + m.notifs[n.ID] = ¬ificationRow{Notification: n.Notification, schoolID: n.SchoolID} + m.byKey[key(n.SchoolID, n.IdempotencyKey)] = n.ID + for _, r := range n.Recipients { + m.recipients[n.ID] = append(m.recipients[n.ID], &recipientRow{Recipient: r, schoolID: n.SchoolID}) + m.deliveries = append(m.deliveries, &DeliveryRow{ + ID: NewID(), SchoolID: n.SchoolID, NotificationID: n.ID, UserID: r.UserID, + Channel: ChannelPush, Status: StatusQueued, + }) + } + return n.Notification, true, nil +} + +// ListInbox returns the user's notifications newest first, keyset-paginated. +func (m *MemoryStore) ListInbox(_ context.Context, schoolID, userID string, limit int, after *Cursor) ([]InboxItem, error) { + m.mu.Lock() + defer m.mu.Unlock() + var items []InboxItem + for id, rows := range m.recipients { + n := m.notifs[id] + if n.schoolID != schoolID { + continue + } + for _, r := range rows { + if r.schoolID != schoolID || r.UserID != userID { + continue + } + items = append(items, InboxItem{ID: id, Type: n.Type, Title: n.Title, Body: n.Body, Urgent: n.Urgent, + ChildIDs: append([]string{}, r.ChildIDs...), CreatedAt: n.CreatedAt, ReadAt: r.readAt}) + } + } + sort.Slice(items, func(i, j int) bool { + if !items[i].CreatedAt.Equal(items[j].CreatedAt) { + return items[i].CreatedAt.After(items[j].CreatedAt) + } + return items[i].ID > items[j].ID + }) + out := []InboxItem{} + for _, it := range items { + if after != nil && !isBefore(it, *after) { + continue + } + if len(out) == limit { + break + } + out = append(out, it) + } + return out, nil +} + +func isBefore(it InboxItem, c Cursor) bool { + return it.CreatedAt.Before(c.CreatedAt) || (it.CreatedAt.Equal(c.CreatedAt) && it.ID < c.ID) +} + +// MarkRead sets read_at once and keeps the first read time on repeats. +func (m *MemoryStore) MarkRead(_ context.Context, schoolID, userID, notificationID string) error { + m.mu.Lock() + defer m.mu.Unlock() + for _, r := range m.recipients[notificationID] { + if r.schoolID == schoolID && r.UserID == userID { + if r.readAt == nil { + t := m.now() + r.readAt = &t + } + return nil + } + } + return ErrNotFound +} + +// ListPreferences returns the user's preference rows sorted by type. +func (m *MemoryStore) ListPreferences(_ context.Context, schoolID, userID string) ([]Preference, error) { + m.mu.Lock() + defer m.mu.Unlock() + prefix := key(schoolID, userID) + "\x00" + out := []Preference{} + for k, p := range m.prefs { + if strings.HasPrefix(k, prefix) { + out = append(out, p) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].Type < out[j].Type }) + return out, nil +} + +// FindPreference prefers the exact type and falls back to the AnyType row. +func (m *MemoryStore) FindPreference(_ context.Context, schoolID, userID, notifType string) (*Preference, error) { + m.mu.Lock() + defer m.mu.Unlock() + for _, t := range []string{notifType, AnyType} { + if p, ok := m.prefs[key(schoolID, userID, t)]; ok { + return &p, nil + } + } + return nil, nil +} + +// SavePreferences upserts each given type. +func (m *MemoryStore) SavePreferences(_ context.Context, schoolID, userID string, prefs []Preference) error { + m.mu.Lock() + defer m.mu.Unlock() + for _, p := range prefs { + m.prefs[key(schoolID, userID, p.Type)] = p + } + return nil +} + +// DeliveryCounts counts deliveries per status for one notification. +func (m *MemoryStore) DeliveryCounts(_ context.Context, schoolID, notificationID string) (map[string]int, error) { + m.mu.Lock() + defer m.mu.Unlock() + if n, ok := m.notifs[notificationID]; !ok || n.schoolID != schoolID { + return nil, ErrNotFound + } + counts := map[string]int{} + for _, s := range AllStatuses { + counts[s] = 0 + } + for _, d := range m.deliveries { + if d.NotificationID == notificationID && d.SchoolID == schoolID { + counts[d.Status]++ + } + } + return counts, nil +} + +// ClaimQueued leases QUEUED deliveries whose claim is missing or older than lease. +func (m *MemoryStore) ClaimQueued(_ context.Context, limit int, lease time.Duration) ([]Job, error) { + m.mu.Lock() + defer m.mu.Unlock() + now := m.now() + var jobs []Job + for _, d := range m.deliveries { + if len(jobs) == limit { + break + } + if d.Status != StatusQueued || (!d.claimedAt.IsZero() && now.Sub(d.claimedAt) < lease) { + continue + } + d.claimedAt = now + n := m.notifs[d.NotificationID] + job := Job{DeliveryID: d.ID, SchoolID: d.SchoolID, NotificationID: d.NotificationID, UserID: d.UserID, + Attempt: d.Attempt, Type: n.Type, Title: n.Title, Body: n.Body, Urgent: n.Urgent} + for _, r := range m.recipients[d.NotificationID] { + if r.UserID == d.UserID { + job.ChildIDs, job.ChannelsAllowed, job.Email, job.Phone = r.ChildIDs, r.ChannelsAllowed, r.Email, r.Phone + } + } + jobs = append(jobs, job) + } + return jobs, nil +} + +func (m *MemoryStore) findDelivery(schoolID, id string) *DeliveryRow { + for _, d := range m.deliveries { + if d.ID == id && d.SchoolID == schoolID { + return d + } + } + return nil +} + +// ReleaseDelivery clears the claim so the next tick can pick the delivery up again. +func (m *MemoryStore) ReleaseDelivery(_ context.Context, schoolID, deliveryID string) error { + m.mu.Lock() + defer m.mu.Unlock() + d := m.findDelivery(schoolID, deliveryID) + if d == nil { + return ErrNotFound + } + d.claimedAt = time.Time{} + return nil +} + +// FinishDelivery records the result and the outbox row for UNDELIVERED. +func (m *MemoryStore) FinishDelivery(_ context.Context, schoolID, deliveryID string, res Result) error { + m.mu.Lock() + defer m.mu.Unlock() + d := m.findDelivery(schoolID, deliveryID) + if d == nil { + return ErrNotFound + } + d.Status, d.Attempt, d.ProviderRef, d.Error, d.claimedAt = res.Status, res.Attempt, res.ProviderRef, res.Error, time.Time{} + if res.Channel != "" { + d.Channel = res.Channel + } + if res.Status == StatusUndelivered { + payload, err := json.Marshal(map[string]string{"notificationId": d.NotificationID, "userId": d.UserID, "deliveryId": d.ID}) + if err != nil { + return err + } + m.outbox = append(m.outbox, OutboxRow{SchoolID: schoolID, Topic: OutboxTopicUndelivered, Payload: payload}) + } + return nil +} + +// RecordCallback is idempotent per (provider, event id) and ignores reports for unknown provider refs. +func (m *MemoryStore) RecordCallback(_ context.Context, ev CallbackEvent) (CallbackOutcome, error) { + m.mu.Lock() + defer m.mu.Unlock() + var target *DeliveryRow + for _, d := range m.deliveries { + if d.ProviderRef != "" && d.ProviderRef == ev.ProviderRef { + target = d + } + } + if target == nil { + return CallbackOutcome{}, nil + } + k := key(ev.Provider, ev.EventID) + if m.callbacks[k] { + return CallbackOutcome{Matched: true, Duplicate: true}, nil + } + m.callbacks[k] = true + if canApplyCallback(target.Status) { + target.Status = ev.Status + } + return CallbackOutcome{Matched: true}, nil +} + +// Deliveries returns copies of a notification's deliveries for tests. +func (m *MemoryStore) Deliveries(schoolID, notificationID string) []DeliveryRow { + m.mu.Lock() + defer m.mu.Unlock() + var out []DeliveryRow + for _, d := range m.deliveries { + if d.SchoolID == schoolID && d.NotificationID == notificationID { + out = append(out, *d) + } + } + return out +} + +// Outbox returns copies of the outbox rows for tests. +func (m *MemoryStore) Outbox() []OutboxRow { + m.mu.Lock() + defer m.mu.Unlock() + return append([]OutboxRow{}, m.outbox...) +} diff --git a/services/communication/internal/notifications/notifications_test.go b/services/communication/internal/notifications/notifications_test.go new file mode 100644 index 0000000..92fcf42 --- /dev/null +++ b/services/communication/internal/notifications/notifications_test.go @@ -0,0 +1,267 @@ +package notifications + +import ( + "context" + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" + kit "github.com/stackable/kyfaru-kit" +) + +const ( + testSecret = "test-secret" + schoolA = "11111111-1111-4111-8111-111111111111" + schoolB = "22222222-2222-4222-8222-222222222222" + parent1 = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" + parent2 = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" + child1 = "cccccccc-cccc-4ccc-8ccc-cccccccccccc" + child2 = "dddddddd-dddd-4ddd-8ddd-dddddddddddd" +) + +func newServer(t *testing.T, store Store, verifier SignatureVerifier) http.Handler { + t.Helper() + tokens, err := kit.NewServiceTokenVerifier(testSecret, "stackable-gateway", "communication") + if err != nil { + t.Fatal(err) + } + r := kit.NewRouter(kit.Config{Name: "communication", Logger: slog.New(slog.NewJSONHandler(io.Discard, nil))}) + NewHandler(NewService(store), verifier).Routes(r, tokens) + return r +} + +func token(t *testing.T, school, user string) string { + t.Helper() + tok, err := kit.SignServiceToken(testSecret, kit.ServiceClaims{SchoolID: school, RegisteredClaims: jwt.RegisteredClaims{ + Issuer: "stackable-gateway", Audience: jwt.ClaimStrings{"communication"}, Subject: user, + ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Minute)), + }}) + if err != nil { + t.Fatal(err) + } + return tok +} + +func call(h http.Handler, method, path, tok, body string) *httptest.ResponseRecorder { + req := httptest.NewRequest(method, path, strings.NewReader(body)) + if tok != "" { + req.Header.Set("Authorization", "Bearer "+tok) + } + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + return rec +} + +func decode(t *testing.T, rec *httptest.ResponseRecorder) map[string]any { + t.Helper() + var out map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { + t.Fatalf("bad json %q: %v", rec.Body.String(), err) + } + return out +} + +func createBody(key string, recipients string) string { + return `{"type":"fees","title":"Fee due","body":"Please pay","urgent":false,"idempotencyKey":"` + key + `","recipients":` + recipients + `}` +} + +func TestCreateIsIdempotentPerKey(t *testing.T) { + store := NewMemoryStore() + h := newServer(t, store, FakeVerifier{}) + tok := token(t, schoolA, parent1) + body := createBody("k1", `[{"userId":"`+parent1+`","childIds":["`+child1+`"]}]`) + + first := call(h, "POST", "/v1/notifications", tok, body) + second := call(h, "POST", "/v1/notifications", tok, body) + + if first.Code != 201 || second.Code != 200 { + t.Fatalf("statuses %d %d", first.Code, second.Code) + } + if decode(t, first)["id"] != decode(t, second)["id"] { + t.Fatal("repeat must return the original notification") + } + id := decode(t, first)["id"].(string) + if n := len(store.Deliveries(schoolA, id)); n != 1 { + t.Fatalf("repeat created extra deliveries: %d", n) + } +} + +func TestSameKeyInAnotherSchoolIsSeparate(t *testing.T) { + h := newServer(t, NewMemoryStore(), FakeVerifier{}) + body := createBody("k1", `[{"userId":"`+parent1+`"}]`) + a := call(h, "POST", "/v1/notifications", token(t, schoolA, parent1), body) + b := call(h, "POST", "/v1/notifications", token(t, schoolB, parent1), body) + if a.Code != 201 || b.Code != 201 { + t.Fatalf("statuses %d %d", a.Code, b.Code) + } +} + +func TestDedupeOneMessagePerParentWithAllChildren(t *testing.T) { + store := NewMemoryStore() + h := newServer(t, store, FakeVerifier{}) + tok := token(t, schoolA, parent1) + rec := call(h, "POST", "/v1/notifications", tok, createBody("k1", `[ + {"userId":"`+parent1+`","childIds":["`+child1+`"],"channelsAllowed":["push"]}, + {"userId":"`+parent1+`","childIds":["`+child2+`","`+child1+`"],"channelsAllowed":["sms"]}, + {"userId":"`+parent2+`","childIds":["`+child1+`"]}]`)) + if rec.Code != 201 { + t.Fatalf("status %d: %s", rec.Code, rec.Body.String()) + } + id := decode(t, rec)["id"].(string) + if n := len(store.Deliveries(schoolA, id)); n != 2 { + t.Fatalf("want 2 deliveries (one per parent), got %d", n) + } + + inbox := decode(t, call(h, "GET", "/v1/inbox", tok, ""))["items"].([]any) + if len(inbox) != 1 { + t.Fatalf("parent1 inbox has %d items", len(inbox)) + } + kids := inbox[0].(map[string]any)["childIds"].([]any) + if len(kids) != 2 || kids[0] != child1 || kids[1] != child2 { + t.Fatalf("combined child ids wrong: %v", kids) + } +} + +func TestValidationListsFailedFields(t *testing.T) { + h := newServer(t, NewMemoryStore(), FakeVerifier{}) + rec := call(h, "POST", "/v1/notifications", token(t, schoolA, parent1), `{"type":"","recipients":[{"userId":"nope"}]}`) + if rec.Code != 400 { + t.Fatalf("status %d", rec.Code) + } + details := decode(t, rec)["details"].(map[string]any) + for _, f := range []string{"type", "title", "body", "idempotencyKey", "recipients[0].userId"} { + if _, ok := details[f]; !ok { + t.Errorf("missing failed field %s in %v", f, details) + } + } +} + +func TestServiceTokenRequired(t *testing.T) { + h := newServer(t, NewMemoryStore(), FakeVerifier{}) + if rec := call(h, "GET", "/v1/inbox", "", ""); rec.Code != 401 { + t.Fatalf("status %d", rec.Code) + } +} + +func TestInboxTenantIsolation(t *testing.T) { + h := newServer(t, NewMemoryStore(), FakeVerifier{}) + rec := call(h, "POST", "/v1/notifications", token(t, schoolA, parent1), createBody("k1", `[{"userId":"`+parent1+`"}]`)) + id := decode(t, rec)["id"].(string) + + // same user id, other school: must see nothing and must not be able to read or count it + other := token(t, schoolB, parent1) + if items := decode(t, call(h, "GET", "/v1/inbox", other, ""))["items"].([]any); len(items) != 0 { + t.Fatalf("school B sees school A inbox: %v", items) + } + if rec := call(h, "POST", "/v1/inbox/"+id+"/read", other, ""); rec.Code != 404 { + t.Fatalf("cross-school read status %d", rec.Code) + } + if rec := call(h, "GET", "/v1/notifications/"+id+"/deliveries", other, ""); rec.Code != 404 { + t.Fatalf("cross-school deliveries status %d", rec.Code) + } +} + +func TestInboxPaginationAndRead(t *testing.T) { + clock := time.Date(2026, 1, 1, 8, 0, 0, 0, time.UTC) + store := NewMemoryStoreWithClock(func() time.Time { clock = clock.Add(time.Minute); return clock }) + h := newServer(t, store, FakeVerifier{}) + tok := token(t, schoolA, parent1) + for _, k := range []string{"a", "b", "c"} { + call(h, "POST", "/v1/notifications", tok, createBody(k, `[{"userId":"`+parent1+`"}]`)) + } + + page1 := decode(t, call(h, "GET", "/v1/inbox?limit=2", tok, "")) + if len(page1["items"].([]any)) != 2 || page1["nextCursor"] == nil { + t.Fatalf("page1 %v", page1) + } + page2 := decode(t, call(h, "GET", "/v1/inbox?limit=2&cursor="+page1["nextCursor"].(string), tok, "")) + if len(page2["items"].([]any)) != 1 || page2["nextCursor"] != nil { + t.Fatalf("page2 %v", page2) + } + + id := page1["items"].([]any)[0].(map[string]any)["id"].(string) + if rec := call(h, "POST", "/v1/inbox/"+id+"/read", tok, ""); rec.Code != 200 { + t.Fatalf("read status %d", rec.Code) + } + first := decode(t, call(h, "GET", "/v1/inbox?limit=1", tok, ""))["items"].([]any)[0].(map[string]any) + if first["readAt"] == nil { + t.Fatal("readAt not set") + } + if rec := call(h, "GET", "/v1/inbox?cursor=garbage", tok, ""); rec.Code != 400 { + t.Fatalf("bad cursor status %d", rec.Code) + } +} + +func TestPreferencesRoundTripAndValidation(t *testing.T) { + h := newServer(t, NewMemoryStore(), FakeVerifier{}) + tok := token(t, schoolA, parent1) + put := call(h, "PUT", "/v1/preferences", tok, `{"preferences":[{"type":"*","channels":["email","push"],"quietStart":"22:00","quietEnd":"06:00"}]}`) + if put.Code != 200 { + t.Fatalf("status %d: %s", put.Code, put.Body.String()) + } + prefs := decode(t, call(h, "GET", "/v1/preferences", tok, ""))["preferences"].([]any) + if len(prefs) != 1 || prefs[0].(map[string]any)["channels"].([]any)[0] != "push" { + t.Fatalf("prefs %v", prefs) + } + bad := call(h, "PUT", "/v1/preferences", tok, `{"preferences":[{"type":"x","channels":["fax"],"quietStart":"25:00"}]}`) + if bad.Code != 400 { + t.Fatalf("bad prefs status %d", bad.Code) + } + other := decode(t, call(h, "GET", "/v1/preferences", token(t, schoolB, parent1), ""))["preferences"].([]any) + if len(other) != 0 { + t.Fatal("preferences leaked across schools") + } +} + +func TestDeliveryCountsIncludeEveryStatus(t *testing.T) { + h := newServer(t, NewMemoryStore(), FakeVerifier{}) + tok := token(t, schoolA, parent1) + id := decode(t, call(h, "POST", "/v1/notifications", tok, createBody("k", `[{"userId":"`+parent1+`"},{"userId":"`+parent2+`"}]`)))["id"].(string) + counts := decode(t, call(h, "GET", "/v1/notifications/"+id+"/deliveries", tok, ""))["counts"].(map[string]any) + if counts["QUEUED"] != float64(2) || counts["SENT"] != float64(0) || len(counts) != 5 { + t.Fatalf("counts %v", counts) + } +} + +func TestCallbackIsIdempotentAndUpdatesDelivery(t *testing.T) { + store := NewMemoryStore() + h := newServer(t, store, FakeVerifier{}) + tok := token(t, schoolA, parent1) + id := decode(t, call(h, "POST", "/v1/notifications", tok, createBody("k", `[{"userId":"`+parent1+`"}]`)))["id"].(string) + d := store.Deliveries(schoolA, id)[0] + if err := store.FinishDelivery(context.Background(), schoolA, d.ID, Result{Status: StatusSent, Channel: ChannelEmail, Attempt: 1, ProviderRef: "ref-1"}); err != nil { + t.Fatal(err) + } + + body := `{"id":"evt-1","providerRef":"ref-1","status":"DELIVERED"}` + first := decode(t, call(h, "POST", "/v1/callbacks/fake", "", body)) + second := decode(t, call(h, "POST", "/v1/callbacks/fake", "", body)) + if first["duplicate"] != false || second["duplicate"] != true || first["matched"] != true { + t.Fatalf("flags %v %v", first, second) + } + counts, _ := store.DeliveryCounts(context.Background(), schoolA, id) + if counts[StatusDelivered] != 1 { + t.Fatalf("counts %v", counts) + } + unknown := decode(t, call(h, "POST", "/v1/callbacks/fake", "", `{"id":"e2","providerRef":"nope","status":"DELIVERED"}`)) + if unknown["matched"] != false { + t.Fatalf("unknown ref should not match: %v", unknown) + } +} + +func TestCallbackRejectedSignature(t *testing.T) { + h := newServer(t, NewMemoryStore(), FakeVerifier{Reject: true}) + if rec := call(h, "POST", "/v1/callbacks/fake", "", `{"id":"e","providerRef":"r","status":"DELIVERED"}`); rec.Code != 401 { + t.Fatalf("status %d", rec.Code) + } + ok := newServer(t, NewMemoryStore(), FakeVerifier{}) + if rec := call(ok, "POST", "/v1/callbacks/unknown", "", `{}`); rec.Code != 404 { + t.Fatalf("unknown provider status %d", rec.Code) + } +} diff --git a/services/communication/internal/notifications/pg_store.go b/services/communication/internal/notifications/pg_store.go new file mode 100644 index 0000000..62a9652 --- /dev/null +++ b/services/communication/internal/notifications/pg_store.go @@ -0,0 +1,372 @@ +package notifications + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + kit "github.com/stackable/kyfaru-kit" +) + +// Not run against a real database yet. Ids are cast to and from text so pgx never guesses a uuid codec. +const ( + insertNotificationSQL = ` +insert into communication.notifications (id, school_id, type, title, body, urgent, idempotency_key) +values ($1::uuid, $2::uuid, $3, $4, $5, $6, $7) +on conflict (school_id, idempotency_key) do nothing +returning created_at` + + selectByKeySQL = ` +select id::text, type, title, body, urgent, sender_identity_id::text, idempotency_key, created_at +from communication.notifications where school_id = $1::uuid and idempotency_key = $2` + + insertRecipientSQL = ` +insert into communication.notification_recipients (school_id, notification_id, user_id, child_ids, channels_allowed, email, phone) +values ($1::uuid, $2::uuid, $3::uuid, $4::text[]::uuid[], $5::text[], nullif($6, ''), nullif($7, ''))` + + insertDeliverySQL = ` +insert into communication.notification_deliveries (school_id, notification_id, user_id, channel, status) +values ($1::uuid, $2::uuid, $3::uuid, 'push', 'QUEUED')` + + listInboxSQL = ` +select n.id::text, n.type, n.title, n.body, n.urgent, n.created_at, r.child_ids::text[], r.read_at +from communication.notification_recipients r +join communication.notifications n on n.id = r.notification_id +where r.school_id = $1::uuid and r.user_id = $2::uuid + and ($3::timestamptz is null or (n.created_at, n.id::text) < ($3::timestamptz, $4::text)) +order by n.created_at desc, n.id desc +limit $5` + + markReadSQL = ` +update communication.notification_recipients +set read_at = coalesce(read_at, now()), updated_at = now() +where school_id = $1::uuid and notification_id = $2::uuid and user_id = $3::uuid` + + listPrefsSQL = ` +select type, channels, quiet_start::text, quiet_end::text +from communication.notification_preferences +where school_id = $1::uuid and user_id = $2::uuid order by type limit 200` + + findPrefSQL = ` +select type, channels, quiet_start::text, quiet_end::text +from communication.notification_preferences +where school_id = $1::uuid and user_id = $2::uuid and type in ($3, '*') +order by (type = '*') limit 1` + + upsertPrefSQL = ` +insert into communication.notification_preferences (school_id, user_id, type, channels, quiet_start, quiet_end) +values ($1::uuid, $2::uuid, $3, $4::text[], $5::time, $6::time) +on conflict (school_id, user_id, type) +do update set channels = excluded.channels, quiet_start = excluded.quiet_start, quiet_end = excluded.quiet_end, updated_at = now()` + + notificationExistsSQL = `select 1 from communication.notifications where id = $1::uuid and school_id = $2::uuid` + + countsSQL = ` +select status, count(*) from communication.notification_deliveries +where notification_id = $1::uuid and school_id = $2::uuid group by status` + + claimSQL = ` +with claimed as ( + update communication.notification_deliveries d + set claimed_at = now(), updated_at = now() + where d.id in ( + select id from communication.notification_deliveries + where status = 'QUEUED' and (claimed_at is null or claimed_at < now() - make_interval(secs => $2)) + order by created_at limit $1 for update skip locked) + returning d.id, d.school_id, d.notification_id, d.user_id, d.attempt) +select c.id::text, c.school_id::text, c.notification_id::text, c.user_id::text, c.attempt, + n.type, n.title, n.body, n.urgent, r.child_ids::text[], r.channels_allowed, coalesce(r.email, ''), coalesce(r.phone, '') +from claimed c +join communication.notifications n on n.id = c.notification_id +join communication.notification_recipients r on r.notification_id = c.notification_id and r.user_id = c.user_id` + + releaseSQL = ` +update communication.notification_deliveries set claimed_at = null, updated_at = now() +where id = $1::uuid and school_id = $2::uuid` + + finishSQL = ` +update communication.notification_deliveries +set status = $3, channel = coalesce(nullif($4, ''), channel), attempt = $5, + provider_ref = nullif($6, ''), error = nullif($7, ''), claimed_at = null, updated_at = now() +where id = $1::uuid and school_id = $2::uuid +returning notification_id::text, user_id::text` + + outboxSQL = ` +insert into communication.outbox (school_id, topic, payload) values ($1::uuid, $2, $3::jsonb)` + + findByRefSQL = ` +select id::text, school_id::text from communication.notification_deliveries where provider_ref = $1 limit 1` + + insertCallbackSQL = ` +insert into communication.delivery_callbacks (school_id, delivery_id, provider, provider_event_id, status, payload) +values ($1::uuid, $2::uuid, $3, $4, $5, $6::jsonb) +on conflict (provider, provider_event_id) do nothing` + + applyCallbackSQL = ` +update communication.notification_deliveries set status = $2, updated_at = now() +where id = $1::uuid and status in ('SENT', 'FAILED')` +) + +// PgStore is the Postgres-backed Store. +type PgStore struct { + pool *pgxpool.Pool +} + +// NewPgStore wraps a pool. +func NewPgStore(pool *pgxpool.Pool) *PgStore { return &PgStore{pool: pool} } + +// CreateNotification inserts the notification, recipients and deliveries in one school-scoped transaction. +func (s *PgStore) CreateNotification(ctx context.Context, n NewNotification) (Notification, bool, error) { + out := n.Notification + created := false + err := kit.WithSchool(ctx, s.pool, n.SchoolID, func(ctx context.Context, tx pgx.Tx) error { + err := tx.QueryRow(ctx, insertNotificationSQL, n.ID, n.SchoolID, n.Type, n.Title, n.Body, n.Urgent, n.IdempotencyKey).Scan(&out.CreatedAt) + if errors.Is(err, pgx.ErrNoRows) { + return tx.QueryRow(ctx, selectByKeySQL, n.SchoolID, n.IdempotencyKey).Scan( + &out.ID, &out.Type, &out.Title, &out.Body, &out.Urgent, &out.SenderIdentityID, &out.IdempotencyKey, &out.CreatedAt) + } + if err != nil { + return fmt.Errorf("insert notification: %w", err) + } + created = true + batch := &pgx.Batch{} + for _, r := range n.Recipients { + batch.Queue(insertRecipientSQL, n.SchoolID, n.ID, r.UserID, r.ChildIDs, r.ChannelsAllowed, r.Email, r.Phone) + batch.Queue(insertDeliverySQL, n.SchoolID, n.ID, r.UserID) + } + br := tx.SendBatch(ctx, batch) + for range n.Recipients { + for range 2 { + if _, err := br.Exec(); err != nil { + _ = br.Close() + return fmt.Errorf("insert recipient: %w", err) + } + } + } + return br.Close() + }) + return out, created, err +} + +// ListInbox pages with a (created_at, id) keyset so deep pages stay fast. +func (s *PgStore) ListInbox(ctx context.Context, schoolID, userID string, limit int, after *Cursor) ([]InboxItem, error) { + var afterTime *time.Time + afterID := "" + if after != nil { + afterTime, afterID = &after.CreatedAt, after.ID + } + items := []InboxItem{} + err := kit.WithSchool(ctx, s.pool, schoolID, func(ctx context.Context, tx pgx.Tx) error { + rows, err := tx.Query(ctx, listInboxSQL, schoolID, userID, afterTime, afterID, limit) + if err != nil { + return fmt.Errorf("query inbox: %w", err) + } + defer rows.Close() + for rows.Next() { + var it InboxItem + if err := rows.Scan(&it.ID, &it.Type, &it.Title, &it.Body, &it.Urgent, &it.CreatedAt, &it.ChildIDs, &it.ReadAt); err != nil { + return fmt.Errorf("scan inbox: %w", err) + } + items = append(items, it) + } + return rows.Err() + }) + return items, err +} + +// MarkRead keeps the first read time on repeats. +func (s *PgStore) MarkRead(ctx context.Context, schoolID, userID, notificationID string) error { + return kit.WithSchool(ctx, s.pool, schoolID, func(ctx context.Context, tx pgx.Tx) error { + tag, err := tx.Exec(ctx, markReadSQL, schoolID, notificationID, userID) + if err != nil { + return fmt.Errorf("mark read: %w", err) + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil + }) +} + +func scanPreference(row pgx.Row) (*Preference, error) { + var p Preference + if err := row.Scan(&p.Type, &p.Channels, &p.QuietStart, &p.QuietEnd); err != nil { + return nil, err + } + trimClock(p.QuietStart) + trimClock(p.QuietEnd) + return &p, nil +} + +// trimClock turns Postgres "22:00:00" into "22:00". +func trimClock(t *string) { + if t != nil && len(*t) > 5 { + *t = (*t)[:5] + } +} + +// ListPreferences returns every preference row of the user. +func (s *PgStore) ListPreferences(ctx context.Context, schoolID, userID string) ([]Preference, error) { + out := []Preference{} + err := kit.WithSchool(ctx, s.pool, schoolID, func(ctx context.Context, tx pgx.Tx) error { + rows, err := tx.Query(ctx, listPrefsSQL, schoolID, userID) + if err != nil { + return fmt.Errorf("query preferences: %w", err) + } + defer rows.Close() + for rows.Next() { + p, err := scanPreference(rows) + if err != nil { + return fmt.Errorf("scan preference: %w", err) + } + out = append(out, *p) + } + return rows.Err() + }) + return out, err +} + +// FindPreference returns the exact-type row, else the AnyType row, else nil. +func (s *PgStore) FindPreference(ctx context.Context, schoolID, userID, notifType string) (*Preference, error) { + var found *Preference + err := kit.WithSchool(ctx, s.pool, schoolID, func(ctx context.Context, tx pgx.Tx) error { + p, err := scanPreference(tx.QueryRow(ctx, findPrefSQL, schoolID, userID, notifType)) + if errors.Is(err, pgx.ErrNoRows) { + return nil + } + found = p + return err + }) + return found, err +} + +// SavePreferences upserts each type in one transaction. +func (s *PgStore) SavePreferences(ctx context.Context, schoolID, userID string, prefs []Preference) error { + return kit.WithSchool(ctx, s.pool, schoolID, func(ctx context.Context, tx pgx.Tx) error { + for _, p := range prefs { + if _, err := tx.Exec(ctx, upsertPrefSQL, schoolID, userID, p.Type, p.Channels, p.QuietStart, p.QuietEnd); err != nil { + return fmt.Errorf("upsert preference: %w", err) + } + } + return nil + }) +} + +// DeliveryCounts returns a count for every status, zero when absent. +func (s *PgStore) DeliveryCounts(ctx context.Context, schoolID, notificationID string) (map[string]int, error) { + counts := map[string]int{} + for _, st := range AllStatuses { + counts[st] = 0 + } + err := kit.WithSchool(ctx, s.pool, schoolID, func(ctx context.Context, tx pgx.Tx) error { + var one int + if err := tx.QueryRow(ctx, notificationExistsSQL, notificationID, schoolID).Scan(&one); errors.Is(err, pgx.ErrNoRows) { + return ErrNotFound + } else if err != nil { + return fmt.Errorf("check notification: %w", err) + } + rows, err := tx.Query(ctx, countsSQL, notificationID, schoolID) + if err != nil { + return fmt.Errorf("count deliveries: %w", err) + } + defer rows.Close() + for rows.Next() { + var status string + var n int64 + if err := rows.Scan(&status, &n); err != nil { + return fmt.Errorf("scan counts: %w", err) + } + counts[status] = int(n) + } + return rows.Err() + }) + return counts, err +} + +// ClaimQueued runs across all schools, so the service role must own the tables or bypass RLS. +func (s *PgStore) ClaimQueued(ctx context.Context, limit int, lease time.Duration) ([]Job, error) { + rows, err := s.pool.Query(ctx, claimSQL, limit, lease.Seconds()) + if err != nil { + return nil, fmt.Errorf("claim deliveries: %w", err) + } + defer rows.Close() + var jobs []Job + for rows.Next() { + var j Job + if err := rows.Scan(&j.DeliveryID, &j.SchoolID, &j.NotificationID, &j.UserID, &j.Attempt, + &j.Type, &j.Title, &j.Body, &j.Urgent, &j.ChildIDs, &j.ChannelsAllowed, &j.Email, &j.Phone); err != nil { + return nil, fmt.Errorf("scan claim: %w", err) + } + jobs = append(jobs, j) + } + return jobs, rows.Err() +} + +// ReleaseDelivery clears the claim. +func (s *PgStore) ReleaseDelivery(ctx context.Context, schoolID, deliveryID string) error { + return kit.WithSchool(ctx, s.pool, schoolID, func(ctx context.Context, tx pgx.Tx) error { + if _, err := tx.Exec(ctx, releaseSQL, deliveryID, schoolID); err != nil { + return fmt.Errorf("release delivery: %w", err) + } + return nil + }) +} + +// FinishDelivery updates the delivery and writes the outbox row in the same transaction. +func (s *PgStore) FinishDelivery(ctx context.Context, schoolID, deliveryID string, res Result) error { + return kit.WithSchool(ctx, s.pool, schoolID, func(ctx context.Context, tx pgx.Tx) error { + var notificationID, userID string + err := tx.QueryRow(ctx, finishSQL, deliveryID, schoolID, res.Status, res.Channel, res.Attempt, res.ProviderRef, res.Error).Scan(¬ificationID, &userID) + if errors.Is(err, pgx.ErrNoRows) { + return ErrNotFound + } + if err != nil { + return fmt.Errorf("finish delivery: %w", err) + } + if res.Status != StatusUndelivered { + return nil + } + payload, err := json.Marshal(map[string]string{"notificationId": notificationID, "userId": userID, "deliveryId": deliveryID}) + if err != nil { + return fmt.Errorf("marshal outbox payload: %w", err) + } + if _, err := tx.Exec(ctx, outboxSQL, schoolID, OutboxTopicUndelivered, string(payload)); err != nil { + return fmt.Errorf("insert outbox: %w", err) + } + return nil + }) +} + +// RecordCallback relies on the unique (provider, provider_event_id) key, so concurrent repeats store one row. +func (s *PgStore) RecordCallback(ctx context.Context, ev CallbackEvent) (CallbackOutcome, error) { + var out CallbackOutcome + tx, err := s.pool.Begin(ctx) + if err != nil { + return out, fmt.Errorf("begin tx: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() // no-op after commit + + var deliveryID, schoolID string + if err := tx.QueryRow(ctx, findByRefSQL, ev.ProviderRef).Scan(&deliveryID, &schoolID); errors.Is(err, pgx.ErrNoRows) { + return out, nil + } else if err != nil { + return out, fmt.Errorf("find delivery: %w", err) + } + out.Matched = true + tag, err := tx.Exec(ctx, insertCallbackSQL, schoolID, deliveryID, ev.Provider, ev.EventID, ev.Status, string(ev.Payload)) + if err != nil { + return out, fmt.Errorf("insert callback: %w", err) + } + if tag.RowsAffected() == 0 { + out.Duplicate = true + return out, nil + } + if _, err := tx.Exec(ctx, applyCallbackSQL, deliveryID, ev.Status); err != nil { + return out, fmt.Errorf("apply callback: %w", err) + } + return out, tx.Commit(ctx) +} diff --git a/services/communication/internal/notifications/service.go b/services/communication/internal/notifications/service.go new file mode 100644 index 0000000..8d243fc --- /dev/null +++ b/services/communication/internal/notifications/service.go @@ -0,0 +1,347 @@ +package notifications + +import ( + "context" + "crypto/rand" + "encoding/base64" + "errors" + "fmt" + "regexp" + "strings" + "time" + + kit "github.com/stackable/kyfaru-kit" +) + +const ( + maxRecipients = 1000 // one request cannot fan out to more than a large school + maxTitleLen = 200 + maxBodyLen = 5000 + maxTypeLen = 64 + maxKeyLen = 128 + defaultLimit = 20 + maxInboxLimit = 100 + maxChildIDs = 20 + maxPrefsPerPut = 50 +) + +var ( + uuidPattern = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`) + clockPattern = regexp.MustCompile(`^([01]\d|2[0-3]):[0-5]\d$`) +) + +// NewID returns a random UUID v4 string. +func NewID() string { + b := make([]byte, 16) + _, _ = rand.Read(b) // crypto/rand does not fail on supported platforms + b[6] = b[6]&0x0f | 0x40 + b[8] = b[8]&0x3f | 0x80 + return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]) +} + +// RecipientInput is one already-resolved recipient from the caller. +type RecipientInput struct { + UserID string `json:"userId"` + ChildIDs []string `json:"childIds"` + ChannelsAllowed []string `json:"channelsAllowed"` + Email string `json:"email"` + Phone string `json:"phone"` +} + +// CreateInput is the body of POST /v1/notifications. +type CreateInput struct { + Type string `json:"type"` + Title string `json:"title"` + Body string `json:"body"` + Recipients []RecipientInput `json:"recipients"` + Urgent bool `json:"urgent"` + IdempotencyKey string `json:"idempotencyKey"` +} + +// InboxPage is one page of a user's inbox. +type InboxPage struct { + Items []InboxItem `json:"items"` + NextCursor *string `json:"nextCursor"` +} + +// Service holds the notification rules. It knows nothing about HTTP. +type Service struct { + store Store +} + +// NewService builds a Service over a Store. +func NewService(store Store) *Service { return &Service{store: store} } + +func isValidChannel(c string) bool { + for _, known := range ChannelOrder { + if c == known { + return true + } + } + return false +} + +// orderedChannels returns the set in fallback order; an empty set means every channel. +func orderedChannels(set map[string]bool) []string { + out := []string{} + for _, c := range ChannelOrder { + if len(set) == 0 || set[c] { + out = append(out, c) + } + } + return out +} + +func addUnique(list []string, items ...string) []string { + seen := map[string]bool{} + for _, v := range list { + seen[v] = true + } + for _, v := range items { + if !seen[v] { + seen[v] = true + list = append(list, v) + } + } + return list +} + +// validateCreate checks the whole body and reports every failed field at once. +func validateCreate(in *CreateInput) error { + bad := map[string]string{} + in.Type, in.Title, in.IdempotencyKey = strings.TrimSpace(in.Type), strings.TrimSpace(in.Title), strings.TrimSpace(in.IdempotencyKey) + if in.Type == "" || len(in.Type) > maxTypeLen { + bad["type"] = "required, at most 64 characters" + } + if in.Title == "" || len(in.Title) > maxTitleLen { + bad["title"] = "required, at most 200 characters" + } + if in.Body == "" || len(in.Body) > maxBodyLen { + bad["body"] = "required, at most 5000 characters" + } + if in.IdempotencyKey == "" || len(in.IdempotencyKey) > maxKeyLen { + bad["idempotencyKey"] = "required, at most 128 characters" + } + if len(in.Recipients) == 0 || len(in.Recipients) > maxRecipients { + bad["recipients"] = "between 1 and 1000 recipients required" + } + for i := range in.Recipients { + r := &in.Recipients[i] + r.UserID = strings.ToLower(strings.TrimSpace(r.UserID)) + if !uuidPattern.MatchString(r.UserID) { + bad[fmt.Sprintf("recipients[%d].userId", i)] = "must be a uuid" + } + if len(r.ChildIDs) > maxChildIDs { + bad[fmt.Sprintf("recipients[%d].childIds", i)] = "at most 20 children" + } + for j := range r.ChildIDs { + r.ChildIDs[j] = strings.ToLower(strings.TrimSpace(r.ChildIDs[j])) + if !uuidPattern.MatchString(r.ChildIDs[j]) { + bad[fmt.Sprintf("recipients[%d].childIds[%d]", i, j)] = "must be a uuid" + } + } + for _, c := range r.ChannelsAllowed { + if !isValidChannel(c) { + bad[fmt.Sprintf("recipients[%d].channelsAllowed", i)] = "allowed values are push, sms, email" + } + } + } + if len(bad) > 0 { + return kit.Validation("Notification is invalid.", bad) + } + return nil +} + +// dedupeRecipients merges repeated parents into one entry that lists all their children. +// Channel sets are unioned so a parent allowed on push for one child and sms for another gets both. +func dedupeRecipients(in []RecipientInput) []Recipient { + var order []string + byUser := map[string]*Recipient{} + channels := map[string]map[string]bool{} + for _, r := range in { + cur, seen := byUser[r.UserID] + if !seen { + cur = &Recipient{UserID: r.UserID, ChildIDs: []string{}} + byUser[r.UserID] = cur + channels[r.UserID] = map[string]bool{} + order = append(order, r.UserID) + } + cur.ChildIDs = addUnique(cur.ChildIDs, r.ChildIDs...) + if cur.Email == "" { + cur.Email = strings.TrimSpace(r.Email) + } + if cur.Phone == "" { + cur.Phone = strings.TrimSpace(r.Phone) + } + // an entry with no channel list allows everything, so the union becomes "all" + if len(r.ChannelsAllowed) == 0 { + for _, c := range ChannelOrder { + channels[r.UserID][c] = true + } + } + for _, c := range r.ChannelsAllowed { + channels[r.UserID][c] = true + } + } + out := make([]Recipient, 0, len(order)) + for _, id := range order { + byUser[id].ChannelsAllowed = orderedChannels(channels[id]) + out = append(out, *byUser[id]) + } + return out +} + +// Create stores a notification and queues one delivery per distinct parent. +// A repeated idempotency key returns the original notification and created=false. +func (s *Service) Create(ctx context.Context, schoolID string, in CreateInput) (Notification, bool, error) { + if err := validateCreate(&in); err != nil { + return Notification{}, false, err + } + n, created, err := s.store.CreateNotification(ctx, NewNotification{ + Notification: Notification{ID: NewID(), Type: in.Type, Title: in.Title, Body: in.Body, Urgent: in.Urgent, IdempotencyKey: in.IdempotencyKey}, + SchoolID: schoolID, + Recipients: dedupeRecipients(in.Recipients), + }) + if err != nil { + return Notification{}, false, fmt.Errorf("create notification: %w", err) + } + return n, created, nil +} + +func encodeCursor(c Cursor) string { + return base64.RawURLEncoding.EncodeToString([]byte(c.CreatedAt.UTC().Format(time.RFC3339Nano) + "|" + c.ID)) +} + +func decodeCursor(raw string) (*Cursor, error) { + if raw == "" { + return nil, nil + } + b, err := base64.RawURLEncoding.DecodeString(raw) + if err != nil { + return nil, kit.Validation("Cursor is invalid.", nil) + } + ts, id, found := strings.Cut(string(b), "|") + t, perr := time.Parse(time.RFC3339Nano, ts) + if !found || perr != nil || !uuidPattern.MatchString(id) { + return nil, kit.Validation("Cursor is invalid.", nil) + } + return &Cursor{CreatedAt: t, ID: id}, nil +} + +// Inbox returns one page of the user's notifications; limit 0 means the default. +func (s *Service) Inbox(ctx context.Context, schoolID, userID string, limit int, rawCursor string) (InboxPage, error) { + if limit < 0 || limit > maxInboxLimit { + return InboxPage{}, kit.Validation("limit must be between 1 and 100.", map[string]string{"limit": "1-100"}) + } + if limit == 0 { + limit = defaultLimit + } + after, err := decodeCursor(rawCursor) + if err != nil { + return InboxPage{}, err + } + // one extra row tells us whether another page exists + items, err := s.store.ListInbox(ctx, schoolID, userID, limit+1, after) + if err != nil { + return InboxPage{}, fmt.Errorf("list inbox: %w", err) + } + page := InboxPage{Items: items} + if len(items) > limit { + page.Items = items[:limit] + last := page.Items[limit-1] + next := encodeCursor(Cursor{CreatedAt: last.CreatedAt, ID: last.ID}) + page.NextCursor = &next + } + return page, nil +} + +// MarkRead marks one notification read for the caller. +func (s *Service) MarkRead(ctx context.Context, schoolID, userID, notificationID string) error { + if !uuidPattern.MatchString(notificationID) { + return kit.Validation("Notification id must be a uuid.", nil) + } + if err := s.store.MarkRead(ctx, schoolID, userID, notificationID); err != nil { + return mapNotFound(err, "Notification not found.") + } + return nil +} + +// Preferences lists the caller's preference rows. +func (s *Service) Preferences(ctx context.Context, schoolID, userID string) ([]Preference, error) { + prefs, err := s.store.ListPreferences(ctx, schoolID, userID) + if err != nil { + return nil, fmt.Errorf("list preferences: %w", err) + } + return prefs, nil +} + +func validatePreferences(prefs []Preference) error { + bad := map[string]string{} + if len(prefs) == 0 || len(prefs) > maxPrefsPerPut { + bad["preferences"] = "between 1 and 50 rows required" + } + for i, p := range prefs { + field := fmt.Sprintf("preferences[%d]", i) + if p.Type == "" || len(p.Type) > maxTypeLen { + bad[field+".type"] = "required, at most 64 characters" + } + if len(p.Channels) == 0 { + bad[field+".channels"] = "at least one channel required" + } + for _, c := range p.Channels { + if !isValidChannel(c) { + bad[field+".channels"] = "allowed values are push, sms, email" + } + } + if (p.QuietStart == nil) != (p.QuietEnd == nil) { + bad[field+".quietStart"] = "quietStart and quietEnd must be set together" + } else if p.QuietStart != nil && (!clockPattern.MatchString(*p.QuietStart) || !clockPattern.MatchString(*p.QuietEnd)) { + bad[field+".quietStart"] = "times must be HH:MM" + } + } + if len(bad) > 0 { + return kit.Validation("Preferences are invalid.", bad) + } + return nil +} + +// SavePreferences upserts the given types and returns all of the caller's rows. +func (s *Service) SavePreferences(ctx context.Context, schoolID, userID string, prefs []Preference) ([]Preference, error) { + if err := validatePreferences(prefs); err != nil { + return nil, err + } + for i := range prefs { + prefs[i].Channels = orderedChannelsFrom(prefs[i].Channels) + } + if err := s.store.SavePreferences(ctx, schoolID, userID, prefs); err != nil { + return nil, fmt.Errorf("save preferences: %w", err) + } + return s.Preferences(ctx, schoolID, userID) +} + +func orderedChannelsFrom(list []string) []string { + set := map[string]bool{} + for _, c := range list { + set[c] = true + } + return orderedChannels(set) +} + +// DeliveryCounts returns how many deliveries of a notification sit in each status. +func (s *Service) DeliveryCounts(ctx context.Context, schoolID, notificationID string) (map[string]int, error) { + if !uuidPattern.MatchString(notificationID) { + return nil, kit.Validation("Notification id must be a uuid.", nil) + } + counts, err := s.store.DeliveryCounts(ctx, schoolID, notificationID) + if err != nil { + return nil, mapNotFound(err, "Notification not found.") + } + return counts, nil +} + +func mapNotFound(err error, message string) error { + if errors.Is(err, ErrNotFound) { + return kit.NotFound(message) + } + return err +} diff --git a/services/communication/internal/notifications/store.go b/services/communication/internal/notifications/store.go new file mode 100644 index 0000000..53bd4f8 --- /dev/null +++ b/services/communication/internal/notifications/store.go @@ -0,0 +1,32 @@ +package notifications + +import ( + "context" + "time" +) + +// Store persists notifications. Every method is scoped by school id except ClaimQueued and RecordCallback. +// Those two run for the whole service because the worker and provider callbacks carry no school token. +type Store interface { + // CreateNotification returns the stored row and false when the idempotency key already existed. + CreateNotification(ctx context.Context, n NewNotification) (Notification, bool, error) + // ListInbox returns up to limit items newest first, after the cursor when given. + ListInbox(ctx context.Context, schoolID, userID string, limit int, after *Cursor) ([]InboxItem, error) + // MarkRead is idempotent and returns ErrNotFound when the user is not a recipient. + MarkRead(ctx context.Context, schoolID, userID, notificationID string) error + ListPreferences(ctx context.Context, schoolID, userID string) ([]Preference, error) + // FindPreference returns the row for the type, else the AnyType row, else nil. + FindPreference(ctx context.Context, schoolID, userID, notifType string) (*Preference, error) + // SavePreferences upserts the given types and leaves other types untouched. + SavePreferences(ctx context.Context, schoolID, userID string, prefs []Preference) error + // DeliveryCounts returns a count for every status, or ErrNotFound. + DeliveryCounts(ctx context.Context, schoolID, notificationID string) (map[string]int, error) + // ClaimQueued leases up to limit QUEUED deliveries so two workers never send the same one. + ClaimQueued(ctx context.Context, limit int, lease time.Duration) ([]Job, error) + // ReleaseDelivery gives a claimed delivery back to the queue untouched. + ReleaseDelivery(ctx context.Context, schoolID, deliveryID string) error + // FinishDelivery writes the result and, for UNDELIVERED, the outbox row in the same transaction. + FinishDelivery(ctx context.Context, schoolID, deliveryID string, res Result) error + // RecordCallback stores a provider report once per (provider, event id) and updates the delivery it names. + RecordCallback(ctx context.Context, ev CallbackEvent) (CallbackOutcome, error) +} diff --git a/services/communication/internal/notifications/types.go b/services/communication/internal/notifications/types.go new file mode 100644 index 0000000..32c8020 --- /dev/null +++ b/services/communication/internal/notifications/types.go @@ -0,0 +1,138 @@ +package notifications + +import ( + "errors" + "time" +) + +// Delivery channels in fallback order. +const ( + ChannelPush = "push" + ChannelSMS = "sms" + ChannelEmail = "email" +) + +// Delivery statuses from the delivery state machine. +const ( + StatusQueued = "QUEUED" + StatusSent = "SENT" + StatusDelivered = "DELIVERED" + StatusFailed = "FAILED" + StatusUndelivered = "UNDELIVERED" +) + +// TypeSafety notifications ignore quiet hours because a child's safety cannot wait for morning. +const TypeSafety = "safety" + +// AnyType is the preference row that applies when no row exists for the notification's own type. +const AnyType = "*" + +// ChannelOrder is the fallback order tried for every delivery. +var ChannelOrder = []string{ChannelPush, ChannelSMS, ChannelEmail} + +// AllStatuses lists every delivery status so counts always include zeros. +var AllStatuses = []string{StatusQueued, StatusSent, StatusDelivered, StatusFailed, StatusUndelivered} + +// ErrNotFound is returned by a Store when the row does not exist for that school. +var ErrNotFound = errors.New("not found") + +// Notification is one message sent to many recipients. +type Notification struct { + ID string `json:"id"` + Type string `json:"type"` + Title string `json:"title"` + Body string `json:"body"` + Urgent bool `json:"urgent"` + SenderIdentityID *string `json:"senderIdentityId"` + IdempotencyKey string `json:"idempotencyKey"` + CreatedAt time.Time `json:"createdAt"` +} + +// Recipient is one parent after deduplication, with the children the message is about. +type Recipient struct { + UserID string + ChildIDs []string + ChannelsAllowed []string + Email string + Phone string +} + +// NewNotification is what a Store persists in one transaction: the notification, its recipients and one QUEUED delivery each. +type NewNotification struct { + Notification + SchoolID string + Recipients []Recipient +} + +// InboxItem is a notification as one recipient sees it. +type InboxItem struct { + ID string `json:"id"` + Type string `json:"type"` + Title string `json:"title"` + Body string `json:"body"` + Urgent bool `json:"urgent"` + ChildIDs []string `json:"childIds"` + CreatedAt time.Time `json:"createdAt"` + ReadAt *time.Time `json:"readAt"` +} + +// Cursor is the keyset position after the last inbox item of a page. +type Cursor struct { + CreatedAt time.Time + ID string +} + +// Preference is a user's channel choice and quiet hours for one notification type. +type Preference struct { + Type string `json:"type"` + Channels []string `json:"channels"` + QuietStart *string `json:"quietStart"` + QuietEnd *string `json:"quietEnd"` +} + +// Job is one claimed QUEUED delivery with everything the worker needs to send it. +type Job struct { + DeliveryID string + SchoolID string + NotificationID string + UserID string + Attempt int + Type string + Title string + Body string + Urgent bool + ChildIDs []string + ChannelsAllowed []string + Email string + Phone string +} + +// Result is the final state the worker writes for a delivery. +type Result struct { + Status string + Channel string + Attempt int + ProviderRef string + Error string +} + +// CallbackEvent is a provider delivery report normalised by ParseCallback. +type CallbackEvent struct { + Provider string + EventID string + ProviderRef string + Status string + Payload []byte +} + +// CallbackOutcome tells the caller what RecordCallback did. +type CallbackOutcome struct { + Matched bool + Duplicate bool +} + +// OutboxTopicUndelivered is published when every channel failed for a recipient. +const OutboxTopicUndelivered = "delivery.undelivered" + +// canApplyCallback reports whether a provider report may move a delivery from this status. +func canApplyCallback(from string) bool { return from == StatusSent || from == StatusFailed } diff --git a/services/communication/migrations/00001_create_communication.sql b/services/communication/migrations/00001_create_communication.sql new file mode 100644 index 0000000..aa1dd49 --- /dev/null +++ b/services/communication/migrations/00001_create_communication.sql @@ -0,0 +1,141 @@ +-- +goose Up +create schema if not exists communication; + +create table communication.sender_identities ( + id uuid primary key default gen_random_uuid(), + school_id uuid not null, + email_from text, + sms_sender_id text, + verified boolean not null default false, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); +create index sender_identities_school_idx on communication.sender_identities (school_id); + +create table communication.notifications ( + id uuid primary key default gen_random_uuid(), + school_id uuid not null, + type text not null, + title text not null, + body text not null, + urgent boolean not null default false, + sender_identity_id uuid references communication.sender_identities (id), + idempotency_key text not null, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + unique (school_id, idempotency_key) +); +create index notifications_sender_identity_idx on communication.notifications (sender_identity_id); + +create table communication.notification_recipients ( + id uuid primary key default gen_random_uuid(), + school_id uuid not null, + notification_id uuid not null references communication.notifications (id), + user_id uuid not null, + child_ids uuid[] not null default '{}', + channels_allowed text[] not null, + email text, + phone text, + read_at timestamptz, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + unique (notification_id, user_id) +); +create index notification_recipients_user_idx on communication.notification_recipients (school_id, user_id); + +create table communication.notification_deliveries ( + id uuid primary key default gen_random_uuid(), + school_id uuid not null, + notification_id uuid not null references communication.notifications (id), + user_id uuid not null, + channel text not null check (channel in ('push', 'sms', 'email')), + status text not null check (status in ('QUEUED', 'SENT', 'DELIVERED', 'FAILED', 'UNDELIVERED')), + attempt integer not null default 0, + provider_ref text, + error text, + claimed_at timestamptz, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); +create index notification_deliveries_notification_idx on communication.notification_deliveries (notification_id); +create index notification_deliveries_queue_idx on communication.notification_deliveries (created_at) where status = 'QUEUED'; +create index notification_deliveries_provider_ref_idx on communication.notification_deliveries (provider_ref) where provider_ref is not null; + +create table communication.delivery_callbacks ( + id uuid primary key default gen_random_uuid(), + school_id uuid not null, + delivery_id uuid not null references communication.notification_deliveries (id), + provider text not null, + provider_event_id text not null, + status text not null, + payload jsonb not null, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + unique (provider, provider_event_id) +); +create index delivery_callbacks_delivery_idx on communication.delivery_callbacks (delivery_id); + +create table communication.notification_preferences ( + id uuid primary key default gen_random_uuid(), + school_id uuid not null, + user_id uuid not null, + type text not null, + channels text[] not null, + quiet_start time, + quiet_end time, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + unique (school_id, user_id, type) +); + +create table communication.outbox ( + id uuid primary key default gen_random_uuid(), + school_id uuid not null, + topic text not null, + payload jsonb not null, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + published_at timestamptz +); +create index outbox_unpublished_idx on communication.outbox (created_at) where published_at is null; + +-- FORCE is off on purpose: the owner role (worker, callbacks) bypasses RLS, every request path goes through WithSchool. +alter table communication.sender_identities enable row level security; +alter table communication.notifications enable row level security; +alter table communication.notification_recipients enable row level security; +alter table communication.notification_deliveries enable row level security; +alter table communication.delivery_callbacks enable row level security; +alter table communication.notification_preferences enable row level security; +alter table communication.outbox enable row level security; + +create policy tenant_isolation on communication.sender_identities + using (school_id = current_setting('app.school_id', true)::uuid) + with check (school_id = current_setting('app.school_id', true)::uuid); +create policy tenant_isolation on communication.notifications + using (school_id = current_setting('app.school_id', true)::uuid) + with check (school_id = current_setting('app.school_id', true)::uuid); +create policy tenant_isolation on communication.notification_recipients + using (school_id = current_setting('app.school_id', true)::uuid) + with check (school_id = current_setting('app.school_id', true)::uuid); +create policy tenant_isolation on communication.notification_deliveries + using (school_id = current_setting('app.school_id', true)::uuid) + with check (school_id = current_setting('app.school_id', true)::uuid); +create policy tenant_isolation on communication.delivery_callbacks + using (school_id = current_setting('app.school_id', true)::uuid) + with check (school_id = current_setting('app.school_id', true)::uuid); +create policy tenant_isolation on communication.notification_preferences + using (school_id = current_setting('app.school_id', true)::uuid) + with check (school_id = current_setting('app.school_id', true)::uuid); +create policy tenant_isolation on communication.outbox + using (school_id = current_setting('app.school_id', true)::uuid) + with check (school_id = current_setting('app.school_id', true)::uuid); + +-- +goose Down +drop table communication.outbox; +drop table communication.notification_preferences; +drop table communication.delivery_callbacks; +drop table communication.notification_deliveries; +drop table communication.notification_recipients; +drop table communication.notifications; +drop table communication.sender_identities; +drop schema communication; diff --git a/services/documents/README.md b/services/documents/README.md new file mode 100644 index 0000000..0af4d64 --- /dev/null +++ b/services/documents/README.md @@ -0,0 +1,59 @@ +# documents + +Go service that renders receipts and reports to PDF or Excel, caches them for 3 days and hands out signed links. +It is the only renderer in the system. Callers fetch their own data and send it in the request. + +## Run + +``` +SERVICE_TOKEN_SECRET=dev-secret go run ./cmd/documents +``` + +Without `DATABASE_URL` it uses an in-memory store and `/ready` reports `degraded`. Without `R2_ENDPOINT` +files go to `DOCS_DIR` on local disk. + +## Environment + +| Variable | Default | Meaning | +|---|---|---| +| `PORT` | `4007` | HTTP port | +| `SERVICE_TOKEN_SECRET` | required | HS256 secret for service tokens (audience `documents`) | +| `SERVICE_TOKEN_ISSUER` | `gateway` | Expected token issuer | +| `DATABASE_URL` | unset | Postgres URL; required when `APP_ENV=production` | +| `APP_ENV` | unset | `production` refuses the in-memory store and an unset signing secret | +| `DOCS_DIR` | `./data/documents` | Local file store directory | +| `FILES_SIGNING_SECRET` | random in dev | HMAC key for local download links; required in production | +| `R2_ENDPOINT`, `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`, `R2_BUCKET_PRIVATE` | unset | Cloudflare R2; all four or none. When set it replaces the local store | + +## Endpoints + +Full contract: `packages/contracts/openapi/documents.yaml`. Errors use `{ error, code, details, requestId }`. + +- `POST /v1/documents` (service token). Body: `type`, `format`, `params`, `dataVersion`, `data`. The school comes from the token. + - `200 SERVED`: cache hit, `{id, status, url}`. + - `201 STORED_AND_CACHED`: rendered now (500 rows or fewer), `{id, status, url, renderMs}`. + - `202 QUEUED_JOB`: more than 500 rows; a worker renders it and writes a `document.ready` outbox row. +- `GET /v1/documents/{id}` (service token): status and a fresh signed url. +- `GET /v1/files/{key}?exp=&sig=` (local store only): the signature is the credential. +- `GET /health`, `GET /ready`. + +Cache key = sha256(type, canonical params, dataVersion, format, schoolId). Change `dataVersion` whenever the +source data changes; an old file is then never served. Cache rows expire after 3 days and an hourly purge +removes them. The purge never touches the stored file or the `documents` row. + +`data` shapes: `receipt` takes `{schoolName, receiptNo, payerName, issuedAt, lines:[{description, amountMinor}]}` +(total is summed from the lines). Every other type takes `{schoolName, title, columns:[{key,label,money}], rows:[{...}]}`; +money cells are integer minor units shown as `KES 1,234.56`. + +## Database + +`migrations/00001_create_documents.sql` (goose) creates schema `documents` with `documents`, `document_cache` +and `outbox`, all with RLS. The worker claims jobs and purges across schools through a second policy keyed on +`app.worker = 'on'`. The Postgres store and the migration are not tested against a real database yet; the +tests use the in-memory store. + +## Test + +``` +go vet ./... && go test ./... +``` diff --git a/services/documents/cmd/documents/main.go b/services/documents/cmd/documents/main.go new file mode 100644 index 0000000..a7a98b2 --- /dev/null +++ b/services/documents/cmd/documents/main.go @@ -0,0 +1,116 @@ +package main + +import ( + "context" + "crypto/rand" + "errors" + "log/slog" + "os" + "os/signal" + "syscall" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + "github.com/stackable/documents/internal/documents" + kit "github.com/stackable/kyfaru-kit" +) + +const ( + serviceName = "documents" + defaultPort = "4007" + defaultDir = "./data/documents" + workerPeriod = 2 * time.Second +) + +// version is set at build time with -ldflags "-X main.version=...". +var version = "dev" + +func main() { + logger := kit.NewLogger(serviceName) + if err := run(logger); err != nil { + logger.Error("fatal", "err", err) + os.Exit(1) + } +} + +func run(logger *slog.Logger) error { + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + store, checks, closeDB, err := buildStore(ctx) + if err != nil { + return err + } + defer closeDB() + objects, local, err := buildObjects() + if err != nil { + return err + } + issuer := os.Getenv("SERVICE_TOKEN_ISSUER") + if issuer == "" { + issuer = "stackable-gateway" + } + verifier, err := kit.NewServiceTokenVerifierFromEnv(issuer, serviceName) + if err != nil { + return err + } + + svc := documents.NewService(documents.Config{ + Store: store, Objects: objects, + Renderers: map[documents.Format]documents.Renderer{ + documents.FormatPDF: documents.PDFRenderer{}, documents.FormatXLSX: documents.XLSXRenderer{}, + }, + }) + go svc.RunWorker(ctx, workerPeriod, logger) + + router := kit.NewRouter(kit.Config{Name: serviceName, Version: version, Logger: logger, Checks: checks}) + documents.NewHandler(svc, verifier, local).Routes(router) + + port := os.Getenv("PORT") + if port == "" { + port = defaultPort + } + return kit.Serve(ctx, ":"+port, router, logger) +} + +// buildStore uses Postgres when DATABASE_URL is set and an in-memory store only outside production. +func buildStore(ctx context.Context) (documents.Store, []kit.Check, func(), error) { + url := os.Getenv("DATABASE_URL") + if url == "" { + if os.Getenv("APP_ENV") == "production" { + return nil, nil, nil, errors.New("DATABASE_URL is required in production") + } + unset := func(context.Context) error { return errors.New("DATABASE_URL not set") } + return documents.NewMemoryStore(), []kit.Check{{Name: "database", Required: false, Fn: unset}}, func() {}, nil + } + pool, err := pgxpool.New(ctx, url) + if err != nil { + return nil, nil, nil, err + } + return documents.NewPgStore(pool), []kit.Check{{Name: "database", Required: true, Fn: pool.Ping}}, pool.Close, nil +} + +// buildObjects prefers R2 when configured; otherwise files go to DOCS_DIR with HMAC-signed links. +func buildObjects() (documents.ObjectStore, *documents.LocalStore, error) { + r2, err := documents.NewS3StoreFromEnv() + if err != nil { + return nil, nil, err + } + if r2 != nil { + return r2, nil, nil + } + secret := []byte(os.Getenv("FILES_SIGNING_SECRET")) + if len(secret) == 0 { + if os.Getenv("APP_ENV") == "production" { + return nil, nil, errors.New("FILES_SIGNING_SECRET is required in production") + } + secret = make([]byte, 32) // random per process in dev: links die on restart, never a known key + _, _ = rand.Read(secret) + } + dir := os.Getenv("DOCS_DIR") + if dir == "" { + dir = defaultDir + } + local, err := documents.NewLocalStore(dir, secret, nil) + return local, local, err +} diff --git a/services/documents/go.mod b/services/documents/go.mod new file mode 100644 index 0000000..f9f2840 --- /dev/null +++ b/services/documents/go.mod @@ -0,0 +1,41 @@ +module github.com/stackable/documents + +go 1.25.0 + +require ( + github.com/aws/aws-sdk-go-v2 v1.47.1 + github.com/aws/aws-sdk-go-v2/credentials v1.20.6 + github.com/aws/aws-sdk-go-v2/service/s3 v1.114.0 + github.com/go-chi/chi/v5 v5.3.2 + github.com/go-pdf/fpdf v0.9.0 + github.com/golang-jwt/jwt/v5 v5.3.1 + github.com/jackc/pgx/v5 v5.11.0 + github.com/stackable/kyfaru-kit v0.0.0 + github.com/xuri/excelize/v2 v2.11.0 +) + +require ( + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.4 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.4 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.4 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.5 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.4 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.4 // indirect + github.com/aws/smithy-go v1.28.1 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/richardlehane/mscfb v1.0.7 // indirect + github.com/richardlehane/msoleps v1.0.6 // indirect + github.com/tiendc/go-deepcopy v1.7.2 // indirect + github.com/xuri/efp v0.0.1 // indirect + github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect + golang.org/x/crypto v0.53.0 // indirect + golang.org/x/net v0.56.0 // indirect + golang.org/x/sync v0.21.0 // indirect + golang.org/x/text v0.38.0 // indirect +) + +replace github.com/stackable/kyfaru-kit => ../../packages/kyfaru-kit diff --git a/services/documents/go.sum b/services/documents/go.sum new file mode 100644 index 0000000..cf8c5ec --- /dev/null +++ b/services/documents/go.sum @@ -0,0 +1,74 @@ +github.com/aws/aws-sdk-go-v2 v1.47.1 h1:uOIZnp4PK3ZhKI0dNrJrhTEsLxbpXHTAJlwoS1pvAtw= +github.com/aws/aws-sdk-go-v2 v1.47.1/go.mod h1:bttEH6JqnUL8LepvDVfdrds/fZ5bCIxzpe3abyUrhDU= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20 h1:GPRlPwz40I2B2VrBEASOA3Bi77NyeqejNLkifosX0rs= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20/go.mod h1:g7PNzKcsOKWb4fkSRBA7BZVAS6Y8IcxzN+nRohhQ1Q8= +github.com/aws/aws-sdk-go-v2/credentials v1.20.6 h1:NpAFXCU7NzXNkdGK3zQTtsRJ+3v9tZQV0xcdRw8uBdw= +github.com/aws/aws-sdk-go-v2/credentials v1.20.6/go.mod h1:mcZCoiPnyMvP8VMNbygNX5lLqSlkYJIMPODylQMurOk= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.4 h1:CLq4+8UHCI+ZZYl/EuJxXovaIVN2xeeT8JV+dsApQ5E= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.4/go.mod h1:Wv4q5sAM04xAMkoOedxLx2inVf6K5FdxYp+A61L+q/0= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.4 h1:dD4MR81I7YkpEBRk6UP9rocC2QnT3qVuXwzlYTtfGEs= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.4/go.mod h1:EcXV1kAFd5XwSkDHlj94gnF3q5CkJyYiIJfH8N0VmrE= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.4 h1:7Wo47d/xn/7KttCSBd8EGYeZ7ULRFRkUHr6vkZPBzVQ= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.4/go.mod h1:tDB2IVC1xC3vX8o+6uRlzhTxP3g1b77CZXFX/oD2FnQ= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19 h1:bAdDl/HkGCcGPoe25ToSHEw23VIxt6CT5fLcg111BKg= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19/go.mod h1:KaUzbLxv4CeSxh6ZCl9B4m7CuFenS8kUEaDs+f/DQr4= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.5 h1:/TYsZXdA8UTa+WCtCYSAJIr1vwl0+eho6TUgJGwFFO8= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.5/go.mod h1:qPqp1Uwd/BqdhPufv6oem9j5J7HNsgc2V22dUiDPn+s= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.4 h1:29SvnfGhXjTl8ONxFwbj2rs6lbhiFXD2CgFQmbT/bXY= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.4/go.mod h1:wm04I5DMuNVvZHFe/dHnUxincvNbbK7AiNBbYsQivek= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.4 h1:pPiWfgeNxqluKEph7hvU88kuGKBPOWzO+Dk9t2zqqNs= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.4/go.mod h1:YlwGoIUDG/3kBQbdNOVs/xKZ9J01G8e/6D1mRBj9uTk= +github.com/aws/aws-sdk-go-v2/service/s3 v1.114.0 h1:VMAdYqr4Jn/8ATs9BHC5riwrs0d6m1Z2ohFriSwZwm0= +github.com/aws/aws-sdk-go-v2/service/s3 v1.114.0/go.mod h1:9APRWGLFITKD+xzWSIyT9V7QV4bNlEuIieWlzXgGFlI= +github.com/aws/smithy-go v1.28.1 h1:R/nXH00c8qcfCzQVELtRw+eLQWtzv+VAIEFJ1/xxXlQ= +github.com/aws/smithy-go v1.28.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-chi/chi/v5 v5.3.2 h1:5YQkICvTCSZ25hoRsyJazN0scjzKGiu4VAUc7H1o1nY= +github.com/go-chi/chi/v5 v5.3.2/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= +github.com/go-pdf/fpdf v0.9.0 h1:PPvSaUuo1iMi9KkaAn90NuKi+P4gwMedWPHhj8YlJQw= +github.com/go-pdf/fpdf v0.9.0/go.mod h1:oO8N111TkmKb9D7VvWGLvLJlaZUQVPM+6V42pp3iV4Y= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg= +github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/richardlehane/mscfb v1.0.7 h1:oeoiM0WE79vHwE8RpIYYvIAc8ajTH2mb6UZm55/+EB0= +github.com/richardlehane/mscfb v1.0.7/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo= +github.com/richardlehane/msoleps v1.0.6 h1:9BvkpjvD+iUBalUY4esMwv6uBkfOip/Lzvd93jvR9gg= +github.com/richardlehane/msoleps v1.0.6/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tiendc/go-deepcopy v1.7.2 h1:Ut2yYR7W9tWjTQitganoIue4UGxZwCcJy3orjrrIj44= +github.com/tiendc/go-deepcopy v1.7.2/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ= +github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8= +github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI= +github.com/xuri/excelize/v2 v2.11.0 h1:HxaEFl6sRN2+8J5a8HaKq+0M4FsjBGMnWWtjOCPSG88= +github.com/xuri/excelize/v2 v2.11.0/go.mod h1:jxFLbzaIwGQ5ufFNvYfUOHqXhfPaNmP14KWfmNz2Uak= +github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE= +github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/image v0.38.0 h1:5l+q+Y9JDC7mBOMjo4/aPhMDcxEptsX+Tt3GgRQRPuE= +golang.org/x/image v0.38.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/services/documents/internal/documents/cachekey.go b/services/documents/internal/documents/cachekey.go new file mode 100644 index 0000000..0ae7508 --- /dev/null +++ b/services/documents/internal/documents/cachekey.go @@ -0,0 +1,41 @@ +package documents + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" +) + +// canonicalJSON re-encodes raw so equal objects give equal bytes (encoding/json sorts map keys). +func canonicalJSON(raw json.RawMessage) (string, error) { + if len(bytes.TrimSpace(raw)) == 0 { + return "{}", nil + } + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.UseNumber() // keeps big integers exact instead of rounding through float64 + var v any + if err := dec.Decode(&v); err != nil { + return "", err + } + out, err := json.Marshal(v) + if err != nil { + return "", err + } + return string(out), nil +} + +// CacheKey is sha256 over type, canonical params, dataVersion, format and schoolId. +// dataVersion is in the key so changed data can never be answered with an older file. +func CacheKey(schoolID string, t DocType, params json.RawMessage, dataVersion string, f Format) (string, error) { + canon, err := canonicalJSON(params) + if err != nil { + return "", fmt.Errorf("canonical params: %w", err) + } + h := sha256.New() + for _, part := range []string{string(t), canon, dataVersion, string(f), schoolID} { + fmt.Fprintf(h, "%d:%s|", len(part), part) // length prefix stops "ab"+"c" colliding with "a"+"bc" + } + return hex.EncodeToString(h.Sum(nil)), nil +} diff --git a/services/documents/internal/documents/content.go b/services/documents/internal/documents/content.go new file mode 100644 index 0000000..347e8a8 --- /dev/null +++ b/services/documents/internal/documents/content.go @@ -0,0 +1,181 @@ +package documents + +import ( + "bytes" + "encoding/json" + "fmt" + "strconv" + "strings" +) + +// Column is one table column; Money columns hold int64 minor units. +type Column struct { + Key string `json:"key"` + Label string `json:"label"` + Money bool `json:"money"` +} + +// TableData is the layout used by every type except receipt. +type TableData struct { + SchoolName string `json:"schoolName"` + Title string `json:"title"` + Columns []Column `json:"columns"` + Rows []map[string]any `json:"rows"` +} + +// ReceiptLine is one charged item on a receipt. +type ReceiptLine struct { + Description string `json:"description"` + AmountMinor int64 `json:"amountMinor"` +} + +// ReceiptData is the receipt layout; the total is always summed from the lines. +type ReceiptData struct { + SchoolName string `json:"schoolName"` + ReceiptNo string `json:"receiptNo"` + PayerName string `json:"payerName"` + IssuedAt string `json:"issuedAt"` + Lines []ReceiptLine `json:"lines"` +} + +// Total sums the line amounts. +func (r ReceiptData) Total() int64 { + var sum int64 + for _, l := range r.Lines { + sum += l.AmountMinor + } + return sum +} + +// Content is validated render input: exactly one of Receipt or Table is set. +type Content struct { + Receipt *ReceiptData + Table *TableData +} + +// SchoolName returns the header text. +func (c Content) SchoolName() string { + if c.Receipt != nil { + return c.Receipt.SchoolName + } + return c.Table.SchoolName +} + +// RowCount estimates the render size and decides sync versus queued. +func (c Content) RowCount() int { + if c.Receipt != nil { + return len(c.Receipt.Lines) + } + return len(c.Table.Rows) +} + +// AsTable converts a receipt into a two-column table so xlsx has one code path. +func (c Content) AsTable() TableData { + if c.Table != nil { + return *c.Table + } + r := c.Receipt + t := TableData{ + SchoolName: r.SchoolName, + Title: "Receipt " + r.ReceiptNo, + Columns: []Column{{Key: "description", Label: "Description"}, {Key: "amount", Label: "Amount", Money: true}}, + } + for _, l := range r.Lines { + t.Rows = append(t.Rows, map[string]any{"description": l.Description, "amount": json.Number(strconv.FormatInt(l.AmountMinor, 10))}) + } + t.Rows = append(t.Rows, map[string]any{"description": "Total", "amount": json.Number(strconv.FormatInt(r.Total(), 10))}) + return t +} + +// ParseContent decodes and validates the caller's data for a document type. +func ParseContent(t DocType, raw json.RawMessage) (Content, error) { + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.UseNumber() // table cells keep exact integers, so money never passes through float64 + if t == TypeReceipt { + var r ReceiptData + if err := dec.Decode(&r); err != nil { + return Content{}, invalid("Receipt data is malformed.", fieldError{"data", "must match the receipt shape: " + err.Error()}) + } + if err := validateReceipt(r); err != nil { + return Content{}, err + } + return Content{Receipt: &r}, nil + } + var tb TableData + if err := dec.Decode(&tb); err != nil { + return Content{}, invalid("Table data is malformed.", fieldError{"data", "must match the table shape: " + err.Error()}) + } + if err := validateTable(tb); err != nil { + return Content{}, err + } + return Content{Table: &tb}, nil +} + +func validateReceipt(r ReceiptData) error { + var f []fieldError + if strings.TrimSpace(r.SchoolName) == "" { + f = append(f, fieldError{"data.schoolName", "is required"}) + } + if strings.TrimSpace(r.ReceiptNo) == "" { + f = append(f, fieldError{"data.receiptNo", "is required"}) + } + if len(r.Lines) == 0 { + f = append(f, fieldError{"data.lines", "needs at least one line"}) + } + if len(f) > 0 { + return invalid("Receipt data is invalid.", f...) + } + return nil +} + +func validateTable(t TableData) error { + var f []fieldError + if strings.TrimSpace(t.SchoolName) == "" { + f = append(f, fieldError{"data.schoolName", "is required"}) + } + if len(t.Columns) == 0 { + f = append(f, fieldError{"data.columns", "needs at least one column"}) + } + for i, c := range t.Columns { + if c.Key == "" || c.Label == "" { + f = append(f, fieldError{fmt.Sprintf("data.columns[%d]", i), "key and label are required"}) + } + } + if len(f) > 0 { + return invalid("Table data is invalid.", f...) + } + for i, row := range t.Rows { + for _, c := range t.Columns { + if !c.Money { + continue + } + if _, ok := moneyCell(row[c.Key]); !ok { + return invalid("Table data is invalid.", fieldError{fmt.Sprintf("data.rows[%d].%s", i, c.Key), "must be an integer amount in minor units"}) + } + } + } + return nil +} + +// moneyCell reads a minor-unit integer; anything else (float, string, missing) is rejected. +func moneyCell(v any) (int64, bool) { + n, ok := v.(json.Number) + if !ok { + return 0, false + } + i, err := n.Int64() + return i, err == nil +} + +func cellText(v any) string { + switch x := v.(type) { + case nil: + return "" + case string: + return x + case json.Number: + return x.String() + default: + return fmt.Sprint(x) + } +} diff --git a/services/documents/internal/documents/documents_test.go b/services/documents/internal/documents/documents_test.go new file mode 100644 index 0000000..7056047 --- /dev/null +++ b/services/documents/internal/documents/documents_test.go @@ -0,0 +1,379 @@ +package documents + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" + kit "github.com/stackable/kyfaru-kit" +) + +const ( + schoolA = "11111111-1111-4111-8111-111111111111" + schoolB = "22222222-2222-4222-8222-222222222222" + secret = "test-secret" +) + +type fakeClock struct { + mu sync.Mutex + t time.Time +} + +func (c *fakeClock) Now() time.Time { + c.mu.Lock() + defer c.mu.Unlock() + return c.t +} + +func (c *fakeClock) Advance(d time.Duration) { + c.mu.Lock() + defer c.mu.Unlock() + c.t = c.t.Add(d) +} + +type env struct { + svc *Service + store *MemoryStore + files *LocalStore + clock *fakeClock + router http.Handler +} + +func newEnv(t *testing.T) *env { + t.Helper() + clock := &fakeClock{t: time.Date(2026, 1, 1, 9, 0, 0, 0, time.UTC)} + files, err := NewLocalStore(t.TempDir(), []byte("files-secret"), clock.Now) + if err != nil { + t.Fatal(err) + } + store := NewMemoryStore() + svc := NewService(Config{ + Store: store, Objects: files, Clock: clock.Now, + Renderers: map[Format]Renderer{FormatPDF: PDFRenderer{}, FormatXLSX: XLSXRenderer{}}, + }) + verifier, _ := kit.NewServiceTokenVerifier(secret, "gateway", "documents") + r := kit.NewRouter(kit.Config{Name: "documents", Logger: slog.New(slog.NewJSONHandler(io.Discard, nil))}) + NewHandler(svc, verifier, files).Routes(r) + return &env{svc: svc, store: store, files: files, clock: clock, router: r} +} + +func receiptData(lines int) json.RawMessage { + var ls []string + for i := 0; i < lines; i++ { + ls = append(ls, fmt.Sprintf(`{"description":"Item %d","amountMinor":123456}`, i)) + } + return json.RawMessage(`{"schoolName":"Green Hill Academy","receiptNo":"R-1","payerName":"A Parent","issuedAt":"2026-01-01","lines":[` + strings.Join(ls, ",") + `]}`) +} + +func tableData(rows int) json.RawMessage { + var rs []string + for i := 0; i < rows; i++ { + rs = append(rs, fmt.Sprintf(`{"name":"Student %d","fee":50000}`, i)) + } + return json.RawMessage(`{"schoolName":"Green Hill Academy","title":"Class sheet","columns":[{"key":"name","label":"Name"},{"key":"fee","label":"Fee","money":true}],"rows":[` + strings.Join(rs, ",") + `]}`) +} + +func receiptReq(version string) CreateRequest { + return CreateRequest{Type: TypeReceipt, Format: FormatPDF, Params: json.RawMessage(`{"a":1,"b":2}`), DataVersion: version, Data: receiptData(2)} +} + +func mustCreate(t *testing.T, e *env, school string, req CreateRequest) Result { + t.Helper() + res, err := e.svc.Create(context.Background(), school, req) + if err != nil { + t.Fatalf("create: %v", err) + } + return res +} + +func TestCacheHitVsMiss(t *testing.T) { + e := newEnv(t) + first := mustCreate(t, e, schoolA, receiptReq("v1")) + second := mustCreate(t, e, schoolA, receiptReq("v1")) + if first.Status != StatusStoredAndCached || first.RenderMs == nil || first.URL == "" { + t.Fatalf("first: %+v", first) + } + if second.Status != StatusServed || second.ID != first.ID || second.URL == "" { + t.Fatalf("second: %+v", second) + } + if e.store.DocumentCount() != 1 { + t.Fatalf("documents = %d, want 1", e.store.DocumentCount()) + } +} + +func TestParamOrderDoesNotChangeCacheKey(t *testing.T) { + a, _ := CacheKey(schoolA, TypeGeneric, json.RawMessage(`{"a":1,"b":2}`), "v1", FormatPDF) + b, _ := CacheKey(schoolA, TypeGeneric, json.RawMessage(`{ "b": 2, "a": 1 }`), "v1", FormatPDF) + if a != b { + t.Fatal("canonical params should give one key") + } +} + +func TestChangedDataVersionMissesCache(t *testing.T) { + e := newEnv(t) + v1 := mustCreate(t, e, schoolA, receiptReq("v1")) + v2 := mustCreate(t, e, schoolA, receiptReq("v2")) + if v2.Status != StatusStoredAndCached || v2.ID == v1.ID { + t.Fatalf("v2 must be a fresh render, got %+v", v2) + } +} + +func TestExpiryPurgesCacheButKeepsDocumentAndFile(t *testing.T) { + e := newEnv(t) + first := mustCreate(t, e, schoolA, receiptReq("v1")) + e.clock.Advance(CacheTTL + time.Second) + + n, err := e.svc.PurgeExpired(context.Background()) + if err != nil || n != 1 { + t.Fatalf("purged %d, err %v", n, err) + } + if e.store.CacheCount() != 0 || e.store.DocumentCount() != 1 { + t.Fatalf("cache %d docs %d", e.store.CacheCount(), e.store.DocumentCount()) + } + doc, _ := e.store.GetDocument(context.Background(), schoolA, first.ID) + if ok, _ := e.files.Exists(context.Background(), doc.ObjectKey); !ok { + t.Fatal("stored file must survive the purge") + } + if again := mustCreate(t, e, schoolA, receiptReq("v1")); again.Status != StatusStoredAndCached { + t.Fatalf("after expiry want a fresh render, got %s", again.Status) + } +} + +func TestExpiredEntryIsNotServedBeforePurge(t *testing.T) { + e := newEnv(t) + mustCreate(t, e, schoolA, receiptReq("v1")) + e.clock.Advance(CacheTTL + time.Second) + if res := mustCreate(t, e, schoolA, receiptReq("v1")); res.Status == StatusServed { + t.Fatal("expired cache entry was served") + } +} + +func TestSmallRendersSyncLargeQueues(t *testing.T) { + e := newEnv(t) + small := mustCreate(t, e, schoolA, CreateRequest{Type: TypeClassSheet, Format: FormatXLSX, DataVersion: "v1", Data: tableData(SyncMaxRows)}) + if small.Status != StatusStoredAndCached { + t.Fatalf("500 rows: %s", small.Status) + } + req := CreateRequest{Type: TypeClassSheet, Format: FormatXLSX, DataVersion: "v2", Data: tableData(SyncMaxRows + 1)} + large := mustCreate(t, e, schoolA, req) + if large.Status != StatusQueuedJob || large.URL != "" { + t.Fatalf("501 rows: %+v", large) + } + view, _ := e.svc.Get(context.Background(), schoolA, large.ID) + if view.Status != StatusQueuedJob || view.URL != "" { + t.Fatalf("queued view: %+v", view) + } + + more, err := e.svc.ProcessNext(context.Background()) + if err != nil || !more { + t.Fatalf("process: %v %v", more, err) + } + view, _ = e.svc.Get(context.Background(), schoolA, large.ID) + if view.Status != StatusStoredAndCached || view.URL == "" { + t.Fatalf("done view: %+v", view) + } + out := e.store.Outbox() + if len(out) != 1 || out[0].Topic != "document.ready" || out[0].SchoolID != schoolA { + t.Fatalf("outbox: %+v", out) + } + if again := mustCreate(t, e, schoolA, req); again.Status != StatusServed || again.ID != large.ID { + t.Fatalf("finished job should be cached: %+v", again) + } + if more, _ := e.svc.ProcessNext(context.Background()); more { + t.Fatal("queue should be empty") + } +} + +func TestSignedURLExpiryAndTamper(t *testing.T) { + e := newEnv(t) + res := mustCreate(t, e, schoolA, receiptReq("v1")) + req := httptest.NewRequest(http.MethodGet, res.URL, nil) + rec := httptest.NewRecorder() + e.router.ServeHTTP(rec, req) + if rec.Code != 200 || !bytes.HasPrefix(rec.Body.Bytes(), []byte("%PDF")) { + t.Fatalf("download: %d", rec.Code) + } + + check := func(name, url, code string) { + t.Helper() + rec := httptest.NewRecorder() + e.router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, url, nil)) + var body struct{ Code string } + _ = json.Unmarshal(rec.Body.Bytes(), &body) + if rec.Code != 403 || body.Code != code { + t.Fatalf("%s: %d %s", name, rec.Code, rec.Body.String()) + } + } + check("tampered sig", strings.Replace(res.URL, "sig=", "sig=00", 1), "DOCUMENT_LINK_INVALID") + check("tampered expiry", strings.Replace(res.URL, "exp=", "exp=9", 1), "DOCUMENT_LINK_INVALID") + check("other school key", strings.Replace(res.URL, schoolA, schoolB, 1), "DOCUMENT_LINK_INVALID") + e.clock.Advance(URLTTL + time.Second) + check("expired", res.URL, "DOCUMENT_LINK_EXPIRED") +} + +func TestLocalStoreRejectsTraversalKeys(t *testing.T) { + e := newEnv(t) + for _, key := range []string{"../x", "a/../../x", "/etc/passwd", "a//b", ""} { + if _, err := e.files.SignedURL(key, time.Minute); err == nil { + t.Fatalf("key %q should be rejected", key) + } + } +} + +func TestTenantIsolation(t *testing.T) { + e := newEnv(t) + a := mustCreate(t, e, schoolA, receiptReq("v1")) + b := mustCreate(t, e, schoolB, receiptReq("v1")) + if b.Status != StatusStoredAndCached || b.ID == a.ID { + t.Fatalf("school B must not hit school A cache: %+v", b) + } + if _, err := e.svc.Get(context.Background(), schoolB, a.ID); err == nil { + t.Fatal("school B read school A document") + } + if _, err := e.svc.Get(context.Background(), schoolA, a.ID); err != nil { + t.Fatalf("owner read failed: %v", err) + } +} + +func TestRenderersProduceRealFiles(t *testing.T) { + ctx := context.Background() + rc, _ := ParseContent(TypeReceipt, receiptData(3)) + tc, _ := ParseContent(TypeGeneric, tableData(120)) // 120 rows forces a second PDF page + cases := []struct { + name string + r Renderer + c Content + magic string + }{ + {"pdf receipt", PDFRenderer{}, rc, "%PDF"}, + {"pdf table", PDFRenderer{}, tc, "%PDF"}, + {"xlsx receipt", XLSXRenderer{}, rc, "PK"}, + {"xlsx table", XLSXRenderer{}, tc, "PK"}, + } + for _, c := range cases { + out, err := c.r.Render(ctx, c.c) + if err != nil || len(out) == 0 || !bytes.HasPrefix(out, []byte(c.magic)) { + t.Fatalf("%s: err %v len %d", c.name, err, len(out)) + } + } +} + +func TestFormatKES(t *testing.T) { + cases := map[int64]string{123456: "KES 1,234.56", 0: "KES 0.00", 5: "KES 0.05", 100: "KES 1.00", 100000000: "KES 1,000,000.00", -500: "-KES 5.00"} + for in, want := range cases { + if got := FormatKES(in); got != want { + t.Errorf("FormatKES(%d) = %q, want %q", in, got, want) + } + } +} + +func TestReceiptPdfShowsFormattedAmounts(t *testing.T) { + c, _ := ParseContent(TypeReceipt, json.RawMessage(`{"schoolName":"S","receiptNo":"1","lines":[{"description":"Fee","amountMinor":123456}]}`)) + out, err := PDFRenderer{Uncompressed: true}.Render(context.Background(), c) + if err != nil || !bytes.Contains(out, []byte("KES 1,234.56")) { + t.Fatalf("receipt pdf lacks formatted amount, err %v", err) + } +} + +func TestOneReceiptRendersUnder500ms(t *testing.T) { + e := newEnv(t) + start := time.Now() + mustCreate(t, e, schoolA, receiptReq("fast")) + if d := time.Since(start); d > 500*time.Millisecond { + t.Fatalf("receipt took %s", d) + } +} + +func TestInvalidMoneyCellIsRejected(t *testing.T) { + data := json.RawMessage(`{"schoolName":"S","columns":[{"key":"fee","label":"Fee","money":true}],"rows":[{"fee":12.5}]}`) + _, err := ParseContent(TypeGeneric, data) + if err == nil { + t.Fatal("float money must be rejected") + } +} + +func token(t *testing.T, school string) string { + t.Helper() + tok, err := kit.SignServiceToken(secret, kit.ServiceClaims{SchoolID: school, RegisteredClaims: jwt.RegisteredClaims{ + Issuer: "gateway", Audience: jwt.ClaimStrings{"documents"}, Subject: "user-1", ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Minute)), + }}) + if err != nil { + t.Fatal(err) + } + return tok +} + +func call(e *env, method, path, tok, body string) *httptest.ResponseRecorder { + req := httptest.NewRequest(method, path, strings.NewReader(body)) + if tok != "" { + req.Header.Set("Authorization", "Bearer "+tok) + } + rec := httptest.NewRecorder() + e.router.ServeHTTP(rec, req) + return rec +} + +func TestHTTPFlow(t *testing.T) { + e := newEnv(t) + tok := token(t, schoolA) + body := `{"type":"receipt","format":"pdf","dataVersion":"v1","params":{},"data":` + string(receiptData(1)) + `}` + + if rec := call(e, "POST", "/v1/documents", "", body); rec.Code != 401 { + t.Fatalf("no token: %d", rec.Code) + } + created := call(e, "POST", "/v1/documents", tok, body) + if created.Code != 201 { + t.Fatalf("create: %d %s", created.Code, created.Body.String()) + } + if served := call(e, "POST", "/v1/documents", tok, body); served.Code != 200 || !strings.Contains(served.Body.String(), "SERVED") { + t.Fatalf("served: %d %s", served.Code, served.Body.String()) + } + var res Result + _ = json.Unmarshal(created.Body.Bytes(), &res) + if got := call(e, "GET", "/v1/documents/"+res.ID, tok, ""); got.Code != 200 || !strings.Contains(got.Body.String(), "url") { + t.Fatalf("get: %d %s", got.Code, got.Body.String()) + } + if other := call(e, "GET", "/v1/documents/"+res.ID, token(t, schoolB), ""); other.Code != 404 { + t.Fatalf("other school: %d", other.Code) + } + if bad := call(e, "GET", "/v1/documents/not-a-uuid", tok, ""); bad.Code != 404 { + t.Fatalf("bad id: %d", bad.Code) + } +} + +func TestHTTPValidationUsesEnvelope(t *testing.T) { + e := newEnv(t) + rec := call(e, "POST", "/v1/documents", token(t, schoolA), `{"type":"nope","format":"doc","data":{}}`) + var body struct { + Error, Code, RequestID string + Details struct{ Fields []fieldError } + } + _ = json.Unmarshal(rec.Body.Bytes(), &body) + if rec.Code != 400 || body.Code != "VALIDATION_FAILED" || body.RequestID == "" || len(body.Details.Fields) < 3 { + t.Fatalf("%d %s", rec.Code, rec.Body.String()) + } +} + +func TestS3PresignedURL(t *testing.T) { + s, err := NewS3Store(S3Config{Endpoint: "https://acct.r2.cloudflarestorage.com", AccessKeyID: "id", SecretAccessKey: "sec", Bucket: "docs"}) + if err != nil { + t.Fatal(err) + } + url, err := s.SignedURL(schoolA+"/x.pdf", time.Minute) + if err != nil || !strings.Contains(url, "X-Amz-Signature") || !strings.Contains(url, "X-Amz-Expires=60") { + t.Fatalf("url %q err %v", url, err) + } + if _, err := NewS3Store(S3Config{Endpoint: "x"}); err == nil { + t.Fatal("partial config must fail") + } +} diff --git a/services/documents/internal/documents/errors.go b/services/documents/internal/documents/errors.go new file mode 100644 index 0000000..468f79c --- /dev/null +++ b/services/documents/internal/documents/errors.go @@ -0,0 +1,34 @@ +package documents + +import ( + "net/http" + + kit "github.com/stackable/kyfaru-kit" +) + +// docError builds an AppError with a DOMAIN_CONDITION code that kyfaru-kit has no constructor for. +func docError(status int, code, message string) *kit.AppError { + return &kit.AppError{Status: status, Code: code, Message: message} +} + +func notFoundErr() *kit.AppError { + return docError(http.StatusNotFound, "DOCUMENT_NOT_FOUND", "Document not found.") +} + +func linkExpiredErr() *kit.AppError { + return docError(http.StatusForbidden, "DOCUMENT_LINK_EXPIRED", "This download link has expired. Request a new one.") +} + +func linkInvalidErr() *kit.AppError { + return docError(http.StatusForbidden, "DOCUMENT_LINK_INVALID", "This download link is not valid.") +} + +// fieldError is one failed field in a validation response. +type fieldError struct { + Field string `json:"field"` + Message string `json:"message"` +} + +func invalid(message string, fields ...fieldError) *kit.AppError { + return kit.Validation(message, map[string]any{"fields": fields}) +} diff --git a/services/documents/internal/documents/handler.go b/services/documents/internal/documents/handler.go new file mode 100644 index 0000000..fd9e88a --- /dev/null +++ b/services/documents/internal/documents/handler.go @@ -0,0 +1,98 @@ +package documents + +import ( + "net/http" + + "github.com/go-chi/chi/v5" + kit "github.com/stackable/kyfaru-kit" +) + +// Handler exposes the document routes. +type Handler struct { + svc *Service + verifier *kit.ServiceTokenVerifier + files *LocalStore // nil when R2 serves downloads directly +} + +// NewHandler builds a Handler; files may be nil. +func NewHandler(svc *Service, verifier *kit.ServiceTokenVerifier, files *LocalStore) *Handler { + return &Handler{svc: svc, verifier: verifier, files: files} +} + +// Routes mounts the token-protected document routes and the signed-link file route. +func (h *Handler) Routes(r chi.Router) { + r.Group(func(r chi.Router) { + r.Use(kit.RequireServiceToken(h.verifier)) + r.Post("/v1/documents", kit.Handle(h.create)) + r.Get("/v1/documents/{id}", kit.Handle(h.get)) + }) + if h.files != nil { + r.Get("/v1/files/*", kit.Handle(h.file)) // the signature is the credential, so no bearer token + } +} + +func schoolFrom(r *http.Request) (string, error) { + claims, ok := kit.ClaimsFrom(r.Context()) + if !ok || !isUUID(claims.SchoolID) { + return "", kit.Unauthorized("Invalid service token.") + } + return claims.SchoolID, nil +} + +func (h *Handler) create(w http.ResponseWriter, r *http.Request) error { + school, err := schoolFrom(r) + if err != nil { + return err + } + var req CreateRequest + if err := kit.DecodeJSON(w, r, &req); err != nil { + return err + } + res, err := h.svc.Create(r.Context(), school, req) + if err != nil { + return err + } + status := http.StatusOK // SERVED + switch res.Status { + case StatusStoredAndCached: + status = http.StatusCreated + case StatusQueuedJob: + status = http.StatusAccepted + } + kit.WriteJSON(w, status, res) + return nil +} + +func (h *Handler) get(w http.ResponseWriter, r *http.Request) error { + school, err := schoolFrom(r) + if err != nil { + return err + } + id := chi.URLParam(r, "id") + if !isUUID(id) { + return notFoundErr() + } + v, err := h.svc.Get(r.Context(), school, id) + if err != nil { + return err + } + kit.WriteJSON(w, http.StatusOK, v) + return nil +} + +func (h *Handler) file(w http.ResponseWriter, r *http.Request) error { + key := chi.URLParam(r, "*") + data, err := h.files.Open(key, r.URL.Query().Get("exp"), r.URL.Query().Get("sig")) + if err != nil { + return err + } + format := FormatPDF + if len(key) > 5 && key[len(key)-5:] == ".xlsx" { + format = FormatXLSX + } + w.Header().Set("Content-Type", contentType(format)) + w.Header().Set("Content-Disposition", "attachment") + w.Header().Set("Cache-Control", "private, no-store") + _, _ = w.Write(data) // headers are sent, a failed write means the client left + return nil +} diff --git a/services/documents/internal/documents/id.go b/services/documents/internal/documents/id.go new file mode 100644 index 0000000..554072c --- /dev/null +++ b/services/documents/internal/documents/id.go @@ -0,0 +1,20 @@ +package documents + +import ( + "crypto/rand" + "fmt" + "regexp" +) + +var uuidPattern = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`) + +func isUUID(s string) bool { return uuidPattern.MatchString(s) } + +// newUUID returns a random v4 uuid; the database columns are uuid, so ids must be valid ones. +func newUUID() string { + b := make([]byte, 16) + _, _ = rand.Read(b) // crypto/rand does not fail on supported platforms + b[6] = b[6]&0x0f | 0x40 + b[8] = b[8]&0x3f | 0x80 + return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]) +} diff --git a/services/documents/internal/documents/money.go b/services/documents/internal/documents/money.go new file mode 100644 index 0000000..7203256 --- /dev/null +++ b/services/documents/internal/documents/money.go @@ -0,0 +1,33 @@ +package documents + +import ( + "strconv" + "strings" +) + +// FormatKES renders int64 minor units (cents) as "KES 1,234.56"; negatives become "-KES 5.00". +// Money stays an integer everywhere else so rounding can never change a receipt. +func FormatKES(minor int64) string { + neg := minor < 0 + abs := uint64(minor) + if neg { + abs = -abs // two's complement negate also handles MinInt64 + } + whole := strconv.FormatUint(abs/100, 10) + cents := strconv.FormatUint(abs%100, 10) + if len(cents) == 1 { + cents = "0" + cents + } + var b strings.Builder + for i, ch := range whole { + if i > 0 && (len(whole)-i)%3 == 0 { + b.WriteByte(',') + } + b.WriteRune(ch) + } + out := "KES " + b.String() + "." + cents + if neg { + return "-" + out + } + return out +} diff --git a/services/documents/internal/documents/objectstore.go b/services/documents/internal/documents/objectstore.go new file mode 100644 index 0000000..dd09410 --- /dev/null +++ b/services/documents/internal/documents/objectstore.go @@ -0,0 +1,133 @@ +package documents + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "time" +) + +// ObjectStore keeps rendered files and hands out time-limited download links. +type ObjectStore interface { + Put(ctx context.Context, key, contentType string, data []byte) error + Exists(ctx context.Context, key string) (bool, error) + SignedURL(key string, ttl time.Duration) (string, error) +} + +var keyPattern = regexp.MustCompile(`^[A-Za-z0-9._-]+(/[A-Za-z0-9._-]+)*$`) + +// validKey blocks absolute paths and ".." so a key can never leave the store directory. +func validKey(key string) bool { + if !keyPattern.MatchString(key) { + return false + } + for _, seg := range strings.Split(key, "/") { + if seg == ".." || seg == "." { + return false + } + } + return true +} + +// LocalStore writes files under a directory and signs links with an HMAC expiry. +type LocalStore struct { + dir string + secret []byte + clock func() time.Time +} + +// NewLocalStore refuses an empty secret so links can never be signed with a known key. +func NewLocalStore(dir string, secret []byte, clock func() time.Time) (*LocalStore, error) { + if dir == "" || len(secret) == 0 { + return nil, errors.New("local store needs a directory and a signing secret") + } + if clock == nil { + clock = time.Now + } + return &LocalStore{dir: dir, secret: secret, clock: clock}, nil +} + +func (s *LocalStore) path(key string) (string, error) { + if !validKey(key) { + return "", linkInvalidErr() + } + return filepath.Join(s.dir, filepath.FromSlash(key)), nil +} + +// Put writes through a temp file so a reader never sees a half-written document. +func (s *LocalStore) Put(_ context.Context, key, _ string, data []byte) error { + p, err := s.path(key) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(p), 0o750); err != nil { + return fmt.Errorf("create dir: %w", err) + } + tmp := p + ".tmp" + if err := os.WriteFile(tmp, data, 0o640); err != nil { + return fmt.Errorf("write file: %w", err) + } + if err := os.Rename(tmp, p); err != nil { + return fmt.Errorf("rename file: %w", err) + } + return nil +} + +// Exists reports whether the file is stored. +func (s *LocalStore) Exists(_ context.Context, key string) (bool, error) { + p, err := s.path(key) + if err != nil { + return false, err + } + _, statErr := os.Stat(p) + if errors.Is(statErr, os.ErrNotExist) { + return false, nil + } + return statErr == nil, statErr +} + +func (s *LocalStore) sign(key string, exp int64) string { + m := hmac.New(sha256.New, s.secret) + fmt.Fprintf(m, "%s\n%d", key, exp) + return hex.EncodeToString(m.Sum(nil)) +} + +// SignedURL returns a path with an expiry and signature that Open verifies. +func (s *LocalStore) SignedURL(key string, ttl time.Duration) (string, error) { + if !validKey(key) { + return "", linkInvalidErr() + } + exp := s.clock().Add(ttl).Unix() + return fmt.Sprintf("/v1/files/%s?exp=%d&sig=%s", key, exp, s.sign(key, exp)), nil +} + +// Open verifies the signature and expiry, then returns the file bytes. +func (s *LocalStore) Open(key, expStr, sig string) ([]byte, error) { + exp, err := strconv.ParseInt(expStr, 10, 64) + if err != nil || !validKey(key) { + return nil, linkInvalidErr() + } + if !hmac.Equal([]byte(sig), []byte(s.sign(key, exp))) { + return nil, linkInvalidErr() + } + if s.clock().Unix() > exp { // checked after the signature so only genuine links reveal expiry + return nil, linkExpiredErr() + } + p, _ := s.path(key) + data, err := os.ReadFile(p) + if errors.Is(err, os.ErrNotExist) { + return nil, notFoundErr() + } + if err != nil { + return nil, fmt.Errorf("read file: %w", err) + } + return data, nil +} diff --git a/services/documents/internal/documents/pdf.go b/services/documents/internal/documents/pdf.go new file mode 100644 index 0000000..2285766 --- /dev/null +++ b/services/documents/internal/documents/pdf.go @@ -0,0 +1,133 @@ +package documents + +import ( + "bytes" + "context" + "fmt" + + "github.com/go-pdf/fpdf" +) + +const ( + pageMargin = 15.0 // mm + pageBottom = 18.0 // mm kept free for the footer + contentWidth = 180.0 // A4 width minus both margins + tableRowHeight = 7.0 +) + +// PDFRenderer draws A4 documents with a school-name header on every page. +type PDFRenderer struct { + Uncompressed bool // tests turn compression off to look for text in the bytes +} + +// Render draws a receipt layout for receipts and a table layout for everything else. +func (r PDFRenderer) Render(_ context.Context, c Content) ([]byte, error) { + p := fpdf.New("P", "mm", "A4", "") + p.SetCompression(!r.Uncompressed) + p.SetMargins(pageMargin, pageMargin, pageMargin) + p.SetAutoPageBreak(true, pageBottom) + tr := p.UnicodeTranslatorFromDescriptor("") // core fonts are cp1252, so convert from UTF-8 + + school := c.SchoolName() + p.SetHeaderFunc(func() { + p.SetFont("Helvetica", "B", 14) + p.CellFormat(0, 8, tr(school), "", 1, "L", false, 0, "") + p.Line(pageMargin, p.GetY(), pageMargin+contentWidth, p.GetY()) + p.Ln(4) + }) + p.SetFooterFunc(func() { + p.SetY(-12) + p.SetFont("Helvetica", "", 8) + p.CellFormat(0, 6, fmt.Sprintf("Page %d of {nb}", p.PageNo()), "", 0, "C", false, 0, "") + }) + p.AliasNbPages("") + p.AddPage() + + if c.Receipt != nil { + drawReceipt(p, tr, *c.Receipt) + } else { + drawTable(p, tr, *c.Table) + } + + var buf bytes.Buffer + if err := p.Output(&buf); err != nil { + return nil, fmt.Errorf("pdf output: %w", err) + } + return buf.Bytes(), nil +} + +func drawReceipt(p *fpdf.Fpdf, tr func(string) string, r ReceiptData) { + p.SetFont("Helvetica", "B", 18) + p.CellFormat(0, 10, "RECEIPT", "", 1, "L", false, 0, "") + p.SetFont("Helvetica", "", 10) + for _, kv := range [][2]string{{"Receipt no", r.ReceiptNo}, {"Received from", r.PayerName}, {"Date", r.IssuedAt}} { + if kv[1] == "" { + continue + } + p.CellFormat(35, 6, kv[0], "", 0, "L", false, 0, "") + p.CellFormat(0, 6, tr(kv[1]), "", 1, "L", false, 0, "") + } + p.Ln(4) + + const descW, amountW = 120.0, 60.0 + p.SetFont("Helvetica", "B", 10) + p.SetFillColor(230, 230, 230) + p.CellFormat(descW, tableRowHeight, "Description", "1", 0, "L", true, 0, "") + p.CellFormat(amountW, tableRowHeight, "Amount", "1", 1, "R", true, 0, "") + p.SetFont("Helvetica", "", 10) + for _, l := range r.Lines { + p.CellFormat(descW, tableRowHeight, tr(fit(p, l.Description, descW)), "1", 0, "L", false, 0, "") + p.CellFormat(amountW, tableRowHeight, FormatKES(l.AmountMinor), "1", 1, "R", false, 0, "") + } + p.SetFont("Helvetica", "B", 11) + p.CellFormat(descW, tableRowHeight+1, "Total", "1", 0, "L", false, 0, "") + p.CellFormat(amountW, tableRowHeight+1, FormatKES(r.Total()), "1", 1, "R", false, 0, "") +} + +func drawTable(p *fpdf.Fpdf, tr func(string) string, t TableData) { + if t.Title != "" { + p.SetFont("Helvetica", "B", 13) + p.CellFormat(0, 9, tr(t.Title), "", 1, "L", false, 0, "") + } + colW := contentWidth / float64(len(t.Columns)) + _, pageH := p.GetPageSize() + + header := func() { + p.SetFont("Helvetica", "B", 9) + p.SetFillColor(230, 230, 230) + for _, c := range t.Columns { + p.CellFormat(colW, tableRowHeight, tr(fit(p, c.Label, colW)), "1", 0, "L", true, 0, "") + } + p.Ln(-1) + p.SetFont("Helvetica", "", 9) + } + header() + for _, row := range t.Rows { + if p.GetY()+tableRowHeight > pageH-pageBottom { + p.AddPage() + header() + } + for _, c := range t.Columns { + if c.Money { + amount, _ := moneyCell(row[c.Key]) // validated when parsed + p.CellFormat(colW, tableRowHeight, FormatKES(amount), "1", 0, "R", false, 0, "") + continue + } + p.CellFormat(colW, tableRowHeight, tr(fit(p, cellText(row[c.Key]), colW)), "1", 0, "L", false, 0, "") + } + p.Ln(-1) + } +} + +// fit trims text so it stays inside its cell instead of overlapping the next column. +func fit(p *fpdf.Fpdf, s string, w float64) string { + limit := w - 2 // cell padding + if p.GetStringWidth(s) <= limit { + return s + } + runes := []rune(s) + for len(runes) > 0 && p.GetStringWidth(string(runes)+"...") > limit { + runes = runes[:len(runes)-1] + } + return string(runes) + "..." +} diff --git a/services/documents/internal/documents/pg_store.go b/services/documents/internal/documents/pg_store.go new file mode 100644 index 0000000..382a14f --- /dev/null +++ b/services/documents/internal/documents/pg_store.go @@ -0,0 +1,186 @@ +package documents + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + kit "github.com/stackable/kyfaru-kit" +) + +const docCols = `id::text, school_id::text, type, format, params, data_version, object_key, status, render_ms, job_payload, created_at, updated_at` + +const ( + insertDocSQL = `insert into documents.documents +(id, school_id, type, format, params, data_version, object_key, status, render_ms, job_payload, created_at, updated_at) +values ($1::uuid, $2::uuid, $3, $4, $5, $6, nullif($7, ''), $8, $9, $10, $11, $12)` + + upsertCacheSQL = `insert into documents.document_cache (school_id, cache_key, document_id, expires_at) +values ($1::uuid, $2, $3::uuid, $4) +on conflict (school_id, cache_key) do update set document_id = excluded.document_id, expires_at = excluded.expires_at` + + updateDocSQL = `update documents.documents +set object_key = $2, status = $3, render_ms = $4, job_payload = null, updated_at = $5 where id = $1::uuid` +) + +// PgStore is the Postgres Store; every tenant query runs inside kit.WithSchool so RLS applies. +type PgStore struct { + pool *pgxpool.Pool +} + +// NewPgStore wraps a pool. +func NewPgStore(pool *pgxpool.Pool) *PgStore { return &PgStore{pool: pool} } + +func scanDoc(row pgx.Row) (Document, error) { + var ( + d Document + key *string + ms *int32 + params []byte + payload []byte + ) + err := row.Scan(&d.ID, &d.SchoolID, &d.Type, &d.Format, ¶ms, &d.DataVersion, &key, &d.Status, &ms, &payload, &d.CreatedAt, &d.UpdatedAt) + if err != nil { + return Document{}, err + } + d.Params, d.JobPayload = params, payload + if key != nil { + d.ObjectKey = *key + } + if ms != nil { + d.RenderMs = int64(*ms) + } + return d, nil +} + +func insertDoc(ctx context.Context, tx pgx.Tx, d Document) error { + _, err := tx.Exec(ctx, insertDocSQL, d.ID, d.SchoolID, string(d.Type), string(d.Format), []byte(d.Params), d.DataVersion, + d.ObjectKey, string(d.Status), int32(d.RenderMs), []byte(d.JobPayload), d.CreatedAt, d.UpdatedAt) + return err +} + +func upsertCache(ctx context.Context, tx pgx.Tx, c CacheEntry) error { + _, err := tx.Exec(ctx, upsertCacheSQL, c.SchoolID, c.CacheKey, c.DocumentID, c.ExpiresAt) + return err +} + +// CreateDocument inserts a queued document. +func (s *PgStore) CreateDocument(ctx context.Context, d Document) error { + return kit.WithSchool(ctx, s.pool, d.SchoolID, func(ctx context.Context, tx pgx.Tx) error { return insertDoc(ctx, tx, d) }) +} + +// CreateRendered inserts the document and its cache entry in one transaction. +func (s *PgStore) CreateRendered(ctx context.Context, d Document, c CacheEntry) error { + return kit.WithSchool(ctx, s.pool, d.SchoolID, func(ctx context.Context, tx pgx.Tx) error { + if err := insertDoc(ctx, tx, d); err != nil { + return err + } + return upsertCache(ctx, tx, c) + }) +} + +// GetDocument returns notFoundErr for unknown ids and other schools' rows (RLS hides them). +func (s *PgStore) GetDocument(ctx context.Context, schoolID, id string) (Document, error) { + var d Document + err := kit.WithSchool(ctx, s.pool, schoolID, func(ctx context.Context, tx pgx.Tx) error { + var err error + d, err = scanDoc(tx.QueryRow(ctx, `select `+docCols+` from documents.documents where id = $1::uuid`, id)) + return err + }) + if errors.Is(err, pgx.ErrNoRows) { + return Document{}, notFoundErr() + } + return d, err +} + +// FindCachedDocument joins the cache row to its document, ignoring expired rows. +func (s *PgStore) FindCachedDocument(ctx context.Context, schoolID, cacheKey string, now time.Time) (Document, bool, error) { + var d Document + found := true + err := kit.WithSchool(ctx, s.pool, schoolID, func(ctx context.Context, tx pgx.Tx) error { + var err error + d, err = scanDoc(tx.QueryRow(ctx, `select `+docCols+` from documents.documents +where id = (select document_id from documents.document_cache where school_id = $1::uuid and cache_key = $2 and expires_at > $3)`, + schoolID, cacheKey, now)) + if errors.Is(err, pgx.ErrNoRows) { + found = false + return nil + } + return err + }) + return d, found && err == nil, err +} + +func beginWorker(ctx context.Context, pool *pgxpool.Pool) (pgx.Tx, error) { + tx, err := pool.Begin(ctx) + if err != nil { + return nil, fmt.Errorf("begin tx: %w", err) + } + if _, err := tx.Exec(ctx, "select set_config('app.worker', 'on', true)"); err != nil { + _ = tx.Rollback(ctx) + return nil, fmt.Errorf("set app.worker: %w", err) + } + return tx, nil +} + +// ClaimQueued uses SKIP LOCKED so two workers never take the same job. +func (s *PgStore) ClaimQueued(ctx context.Context, now time.Time) (Document, bool, error) { + tx, err := beginWorker(ctx, s.pool) + if err != nil { + return Document{}, false, err + } + defer func() { _ = tx.Rollback(ctx) }() // no-op after commit + d, err := scanDoc(tx.QueryRow(ctx, `update documents.documents set status = 'RENDERING', updated_at = $1 +where id = (select id from documents.documents + where status = 'QUEUED_JOB' or (status = 'RENDERING' and updated_at < $1::timestamptz - make_interval(secs => $2)) + order by created_at limit 1 for update skip locked) +returning `+docCols, now, StaleClaimAfter.Seconds())) + if errors.Is(err, pgx.ErrNoRows) { + return Document{}, false, nil + } + if err != nil { + return Document{}, false, err + } + d.Status, d.UpdatedAt = StatusRendering, now + return d, true, tx.Commit(ctx) +} + +// CompleteJob updates the document, writes the cache row and the outbox row in one transaction. +func (s *PgStore) CompleteJob(ctx context.Context, d Document, c CacheEntry, ev OutboxEvent) error { + return kit.WithSchool(ctx, s.pool, d.SchoolID, func(ctx context.Context, tx pgx.Tx) error { + if _, err := tx.Exec(ctx, updateDocSQL, d.ID, d.ObjectKey, string(d.Status), int32(d.RenderMs), d.UpdatedAt); err != nil { + return err + } + if err := upsertCache(ctx, tx, c); err != nil { + return err + } + _, err := tx.Exec(ctx, `insert into documents.outbox (id, school_id, topic, payload, created_at) values ($1::uuid, $2::uuid, $3, $4, $5)`, + ev.ID, ev.SchoolID, ev.Topic, []byte(ev.Payload), ev.CreatedAt) + return err + }) +} + +// FailJob marks the job FAILED. +func (s *PgStore) FailJob(ctx context.Context, schoolID, id string, now time.Time) error { + return kit.WithSchool(ctx, s.pool, schoolID, func(ctx context.Context, tx pgx.Tx) error { + _, err := tx.Exec(ctx, `update documents.documents set status = 'FAILED', updated_at = $2 where id = $1::uuid`, id, now) + return err + }) +} + +// PurgeExpiredCache deletes cache rows only. +func (s *PgStore) PurgeExpiredCache(ctx context.Context, now time.Time) (int, error) { + tx, err := beginWorker(ctx, s.pool) + if err != nil { + return 0, err + } + defer func() { _ = tx.Rollback(ctx) }() // no-op after commit + tag, err := tx.Exec(ctx, `delete from documents.document_cache where expires_at <= $1`, now) + if err != nil { + return 0, err + } + return int(tag.RowsAffected()), tx.Commit(ctx) +} diff --git a/services/documents/internal/documents/renderer.go b/services/documents/internal/documents/renderer.go new file mode 100644 index 0000000..a776bfd --- /dev/null +++ b/services/documents/internal/documents/renderer.go @@ -0,0 +1,16 @@ +package documents + +import "context" + +// Renderer turns validated content into file bytes for one format. +type Renderer interface { + Render(ctx context.Context, c Content) ([]byte, error) +} + +// contentType maps a format to its MIME type for storage and downloads. +func contentType(f Format) string { + if f == FormatXLSX { + return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + } + return "application/pdf" +} diff --git a/services/documents/internal/documents/s3store.go b/services/documents/internal/documents/s3store.go new file mode 100644 index 0000000..0c220ec --- /dev/null +++ b/services/documents/internal/documents/s3store.go @@ -0,0 +1,89 @@ +package documents + +import ( + "bytes" + "context" + "errors" + "fmt" + "os" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/aws/aws-sdk-go-v2/service/s3/types" +) + +// S3Config holds the Cloudflare R2 (S3-compatible) settings. +type S3Config struct { + Endpoint, AccessKeyID, SecretAccessKey, Bucket string +} + +// S3Store keeps files in an S3-compatible bucket and signs download links with presigned GETs. +type S3Store struct { + client *s3.Client + presign *s3.PresignClient + bucket string +} + +// NewS3StoreFromEnv returns (nil, nil) when R2_ENDPOINT is unset, so the local store stays the default. +func NewS3StoreFromEnv() (*S3Store, error) { + cfg := S3Config{ + Endpoint: os.Getenv("R2_ENDPOINT"), + AccessKeyID: os.Getenv("R2_ACCESS_KEY_ID"), + SecretAccessKey: os.Getenv("R2_SECRET_ACCESS_KEY"), + Bucket: os.Getenv("R2_BUCKET_PRIVATE"), + } + if cfg.Endpoint == "" { + return nil, nil + } + return NewS3Store(cfg) +} + +// NewS3Store fails when any setting is missing, so a half-configured bucket is caught at startup. +func NewS3Store(cfg S3Config) (*S3Store, error) { + if cfg.Endpoint == "" || cfg.AccessKeyID == "" || cfg.SecretAccessKey == "" || cfg.Bucket == "" { + return nil, errors.New("R2_ENDPOINT, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY and R2_BUCKET_PRIVATE must all be set") + } + client := s3.New(s3.Options{ + Region: "auto", // R2 ignores the region but the signer needs one + BaseEndpoint: aws.String(cfg.Endpoint), + Credentials: credentials.NewStaticCredentialsProvider(cfg.AccessKeyID, cfg.SecretAccessKey, ""), + UsePathStyle: true, + }) + return &S3Store{client: client, presign: s3.NewPresignClient(client), bucket: cfg.Bucket}, nil +} + +// Put uploads the file. +func (s *S3Store) Put(ctx context.Context, key, contentType string, data []byte) error { + _, err := s.client.PutObject(ctx, &s3.PutObjectInput{ + Bucket: &s.bucket, Key: &key, Body: bytes.NewReader(data), ContentType: &contentType, + }) + if err != nil { + return fmt.Errorf("put object: %w", err) + } + return nil +} + +// Exists checks the object with a HEAD request. +func (s *S3Store) Exists(ctx context.Context, key string) (bool, error) { + _, err := s.client.HeadObject(ctx, &s3.HeadObjectInput{Bucket: &s.bucket, Key: &key}) + var nf *types.NotFound + if errors.As(err, &nf) { + return false, nil + } + if err != nil { + return false, fmt.Errorf("head object: %w", err) + } + return true, nil +} + +// SignedURL presigns a GET that stops working after ttl. +func (s *S3Store) SignedURL(key string, ttl time.Duration) (string, error) { + req, err := s.presign.PresignGetObject(context.Background(), + &s3.GetObjectInput{Bucket: &s.bucket, Key: &key}, s3.WithPresignExpires(ttl)) + if err != nil { + return "", fmt.Errorf("presign: %w", err) + } + return req.URL, nil +} diff --git a/services/documents/internal/documents/service.go b/services/documents/internal/documents/service.go new file mode 100644 index 0000000..660c7bf --- /dev/null +++ b/services/documents/internal/documents/service.go @@ -0,0 +1,179 @@ +package documents + +import ( + "context" + "encoding/json" + "fmt" + "time" +) + +const ( + CacheTTL = 72 * time.Hour // cache entries live 3 days, files and rows stay + URLTTL = 15 * time.Minute // download links are short-lived and re-signed on each GET + SyncMaxRows = 500 // above this a render could miss the 2 s target, so it is queued + maxVersion = 128 +) + +// Config wires a Service; zero values get the defaults above. +type Config struct { + Store Store + Objects ObjectStore + Renderers map[Format]Renderer + Clock func() time.Time + CacheTTL time.Duration + URLTTL time.Duration + SyncMaxRows int +} + +// Service creates, serves and renders documents. +type Service struct { + cfg Config +} + +// NewService fills defaults. +func NewService(cfg Config) *Service { + if cfg.Clock == nil { + cfg.Clock = time.Now + } + if cfg.CacheTTL == 0 { + cfg.CacheTTL = CacheTTL + } + if cfg.URLTTL == 0 { + cfg.URLTTL = URLTTL + } + if cfg.SyncMaxRows == 0 { + cfg.SyncMaxRows = SyncMaxRows + } + return &Service{cfg: cfg} +} + +func objectKey(d Document) string { + return fmt.Sprintf("%s/%s.%s", d.SchoolID, d.ID, d.Format) +} + +func validateRequest(req CreateRequest) error { + var f []fieldError + switch req.Type { + case TypeReceipt, TypeReportCard, TypeFeeStatement, TypeClassSheet, TypeGeneric: + default: + f = append(f, fieldError{"type", "must be receipt, report_card, fee_statement, class_sheet or generic"}) + } + if req.Format != FormatPDF && req.Format != FormatXLSX { + f = append(f, fieldError{"format", "must be pdf or xlsx"}) + } + if req.DataVersion == "" || len(req.DataVersion) > maxVersion { + f = append(f, fieldError{"dataVersion", "is required and at most 128 characters"}) + } + if len(req.Data) == 0 { + f = append(f, fieldError{"data", "is required"}) + } + if len(req.Params) > 0 { + var p map[string]any + if err := json.Unmarshal(req.Params, &p); err != nil || p == nil { + f = append(f, fieldError{"params", "must be an object"}) + } + } + if len(f) > 0 { + return invalid("Request is invalid.", f...) + } + return nil +} + +// Create returns a cached file, renders a small one now, or queues a large one. +// It exists so every module asks one service for files and repeated asks cost nothing. +func (s *Service) Create(ctx context.Context, schoolID string, req CreateRequest) (Result, error) { + if err := validateRequest(req); err != nil { + return Result{}, err + } + content, err := ParseContent(req.Type, req.Data) + if err != nil { + return Result{}, err + } + key, err := CacheKey(schoolID, req.Type, req.Params, req.DataVersion, req.Format) + if err != nil { + return Result{}, invalid("Request is invalid.", fieldError{"params", "must be valid JSON"}) + } + + now := s.cfg.Clock() + if hit, ok, err := s.cfg.Store.FindCachedDocument(ctx, schoolID, key, now); err != nil { + return Result{}, err + } else if ok { + url, err := s.cfg.Objects.SignedURL(hit.ObjectKey, s.cfg.URLTTL) + if err != nil { + return Result{}, err + } + return Result{ID: hit.ID, Status: StatusServed, URL: url}, nil + } + + params := req.Params + if len(params) == 0 { + params = json.RawMessage("{}") + } + doc := Document{ + ID: newUUID(), SchoolID: schoolID, Type: req.Type, Format: req.Format, + Params: params, DataVersion: req.DataVersion, CreatedAt: now, UpdatedAt: now, + } + if content.RowCount() > s.cfg.SyncMaxRows { + doc.Status, doc.JobPayload = StatusQueuedJob, req.Data + if err := s.cfg.Store.CreateDocument(ctx, doc); err != nil { + return Result{}, err + } + return Result{ID: doc.ID, Status: StatusQueuedJob}, nil + } + + if err := s.render(ctx, &doc, content); err != nil { + return Result{}, err + } + entry := CacheEntry{SchoolID: schoolID, CacheKey: key, DocumentID: doc.ID, ExpiresAt: now.Add(s.cfg.CacheTTL)} + if err := s.cfg.Store.CreateRendered(ctx, doc, entry); err != nil { + return Result{}, err + } + url, err := s.cfg.Objects.SignedURL(doc.ObjectKey, s.cfg.URLTTL) + if err != nil { + return Result{}, err + } + return Result{ID: doc.ID, Status: StatusStoredAndCached, URL: url, RenderMs: &doc.RenderMs}, nil +} + +// render fills ObjectKey, RenderMs and Status on d after storing the file. +func (s *Service) render(ctx context.Context, d *Document, c Content) error { + r, ok := s.cfg.Renderers[d.Format] + if !ok { + return invalid("Format is not supported.", fieldError{"format", "no renderer for " + string(d.Format)}) + } + start := time.Now() // wall clock on purpose: the injectable clock drives TTLs, not timings + data, err := r.Render(ctx, c) + if err != nil { + return fmt.Errorf("render %s: %w", d.Format, err) + } + d.RenderMs = time.Since(start).Milliseconds() + d.ObjectKey = objectKey(*d) + if err := s.cfg.Objects.Put(ctx, d.ObjectKey, contentType(d.Format), data); err != nil { + return fmt.Errorf("store file: %w", err) + } + d.Status, d.JobPayload = StatusStoredAndCached, nil + d.UpdatedAt = s.cfg.Clock() + return nil +} + +// Get returns a document's status and a freshly signed link when the file exists. +func (s *Service) Get(ctx context.Context, schoolID, id string) (View, error) { + d, err := s.cfg.Store.GetDocument(ctx, schoolID, id) + if err != nil { + return View{}, err + } + v := View{ID: d.ID, Status: d.Status, Type: d.Type, Format: d.Format, CreatedAt: d.CreatedAt} + if d.ObjectKey != "" { + if v.URL, err = s.cfg.Objects.SignedURL(d.ObjectKey, s.cfg.URLTTL); err != nil { + return View{}, err + } + ms := d.RenderMs + v.RenderMs = &ms + } + return v, nil +} + +// PurgeExpired removes expired cache rows; never the stored file or the document row. +func (s *Service) PurgeExpired(ctx context.Context) (int, error) { + return s.cfg.Store.PurgeExpiredCache(ctx, s.cfg.Clock()) +} diff --git a/services/documents/internal/documents/store.go b/services/documents/internal/documents/store.go new file mode 100644 index 0000000..ded2521 --- /dev/null +++ b/services/documents/internal/documents/store.go @@ -0,0 +1,165 @@ +package documents + +import ( + "context" + "sort" + "sync" + "time" +) + +// StaleClaimAfter is how long a RENDERING job may sit before another worker takes it over. +const StaleClaimAfter = 5 * time.Minute + +// Store persists documents, the cache index and the outbox. +type Store interface { + // CreateDocument inserts a document row (used for queued jobs). + CreateDocument(ctx context.Context, d Document) error + // CreateRendered inserts a finished document and its cache entry together. + CreateRendered(ctx context.Context, d Document, c CacheEntry) error + // GetDocument returns ErrNotFound when the id is unknown or belongs to another school. + GetDocument(ctx context.Context, schoolID, id string) (Document, error) + // FindCachedDocument returns the document for an unexpired cache entry. + FindCachedDocument(ctx context.Context, schoolID, cacheKey string, now time.Time) (Document, bool, error) + // ClaimQueued marks the oldest queued (or stale rendering) job as RENDERING; works across schools. + ClaimQueued(ctx context.Context, now time.Time) (Document, bool, error) + // CompleteJob stores the finished document, its cache entry and the outbox event atomically. + CompleteJob(ctx context.Context, d Document, c CacheEntry, ev OutboxEvent) error + // FailJob marks a job FAILED. + FailJob(ctx context.Context, schoolID, id string, now time.Time) error + // PurgeExpiredCache deletes expired cache rows only; documents and files stay. + PurgeExpiredCache(ctx context.Context, now time.Time) (int, error) +} + +// MemoryStore is an in-process Store for tests and local runs without a database. +type MemoryStore struct { + mu sync.Mutex + docs map[string]Document + cache map[string]CacheEntry // key: schoolID + "\x00" + cacheKey + outbox []OutboxEvent +} + +// NewMemoryStore returns an empty MemoryStore. +func NewMemoryStore() *MemoryStore { + return &MemoryStore{docs: map[string]Document{}, cache: map[string]CacheEntry{}} +} + +func cacheID(schoolID, key string) string { return schoolID + "\x00" + key } + +// CreateDocument stores d. +func (m *MemoryStore) CreateDocument(_ context.Context, d Document) error { + m.mu.Lock() + defer m.mu.Unlock() + m.docs[d.ID] = d + return nil +} + +// CreateRendered stores d and its cache entry. +func (m *MemoryStore) CreateRendered(_ context.Context, d Document, c CacheEntry) error { + m.mu.Lock() + defer m.mu.Unlock() + m.docs[d.ID] = d + m.cache[cacheID(c.SchoolID, c.CacheKey)] = c + return nil +} + +// GetDocument returns the document only for its own school. +func (m *MemoryStore) GetDocument(_ context.Context, schoolID, id string) (Document, error) { + m.mu.Lock() + defer m.mu.Unlock() + d, ok := m.docs[id] + if !ok || d.SchoolID != schoolID { + return Document{}, notFoundErr() + } + return d, nil +} + +// FindCachedDocument ignores expired entries even before they are purged. +func (m *MemoryStore) FindCachedDocument(_ context.Context, schoolID, cacheKey string, now time.Time) (Document, bool, error) { + m.mu.Lock() + defer m.mu.Unlock() + c, ok := m.cache[cacheID(schoolID, cacheKey)] + if !ok || !c.ExpiresAt.After(now) { + return Document{}, false, nil + } + d, ok := m.docs[c.DocumentID] + return d, ok, nil +} + +// ClaimQueued picks the oldest claimable job. +func (m *MemoryStore) ClaimQueued(_ context.Context, now time.Time) (Document, bool, error) { + m.mu.Lock() + defer m.mu.Unlock() + var ready []Document + for _, d := range m.docs { + stale := d.Status == StatusRendering && now.Sub(d.UpdatedAt) > StaleClaimAfter + if d.Status == StatusQueuedJob || stale { + ready = append(ready, d) + } + } + if len(ready) == 0 { + return Document{}, false, nil + } + sort.Slice(ready, func(i, j int) bool { return ready[i].CreatedAt.Before(ready[j].CreatedAt) }) + d := ready[0] + d.Status, d.UpdatedAt = StatusRendering, now + m.docs[d.ID] = d + return d, true, nil +} + +// CompleteJob applies the three writes under one lock. +func (m *MemoryStore) CompleteJob(_ context.Context, d Document, c CacheEntry, ev OutboxEvent) error { + m.mu.Lock() + defer m.mu.Unlock() + m.docs[d.ID] = d + m.cache[cacheID(c.SchoolID, c.CacheKey)] = c + m.outbox = append(m.outbox, ev) + return nil +} + +// FailJob marks the job FAILED. +func (m *MemoryStore) FailJob(_ context.Context, schoolID, id string, now time.Time) error { + m.mu.Lock() + defer m.mu.Unlock() + d, ok := m.docs[id] + if !ok || d.SchoolID != schoolID { + return notFoundErr() + } + d.Status, d.UpdatedAt = StatusFailed, now + m.docs[id] = d + return nil +} + +// PurgeExpiredCache removes expired cache rows and returns how many. +func (m *MemoryStore) PurgeExpiredCache(_ context.Context, now time.Time) (int, error) { + m.mu.Lock() + defer m.mu.Unlock() + n := 0 + for k, c := range m.cache { + if !c.ExpiresAt.After(now) { + delete(m.cache, k) + n++ + } + } + return n, nil +} + +// CacheCount returns the number of cache rows (tests). +func (m *MemoryStore) CacheCount() int { + m.mu.Lock() + defer m.mu.Unlock() + return len(m.cache) +} + +// DocumentCount returns the number of document rows (tests). +func (m *MemoryStore) DocumentCount() int { + m.mu.Lock() + defer m.mu.Unlock() + return len(m.docs) +} + +// Outbox returns a copy of the outbox rows (tests). +func (m *MemoryStore) Outbox() []OutboxEvent { + m.mu.Lock() + defer m.mu.Unlock() + return append([]OutboxEvent(nil), m.outbox...) +} diff --git a/services/documents/internal/documents/types.go b/services/documents/internal/documents/types.go new file mode 100644 index 0000000..e34a921 --- /dev/null +++ b/services/documents/internal/documents/types.go @@ -0,0 +1,93 @@ +package documents + +import ( + "encoding/json" + "time" +) + +// DocType is the business kind of a document. +type DocType string + +// Format is the output file format. +type Format string + +// Status is the lifecycle state of a document row or the outcome of a request. +type Status string + +const ( + TypeReceipt DocType = "receipt" + TypeReportCard DocType = "report_card" + TypeFeeStatement DocType = "fee_statement" + TypeClassSheet DocType = "class_sheet" + TypeGeneric DocType = "generic" + + FormatPDF Format = "pdf" + FormatXLSX Format = "xlsx" + + StatusServed Status = "SERVED" // response only: cache hit + StatusStoredAndCached Status = "STORED_AND_CACHED" // file stored and cache row written + StatusQueuedJob Status = "QUEUED_JOB" // waiting for the worker + StatusRendering Status = "RENDERING" // claimed by the worker + StatusFailed Status = "FAILED" // render failed, needs a new request +) + +// Document is one stored (or pending) rendered file. +type Document struct { + ID string + SchoolID string + Type DocType + Format Format + Params json.RawMessage + DataVersion string + ObjectKey string + Status Status + RenderMs int64 + JobPayload json.RawMessage // caller data, kept only while the job is queued + CreatedAt time.Time + UpdatedAt time.Time +} + +// CacheEntry maps a cache key to a stored document until ExpiresAt. +type CacheEntry struct { + SchoolID string + CacheKey string + DocumentID string + ExpiresAt time.Time +} + +// OutboxEvent is a fact to be relayed to other services. +type OutboxEvent struct { + ID string + SchoolID string + Topic string + Payload json.RawMessage + CreatedAt time.Time +} + +// CreateRequest is the POST /v1/documents body; the school comes from the token, never the body. +type CreateRequest struct { + Type DocType `json:"type"` + Format Format `json:"format"` + Params json.RawMessage `json:"params"` + DataVersion string `json:"dataVersion"` + Data json.RawMessage `json:"data"` +} + +// Result is the POST /v1/documents response. +type Result struct { + ID string `json:"id"` + Status Status `json:"status"` + URL string `json:"url,omitempty"` + RenderMs *int64 `json:"renderMs,omitempty"` +} + +// View is the GET /v1/documents/{id} response. +type View struct { + ID string `json:"id"` + Status Status `json:"status"` + Type DocType `json:"type"` + Format Format `json:"format"` + URL string `json:"url,omitempty"` + RenderMs *int64 `json:"renderMs,omitempty"` + CreatedAt time.Time `json:"createdAt"` +} diff --git a/services/documents/internal/documents/worker.go b/services/documents/internal/documents/worker.go new file mode 100644 index 0000000..be82ffe --- /dev/null +++ b/services/documents/internal/documents/worker.go @@ -0,0 +1,82 @@ +package documents + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "time" +) + +const ( + TopicDocumentReady = "document.ready" + purgeEvery = time.Hour // expired cache rows are harmless, so purging is not urgent +) + +// ProcessNext renders one queued job and writes the document.ready outbox row; false means the queue is empty. +// A failed render marks the job FAILED instead of retrying forever on bad data. +func (s *Service) ProcessNext(ctx context.Context) (bool, error) { + now := s.cfg.Clock() + doc, ok, err := s.cfg.Store.ClaimQueued(ctx, now) + if err != nil || !ok { + return false, err + } + fail := func(cause error) (bool, error) { + if ferr := s.cfg.Store.FailJob(ctx, doc.SchoolID, doc.ID, s.cfg.Clock()); ferr != nil { + return true, fmt.Errorf("fail job: %w (after %v)", ferr, cause) + } + return true, cause + } + content, err := ParseContent(doc.Type, doc.JobPayload) + if err != nil { + return fail(err) + } + if err := s.render(ctx, &doc, content); err != nil { + return fail(err) + } + key, err := CacheKey(doc.SchoolID, doc.Type, doc.Params, doc.DataVersion, doc.Format) + if err != nil { + return fail(err) + } + payload, _ := json.Marshal(map[string]string{ + "documentId": doc.ID, "schoolId": doc.SchoolID, "type": string(doc.Type), "format": string(doc.Format), + }) // a map of strings cannot fail to marshal + entry := CacheEntry{SchoolID: doc.SchoolID, CacheKey: key, DocumentID: doc.ID, ExpiresAt: s.cfg.Clock().Add(s.cfg.CacheTTL)} + ev := OutboxEvent{ID: newUUID(), SchoolID: doc.SchoolID, Topic: TopicDocumentReady, Payload: payload, CreatedAt: s.cfg.Clock()} + if err := s.cfg.Store.CompleteJob(ctx, doc, entry, ev); err != nil { + return fail(err) + } + return true, nil +} + +// RunWorker drains the queue every interval and purges expired cache rows hourly until ctx ends. +func (s *Service) RunWorker(ctx context.Context, interval time.Duration, logger *slog.Logger) { + ticker := time.NewTicker(interval) + defer ticker.Stop() + var lastPurge time.Time + for { + for { + more, err := s.ProcessNext(ctx) + if err != nil { + logger.Error("document job failed", "err", err) + } + if !more { + break + } + } + if time.Since(lastPurge) >= purgeEvery { + n, err := s.PurgeExpired(ctx) + if err != nil { + logger.Error("cache purge failed", "err", err) + } else { + logger.Info("cache purged", "rows", n) + } + lastPurge = time.Now() + } + select { + case <-ctx.Done(): + return + case <-ticker.C: + } + } +} diff --git a/services/documents/internal/documents/xlsx.go b/services/documents/internal/documents/xlsx.go new file mode 100644 index 0000000..59e5412 --- /dev/null +++ b/services/documents/internal/documents/xlsx.go @@ -0,0 +1,67 @@ +package documents + +import ( + "context" + "fmt" + + "github.com/xuri/excelize/v2" +) + +const ( + sheetName = "Report" + moneyFormat = 4 // built-in "#,##0.00" +) + +// XLSXRenderer writes one sheet: a bold header row, then one row per record. +type XLSXRenderer struct{} + +// Render writes money columns as numbers in major units so spreadsheets can sum them. +func (XLSXRenderer) Render(_ context.Context, c Content) ([]byte, error) { + t := c.AsTable() + f := excelize.NewFile() + defer func() { _ = f.Close() }() // in-memory file, nothing to flush + if err := f.SetSheetName("Sheet1", sheetName); err != nil { + return nil, fmt.Errorf("name sheet: %w", err) + } + bold, err := f.NewStyle(&excelize.Style{Font: &excelize.Font{Bold: true}}) + if err != nil { + return nil, fmt.Errorf("header style: %w", err) + } + money, err := f.NewStyle(&excelize.Style{NumFmt: moneyFormat}) + if err != nil { + return nil, fmt.Errorf("money style: %w", err) + } + + for i, col := range t.Columns { + cell, _ := excelize.CoordinatesToCellName(i+1, 1) + if err := f.SetCellValue(sheetName, cell, col.Label); err != nil { + return nil, fmt.Errorf("write header: %w", err) + } + if err := f.SetCellStyle(sheetName, cell, cell, bold); err != nil { + return nil, fmt.Errorf("style header: %w", err) + } + } + for r, row := range t.Rows { + for i, col := range t.Columns { + cell, _ := excelize.CoordinatesToCellName(i+1, r+2) + if col.Money { + minor, _ := moneyCell(row[col.Key]) // validated when parsed + if err := f.SetCellValue(sheetName, cell, float64(minor)/100); err != nil { + return nil, fmt.Errorf("write money: %w", err) + } + if err := f.SetCellStyle(sheetName, cell, cell, money); err != nil { + return nil, fmt.Errorf("style money: %w", err) + } + continue + } + if err := f.SetCellValue(sheetName, cell, cellText(row[col.Key])); err != nil { + return nil, fmt.Errorf("write cell: %w", err) + } + } + } + buf, err := f.WriteToBuffer() + if err != nil { + return nil, fmt.Errorf("xlsx output: %w", err) + } + return buf.Bytes(), nil +} diff --git a/services/documents/migrations/00001_create_documents.sql b/services/documents/migrations/00001_create_documents.sql new file mode 100644 index 0000000..fef1ecb --- /dev/null +++ b/services/documents/migrations/00001_create_documents.sql @@ -0,0 +1,69 @@ +-- +goose Up +create schema if not exists documents; + +create table documents.documents ( + id uuid primary key default gen_random_uuid(), + school_id uuid not null, + type text not null check (type in ('receipt', 'report_card', 'fee_statement', 'class_sheet', 'generic')), + format text not null check (format in ('pdf', 'xlsx')), + params jsonb not null default '{}', + data_version text not null, + object_key text, + status text not null check (status in ('QUEUED_JOB', 'RENDERING', 'STORED_AND_CACHED', 'FAILED')), + render_ms integer, + job_payload jsonb, -- caller data, only while queued + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); +create index documents_school_idx on documents.documents (school_id, created_at desc); +create index documents_claim_idx on documents.documents (created_at) where status in ('QUEUED_JOB', 'RENDERING'); + +create table documents.document_cache ( + school_id uuid not null, + cache_key text not null, + document_id uuid not null references documents.documents (id), + expires_at timestamptz not null, + created_at timestamptz not null default now(), + primary key (school_id, cache_key) +); +create index document_cache_document_idx on documents.document_cache (document_id); +create index document_cache_expires_idx on documents.document_cache (expires_at); + +create table documents.outbox ( + id uuid primary key default gen_random_uuid(), + school_id uuid not null, + topic text not null, + payload jsonb not null, + created_at timestamptz not null default now(), + published_at timestamptz +); +create index outbox_unpublished_idx on documents.outbox (created_at) where published_at is null; + +-- nullif: after a pooled transaction ends the setting is '' (not null) and ''::uuid would raise. +-- The worker setting lets the queue claim and cache purge span schools; only service code sets it. +alter table documents.documents enable row level security; +alter table documents.documents force row level security; +create policy tenant on documents.documents + using (school_id = nullif(current_setting('app.school_id', true), '')::uuid) + with check (school_id = nullif(current_setting('app.school_id', true), '')::uuid); +create policy worker on documents.documents using (current_setting('app.worker', true) = 'on'); + +alter table documents.document_cache enable row level security; +alter table documents.document_cache force row level security; +create policy tenant on documents.document_cache + using (school_id = nullif(current_setting('app.school_id', true), '')::uuid) + with check (school_id = nullif(current_setting('app.school_id', true), '')::uuid); +create policy worker on documents.document_cache using (current_setting('app.worker', true) = 'on'); + +alter table documents.outbox enable row level security; +alter table documents.outbox force row level security; +create policy tenant on documents.outbox + using (school_id = nullif(current_setting('app.school_id', true), '')::uuid) + with check (school_id = nullif(current_setting('app.school_id', true), '')::uuid); +create policy worker on documents.outbox using (current_setting('app.worker', true) = 'on'); + +-- +goose Down +drop table documents.outbox; +drop table documents.document_cache; +drop table documents.documents; +drop schema documents; diff --git a/services/identity/package.json b/services/identity/package.json new file mode 100644 index 0000000..4c8a7a0 --- /dev/null +++ b/services/identity/package.json @@ -0,0 +1,24 @@ +{ + "name": "@stackable/identity", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "tsx watch src/main.ts", + "start": "tsx src/main.ts", + "typecheck": "tsc --noEmit", + "test": "vitest run --root ../.." + }, + "dependencies": { + "@hono/node-server": "^1.19.0", + "@stackable/contracts": "workspace:*", + "@stackable/service-kit": "workspace:*", + "pg": "^8.16.0" + }, + "devDependencies": { + "@types/node": "^24.0.0", + "@types/pg": "^8.15.0", + "tsx": "^4.20.0", + "typescript": "^5.9.0" + } +} diff --git a/services/identity/src/app.test.ts b/services/identity/src/app.test.ts new file mode 100644 index 0000000..d4a722a --- /dev/null +++ b/services/identity/src/app.test.ts @@ -0,0 +1,53 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { Pool } from "pg"; +import { signServiceToken } from "@stackable/service-kit"; +import { buildApp } from "./app"; + +beforeEach(() => { + process.env.SERVICE_TOKEN_SECRET = "test-secret-test-secret-test-secret-1234"; + vi.spyOn(console, "log").mockImplementation(() => undefined); +}); +afterEach(() => { + delete process.env.SERVICE_TOKEN_SECRET; + vi.restoreAllMocks(); +}); + +const okPool = { query: async () => ({ rows: [] }) } as unknown as Pool; +const badPool = { query: async () => Promise.reject(new Error("down")) } as unknown as Pool; + +describe("GET /v1/me", () => { + it("returns the actor from a valid token", async () => { + const token = await signServiceToken({ audience: "identity", sub: "u1", schoolId: "s1", role: "teacher" }); + const res = await buildApp(okPool, "1").request("/v1/me", { headers: { authorization: `Bearer ${token}` } }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ userId: "u1", schoolId: "s1", role: "teacher" }); + }); + + it("rejects a missing token with the envelope", async () => { + const res = await buildApp(okPool, "1").request("/v1/me"); + expect(res.status).toBe(401); + expect(await res.json()).toMatchObject({ code: "AUTH_UNAUTHORIZED" }); + }); + + it("rejects a token issued for another service", async () => { + const token = await signServiceToken({ audience: "payments", sub: "u1", schoolId: "s1" }); + const res = await buildApp(okPool, "1").request("/v1/me", { headers: { authorization: `Bearer ${token}` } }); + expect(res.status).toBe(401); + }); +}); + +describe("probes", () => { + it("/ready is 200 with a healthy database", async () => { + expect((await buildApp(okPool, "1").request("/ready")).status).toBe(200); + }); + + it("/ready is 503 when the database is down", async () => { + expect((await buildApp(badPool, "1").request("/ready")).status).toBe(503); + }); + + it("/ready is degraded, not 503, when no database is configured in dev", async () => { + const res = await buildApp(undefined, "1").request("/ready"); + expect(res.status).toBe(200); + expect(await res.json()).toMatchObject({ status: "degraded" }); + }); +}); diff --git a/services/identity/src/app.ts b/services/identity/src/app.ts new file mode 100644 index 0000000..ee2cf15 --- /dev/null +++ b/services/identity/src/app.ts @@ -0,0 +1,22 @@ +import type { Pool } from "pg"; +import type { MeResponse } from "@stackable/contracts"; +import { createService, requireServiceToken, type ReadyCheck } from "@stackable/service-kit"; + +export const SERVICE_NAME = "identity"; + +/** Builds the identity app; pool is optional so dev can run without a database. */ +export function buildApp(pool: Pool | undefined, version: string) { + const dbCheck: ReadyCheck = pool + ? { name: "database", run: () => pool.query("select 1") } + : { name: "database", required: false, run: () => Promise.reject(new Error("DATABASE_URL not set")) }; + + const app = createService({ name: SERVICE_NAME, version, checks: [dbCheck] }); + + app.get("/v1/me", requireServiceToken(SERVICE_NAME), (c) => { + const claims = c.var.claims; + const body: MeResponse = { userId: claims.sub, schoolId: claims.schoolId, role: claims.role ?? null }; + return c.json(body); + }); + + return app; +} diff --git a/services/identity/src/main.ts b/services/identity/src/main.ts new file mode 100644 index 0000000..a5a1163 --- /dev/null +++ b/services/identity/src/main.ts @@ -0,0 +1,19 @@ +import { serve } from "@hono/node-server"; +import pg from "pg"; +import { buildApp } from "./app"; + +const DEFAULT_PORT = 4001; +const databaseUrl = process.env.DATABASE_URL; + +// Only dev may run without a database; production must fail at boot, not at first request. +if (!databaseUrl && process.env.NODE_ENV === "production") { + throw new Error("DATABASE_URL is required in production"); +} + +const pool = databaseUrl ? new pg.Pool({ connectionString: databaseUrl }) : undefined; +const app = buildApp(pool, process.env.npm_package_version ?? "0.0.0"); +const server = serve({ fetch: app.fetch, port: Number(process.env.PORT ?? DEFAULT_PORT) }); + +process.on("SIGTERM", () => { + server.close(() => void pool?.end()); +}); diff --git a/services/identity/tsconfig.json b/services/identity/tsconfig.json new file mode 100644 index 0000000..cc2c43a --- /dev/null +++ b/services/identity/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2023", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "types": ["node"] + }, + "include": ["src"] +} diff --git a/services/payments/README.md b/services/payments/README.md new file mode 100644 index 0000000..695e077 --- /dev/null +++ b/services/payments/README.md @@ -0,0 +1,76 @@ +# payments + +Go service for fees, payments and the ledger (Fees MVP). Money is `int64` minor units (KES cents). A +balance is the signed sum of the append-only `ledger_entries` (debits positive, credits negative). +Contract: `packages/contracts/openapi/payments.yaml`. + +## Routes + +All `/v1` routes need a service token (issuer `stackable-gateway`, audience `payments`, school from the +token). Only the webhook is public. Errors use `{ error, code, details, requestId }`. + +| Route | Purpose | +|---|---| +| `POST/GET /v1/fee-structures` | price lists (term, level, name, items) | +| `POST /v1/invoices` | issue to students from a structure (mandatory items) or custom lines; invoice, lines and `DEBIT_INVOICE` entries in one transaction | +| `GET /v1/students/{id}/statement` | ledger balance, per-term lines, payments | +| `POST /v1/payments/intent` | PENDING payment plus provider call; same `idempotencyKey` returns the same payment | +| `GET /v1/payments/{id}` | status polling | +| `POST /v1/webhooks/{provider}` | `mpesa` or `fake`; applied once | +| `GET /v1/unmatched`, `POST /v1/unmatched/{id}/resolve` | queue and resolution | +| `GET /v1/reconciliation?from&to` | rows for the Excel export (documents service) | + +## Rules worth knowing + +- **Webhook once:** the event row, status change, allocations, `CREDIT_PAYMENT` entry, receipt and the + `payment.confirmed` outbox row commit in one transaction. A repeat (same event id) changes nothing, and a late + event for a payment that is no longer PENDING is ignored, so two event ids cannot double-credit. +- **States:** PENDING, CONFIRMED, FAILED, UNMATCHED, REVERSED (schema only; refunds are not exposed yet). +- **Overpayment rule:** accepted, never rejected after money moved. The intent itself may not exceed what is + outstanding on the chosen lines. If lines were paid by another payment meanwhile, the extra becomes a + `STUDENT_CREDIT` allocation; the ledger shows it as a negative balance. Credit is not auto-applied to later invoices. +- **Unmatched:** a successful event for no known payment (needs a `schoolId` hint, fake only) or with an amount + that differs from the intent is booked to the `UNMATCHED_SUSPENSE` account and queued. Resolve clears the + suspense with an `ADJUSTMENT` and then confirms normally. Events with no determinable school are only stored. +- **Provider errors:** `INSUFFICIENT_FUNDS`, `WRONG_PIN`, `USER_CANCELLED` (402), `TIMEOUT` (504), `RAIL_DOWN` + (503), exposed as `PAYMENT_` with `details.paymentId`; the payment is stored as FAILED with `failureCode`. +- **Tenancy:** every table has `school_id` plus an RLS policy on `app.school_id`, and every query also filters + by school. `payments.payment_school()` is a security-definer lookup so a webhook (no token) finds its school. + +## Providers + +`internal/provider`: `PaymentProvider{Initiate, VerifyWebhook, Refund}`. + +- `fake`: deterministic. Payer phone `254700000003` times out, `254700000005` is rail down. Webhook body: + `{"id","transactionId","status":"success|failed","failureCode","amountMinor","schoolId","studentId"}`. + Disabled when `APP_ENV=production` unless `ENABLE_FAKE_PROVIDER=true` (it has no signature). +- `mpesa`: Daraja STK push (OAuth, `processrequest`, callback parsing). Disabled unless all `MPESA_*` are set. + Daraja does not sign callbacks: only payments we initiated can be confirmed; allowlist Safaricom IPs at the proxy. + Refund is not supported yet. + +## Environment + +| Variable | Meaning | +|---|---| +| `PORT` (4005), `APP_ENV`, `DATABASE_URL` | as before; the in-memory store is refused in production | +| `SERVICE_TOKEN_SECRET` | required: the service refuses to start without it | +| `MPESA_CONSUMER_KEY`, `MPESA_CONSUMER_SECRET`, `MPESA_SHORTCODE`, `MPESA_PASSKEY`, `MPESA_CALLBACK_URL` | enable the mpesa rail | +| `MPESA_BASE_URL` | optional, defaults to the Daraja sandbox | +| `ENABLE_FAKE_PROVIDER` | `true` to allow the fake provider in production | + +## Run, migrate, test + +``` +SERVICE_TOKEN_SECRET=dev go run ./cmd/payments +goose -dir migrations postgres "$DATABASE_URL" up +go vet ./... && go test ./... +``` + +`go test -race` needs cgo (a C compiler); it was not available on the build machine, so tests ran without it. + +## Status and gaps + +- `internal/fees/pg_store.go` and migrations `00002`, `00003` are written to match each other but were **not run + against a real Postgres**. The in-memory store (copy on begin, swap on commit) is what the tests use. +- Not built: wallet, event and fundraiser payments, refund endpoint, card and bank rails, outbox relay, + sqlc (queries are hand-written in the store). diff --git a/services/payments/cmd/payments/main.go b/services/payments/cmd/payments/main.go new file mode 100644 index 0000000..12181b2 --- /dev/null +++ b/services/payments/cmd/payments/main.go @@ -0,0 +1,88 @@ +package main + +import ( + "context" + "errors" + "log/slog" + "os" + "os/signal" + "syscall" + + "github.com/jackc/pgx/v5/pgxpool" + kit "github.com/stackable/kyfaru-kit" + "github.com/stackable/payments/internal/fees" + "github.com/stackable/payments/internal/provider" +) + +const ( + serviceName = "payments" + defaultPort = "4005" + tokenIssuer = "stackable-gateway" +) + +// version is set at build time with -ldflags "-X main.version=...". +var version = "dev" + +func main() { + logger := kit.NewLogger(serviceName) + if err := run(logger); err != nil { + logger.Error("fatal", "err", err) + os.Exit(1) + } +} + +func run(logger *slog.Logger) error { + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + store, checks, closeDB, err := buildStore(ctx) + if err != nil { + return err + } + defer closeDB() + + router := kit.NewRouter(kit.Config{Name: serviceName, Version: version, Logger: logger, Checks: checks}) + verifier, err := kit.NewServiceTokenVerifierFromEnv(tokenIssuer, serviceName) + if err != nil { + return err // fail closed: never serve money routes without a token secret + } + fees.NewHandler(fees.NewService(store, buildProviders(logger)...)).Routes(router, kit.RequireServiceToken(verifier)) + + port := os.Getenv("PORT") + if port == "" { + port = defaultPort + } + return kit.Serve(ctx, ":"+port, router, logger) +} + +// buildStore uses Postgres when DATABASE_URL is set and an in-memory store only outside production. +func buildStore(ctx context.Context) (fees.Store, []kit.Check, func(), error) { + url := os.Getenv("DATABASE_URL") + if url == "" { + if os.Getenv("APP_ENV") == "production" { + return nil, nil, nil, errors.New("DATABASE_URL is required in production") + } + unset := func(context.Context) error { return errors.New("DATABASE_URL not set") } + return fees.NewMemoryStore(), []kit.Check{{Name: "database", Required: false, Fn: unset}}, func() {}, nil + } + pool, err := pgxpool.New(ctx, url) + if err != nil { + return nil, nil, nil, err + } + checks := []kit.Check{{Name: "database", Required: true, Fn: pool.Ping}} + return fees.NewPgStore(pool), checks, pool.Close, nil +} + +// buildProviders enables mpesa only when configured and the fake only outside production unless forced on. +func buildProviders(logger *slog.Logger) []provider.PaymentProvider { + var out []provider.PaymentProvider + if m, ok := provider.NewMpesaFromEnv(); ok { + out = append(out, m) + } else { + logger.Warn("mpesa disabled: MPESA_* variables are not all set") + } + if os.Getenv("APP_ENV") != "production" || os.Getenv("ENABLE_FAKE_PROVIDER") == "true" { + out = append(out, provider.Fake{}) + } + return out +} diff --git a/services/payments/go.mod b/services/payments/go.mod new file mode 100644 index 0000000..9fbcd8b --- /dev/null +++ b/services/payments/go.mod @@ -0,0 +1,20 @@ +module github.com/stackable/payments + +go 1.25.0 + +require ( + github.com/go-chi/chi/v5 v5.3.2 + github.com/jackc/pgx/v5 v5.11.0 + github.com/stackable/kyfaru-kit v0.0.0 +) + +require ( + github.com/golang-jwt/jwt/v5 v5.3.1 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + golang.org/x/sync v0.17.0 // indirect + golang.org/x/text v0.29.0 // indirect +) + +replace github.com/stackable/kyfaru-kit => ../../packages/kyfaru-kit diff --git a/services/payments/go.sum b/services/payments/go.sum new file mode 100644 index 0000000..a785dba --- /dev/null +++ b/services/payments/go.sum @@ -0,0 +1,30 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-chi/chi/v5 v5.3.2 h1:5YQkICvTCSZ25hoRsyJazN0scjzKGiu4VAUc7H1o1nY= +github.com/go-chi/chi/v5 v5.3.2/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg= +github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= +golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/services/payments/internal/fees/fees_test.go b/services/payments/internal/fees/fees_test.go new file mode 100644 index 0000000..3e15ad4 --- /dev/null +++ b/services/payments/internal/fees/fees_test.go @@ -0,0 +1,560 @@ +package fees + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" + kit "github.com/stackable/kyfaru-kit" + "github.com/stackable/payments/internal/provider" +) + +const ( + testSecret = "test-secret" + schoolA = "11111111-1111-4111-8111-111111111111" + schoolB = "22222222-2222-4222-8222-222222222222" + studentA = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" + studentB = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" +) + +type countingProvider struct { + provider.Fake + calls atomic.Int32 +} + +func (c *countingProvider) Initiate(ctx context.Context, p provider.Payment) (provider.Ref, error) { + c.calls.Add(1) + return c.Fake.Initiate(ctx, p) +} + +type failingProvider struct { + provider.Fake + err error +} + +func (f failingProvider) Initiate(context.Context, provider.Payment) (provider.Ref, error) { + return provider.Ref{}, f.err +} + +type env struct { + t *testing.T + store *MemoryStore + svc *Service + h http.Handler + fake *countingProvider +} + +func newEnv(t *testing.T) *env { + t.Helper() + return newEnvWith(t, NewMemoryStore(), nil) +} + +func newEnvWith(t *testing.T, store Store, prov provider.PaymentProvider) *env { + t.Helper() + fake := &countingProvider{} + if prov == nil { + prov = fake + } + svc := NewService(store, prov) + v, err := kit.NewServiceTokenVerifier(testSecret, "stackable-gateway", "payments") + if err != nil { + t.Fatal(err) + } + r := kit.NewRouter(kit.Config{Name: "payments", Logger: slog.New(slog.NewJSONHandler(io.Discard, nil))}) + NewHandler(svc).Routes(r, kit.RequireServiceToken(v)) + ms, _ := store.(*MemoryStore) + return &env{t: t, store: ms, svc: svc, h: r, fake: fake} +} + +func (e *env) token(school string) string { + tok, err := kit.SignServiceToken(testSecret, kit.ServiceClaims{SchoolID: school, RegisteredClaims: jwt.RegisteredClaims{ + Issuer: "stackable-gateway", Audience: jwt.ClaimStrings{"payments"}, Subject: "user-1", ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Minute))}}) + if err != nil { + e.t.Fatal(err) + } + return tok +} + +func (e *env) call(school, method, path, body string) (int, map[string]any) { + req := httptest.NewRequest(method, path, strings.NewReader(body)) + if school != "" { + req.Header.Set("Authorization", "Bearer "+e.token(school)) + } + rec := httptest.NewRecorder() + e.h.ServeHTTP(rec, req) + var out map[string]any + _ = json.Unmarshal(rec.Body.Bytes(), &out) + return rec.Code, out +} + +// debt issues one invoice with the given line amounts and returns the line ids. +func (e *env) debt(school, student string, amounts ...int64) []string { + e.t.Helper() + var lines []LineInput + for i, a := range amounts { + lines = append(lines, LineInput{Description: fmt.Sprintf("Item %d", i), AmountMinor: a}) + } + invs, err := e.svc.IssueInvoices(context.Background(), school, IssueInvoicesInput{StudentIDs: []string{student}, Term: "2026-T1", Lines: lines}) + if err != nil { + e.t.Fatal(err) + } + var ids []string + for _, l := range invs[0].Lines { + ids = append(ids, l.ID) + } + return ids +} + +func (e *env) intent(school, student, key string, amount int64, lines []string) Payment { + e.t.Helper() + p, _, err := e.svc.CreateIntent(context.Background(), school, "user-1", IntentInput{StudentID: student, AmountMinor: amount, Rail: RailFake, InvoiceLineIDs: lines, IdempotencyKey: key}) + if err != nil { + e.t.Fatal(err) + } + return p +} + +func (e *env) webhook(eventID string, p Payment, extra string) (WebhookResult, error) { + body := fmt.Sprintf(`{"id":%q,"transactionId":%q%s}`, eventID, p.ProviderTransactionID, extra) + return e.svc.ReceiveWebhook(context.Background(), "fake", http.Header{}, []byte(body)) +} + +func (e *env) balance(school, student string) int64 { + e.t.Helper() + var b int64 + _ = e.store.InTx(context.Background(), school, func(r Repo) (err error) { + b, err = r.LedgerBalance(context.Background(), AccountReceivable, student) + return err + }) + return b +} + +func (e *env) ledgerSum(school, student string) int64 { + e.store.mu.Lock() + defer e.store.mu.Unlock() + var sum int64 + for _, l := range e.store.state.ledger { + if l.SchoolID == school && l.StudentID == student && l.Account == AccountReceivable { + sum += l.AmountMinor + } + } + return sum +} + +func TestWebhookAppliedOnceUnder50ConcurrentDeliveries(t *testing.T) { + e := newEnv(t) + lines := e.debt(schoolA, studentA, 100000) + pay := e.intent(schoolA, studentA, "k1", 100000, lines) + + var wg sync.WaitGroup + var fresh atomic.Int32 + for i := 0; i < 50; i++ { + wg.Add(1) + go func() { + defer wg.Done() + res, err := e.webhook("evt-1", pay, "") + if err != nil { + t.Error(err) + } else if !res.Duplicate { + fresh.Add(1) + } + }() + } + wg.Wait() + + if fresh.Load() != 1 { + t.Fatalf("%d deliveries were applied, want 1", fresh.Load()) + } + e.store.mu.Lock() + defer e.store.mu.Unlock() + st := e.store.state + if len(st.receipts) != 1 || len(st.allocations) != 1 || len(st.outbox) != 1 || st.outbox[0].Type != "payment.confirmed" { + t.Fatalf("receipts %d allocations %d outbox %d", len(st.receipts), len(st.allocations), len(st.outbox)) + } + if got := st.payments[pay.ID].Status; got != StatusConfirmed { + t.Fatalf("status %s", got) + } +} + +func TestLedgerBalanceEqualsSumAndStatementAgrees(t *testing.T) { + e := newEnv(t) + lines := e.debt(schoolA, studentA, 30000, 20000) + pay := e.intent(schoolA, studentA, "k1", 30000, lines[:1]) + if _, err := e.webhook("e1", pay, ""); err != nil { + t.Fatal(err) + } + if got, want := e.balance(schoolA, studentA), e.ledgerSum(schoolA, studentA); got != want || got != 20000 { + t.Fatalf("balance %d, sum %d, want 20000", got, want) + } + st, err := e.svc.GetStatement(context.Background(), schoolA, studentA, "") + if err != nil { + t.Fatal(err) + } + var termBalance int64 + for _, term := range st.Terms { + termBalance += term.BalanceMinor + } + if st.BalanceMinor != termBalance-st.CreditMinor || len(st.Payments) != 1 || st.Payments[0].ReceiptNumber != "RCT-000001" { + t.Fatalf("statement %+v", st) + } +} + +func TestPartialPaymentAllocation(t *testing.T) { + e := newEnv(t) + lines := e.debt(schoolA, studentA, 50000, 50000) + pay := e.intent(schoolA, studentA, "k1", 70000, lines) + if _, err := e.webhook("e1", pay, ""); err != nil { + t.Fatal(err) + } + st, _ := e.svc.GetStatement(context.Background(), schoolA, studentA, "") + got := st.Terms[0].Lines + paid := map[string]int64{got[0].LineID: got[0].PaidMinor, got[1].LineID: got[1].PaidMinor} + if paid[lines[0]] != 50000 || paid[lines[1]] != 20000 || st.BalanceMinor != 30000 || st.CreditMinor != 0 { + t.Fatalf("paid %v statement %+v", paid, st) + } +} + +func TestOverpaymentBecomesStudentCredit(t *testing.T) { + e := newEnv(t) + lines := e.debt(schoolA, studentA, 100000) + first := e.intent(schoolA, studentA, "k1", 100000, lines) + second := e.intent(schoolA, studentA, "k2", 100000, lines) // both were valid when created + _, _ = e.webhook("e1", first, "") + _, _ = e.webhook("e2", second, "") + st, _ := e.svc.GetStatement(context.Background(), schoolA, studentA, "") + if st.CreditMinor != 100000 || st.BalanceMinor != -100000 || st.Terms[0].BalanceMinor != 0 { + t.Fatalf("statement %+v", st) + } +} + +func TestIntentAboveOutstandingIsRejected(t *testing.T) { + e := newEnv(t) + lines := e.debt(schoolA, studentA, 10000) + _, _, err := e.svc.CreateIntent(context.Background(), schoolA, "u", IntentInput{StudentID: studentA, AmountMinor: 10001, Rail: RailFake, InvoiceLineIDs: lines, IdempotencyKey: "k"}) + var app *kit.AppError + if !errors.As(err, &app) || app.Code != "PAYMENT_AMOUNT_EXCEEDS_OUTSTANDING" { + t.Fatalf("err %v", err) + } +} + +func TestUnmatchedQueueAndResolve(t *testing.T) { + e := newEnv(t) + lines := e.debt(schoolA, studentA, 40000) + body := fmt.Sprintf(`{"id":"ext-1","amountMinor":40000,"schoolId":%q}`, schoolA) + res, err := e.svc.ReceiveWebhook(context.Background(), "fake", http.Header{}, []byte(body)) + if err != nil || res.Outcome != OutcomeUnmatched { + t.Fatalf("%+v %v", res, err) + } + rows, _ := e.svc.ListUnmatched(context.Background(), schoolA, false, "", 10) + if len(rows) != 1 || rows[0].Reason != ReasonNoMatchingPayment || e.suspense(schoolA) != -40000 { + t.Fatalf("rows %+v suspense %d", rows, e.suspense(schoolA)) + } + if e.balance(schoolA, studentA) != 40000 { + t.Fatal("unmatched money must not touch the student balance yet") + } + + pay, err := e.svc.ResolveUnmatched(context.Background(), schoolA, "finance-1", rows[0].PaymentID, ResolveInput{StudentID: studentA, InvoiceLineIDs: lines}) + if err != nil || pay.Status != StatusConfirmed { + t.Fatalf("%+v %v", pay, err) + } + if e.suspense(schoolA) != 0 || e.balance(schoolA, studentA) != 0 { + t.Fatalf("suspense %d balance %d", e.suspense(schoolA), e.balance(schoolA, studentA)) + } + if open, _ := e.svc.ListUnmatched(context.Background(), schoolA, false, "", 10); len(open) != 0 { + t.Fatalf("queue still has %d open rows", len(open)) + } + _, err = e.svc.ResolveUnmatched(context.Background(), schoolA, "finance-1", rows[0].PaymentID, ResolveInput{StudentID: studentA}) + var app *kit.AppError + if !errors.As(err, &app) || app.Code != "PAYMENT_ALREADY_RESOLVED" { + t.Fatalf("second resolve: %v", err) + } +} + +func (e *env) suspense(school string) int64 { + var b int64 + _ = e.store.InTx(context.Background(), school, func(r Repo) (err error) { + b, err = r.LedgerBalance(context.Background(), AccountSuspense, "") + return err + }) + return b +} + +func TestAmountMismatchGoesToUnmatched(t *testing.T) { + e := newEnv(t) + lines := e.debt(schoolA, studentA, 10000) + pay := e.intent(schoolA, studentA, "k1", 10000, lines) + res, err := e.webhook("e1", pay, `,"amountMinor":99999`) + if err != nil || res.Outcome != OutcomeUnmatched { + t.Fatalf("%+v %v", res, err) + } + if e.balance(schoolA, studentA) != 10000 { + t.Fatal("a mismatched amount must not credit the student") + } +} + +func TestFailedThenLateSuccessChangesNothing(t *testing.T) { + e := newEnv(t) + lines := e.debt(schoolA, studentA, 10000) + pay := e.intent(schoolA, studentA, "k1", 10000, lines) + if res, _ := e.webhook("e1", pay, `,"status":"failed","failureCode":"WRONG_PIN"`); res.Outcome != OutcomeFailed { + t.Fatalf("%+v", res) + } + if res, _ := e.webhook("e2", pay, ""); res.Outcome != OutcomeIgnored { + t.Fatalf("%+v", res) + } + got, _ := e.svc.GetPayment(context.Background(), schoolA, pay.ID) + if got.Status != StatusFailed || got.FailureCode != "WRONG_PIN" || e.balance(schoolA, studentA) != 10000 { + t.Fatalf("%+v", got) + } +} + +func TestIdempotentIntent(t *testing.T) { + e := newEnv(t) + lines := e.debt(schoolA, studentA, 10000) + body := fmt.Sprintf(`{"studentId":%q,"amountMinor":10000,"rail":"fake","invoiceLineIds":[%q],"idempotencyKey":"key-1"}`, studentA, lines[0]) + c1, a := e.call(schoolA, "POST", "/v1/payments/intent", body) + c2, b := e.call(schoolA, "POST", "/v1/payments/intent", body) + if c1 != 201 || c2 != 200 || a["id"] != b["id"] || e.fake.calls.Load() != 1 { + t.Fatalf("%d %d %v %v calls=%d", c1, c2, a, b, e.fake.calls.Load()) + } + other := strings.Replace(body, "10000,", "5000,", 1) + if c, out := e.call(schoolA, "POST", "/v1/payments/intent", other); c != 409 || out["code"] != "IDEMPOTENCY_KEY_REUSED" { + t.Fatalf("%d %v", c, out) + } +} + +func TestIdempotentIntentUnderConcurrency(t *testing.T) { + e := newEnv(t) + lines := e.debt(schoolA, studentA, 10000) + var wg sync.WaitGroup + ids := sync.Map{} + for i := 0; i < 20; i++ { + wg.Add(1) + go func() { + defer wg.Done() + p, _, err := e.svc.CreateIntent(context.Background(), schoolA, "u", IntentInput{StudentID: studentA, AmountMinor: 10000, Rail: RailFake, InvoiceLineIDs: lines, IdempotencyKey: "same"}) + if err != nil { + t.Error(err) + return + } + ids.Store(p.ID, true) + }() + } + wg.Wait() + n := 0 + ids.Range(func(any, any) bool { n++; return true }) + if n != 1 || e.fake.calls.Load() != 1 { + t.Fatalf("%d payments, %d provider calls", n, e.fake.calls.Load()) + } +} + +// receiptFailStore makes the receipt insert fail so we can prove the whole confirmation rolls back. +type receiptFailStore struct{ Store } + +type receiptFailRepo struct{ Repo } + +func (receiptFailRepo) InsertReceipt(context.Context, Receipt) error { + return errors.New("receipt insert failed") +} + +func (s receiptFailStore) InTx(ctx context.Context, school string, fn func(Repo) error) error { + return s.Store.InTx(ctx, school, func(r Repo) error { return fn(receiptFailRepo{r}) }) +} + +func TestFailingReceiptRollsBackLedgerCredit(t *testing.T) { + mem := NewMemoryStore() + good := newEnvWith(t, mem, nil) + lines := good.debt(schoolA, studentA, 10000) + pay := good.intent(schoolA, studentA, "k1", 10000, lines) + + bad := newEnvWith(t, receiptFailStore{mem}, nil) + if _, err := bad.webhook("e1", pay, ""); err == nil { + t.Fatal("expected the failing receipt to fail the webhook") + } + mem.mu.Lock() + st := mem.state + untouched := len(st.ledger) == 1 && len(st.allocations) == 0 && len(st.outbox) == 0 && st.payments[pay.ID].Status == StatusPending && len(st.events) == 0 + mem.mu.Unlock() + if !untouched { + t.Fatalf("partial writes survived: ledger %d allocations %d outbox %d events %d", len(st.ledger), len(st.allocations), len(st.outbox), len(st.events)) + } + // the provider retry on a healthy service still confirms it + if res, err := good.webhook("e1", pay, ""); err != nil || res.Outcome != OutcomeConfirmed { + t.Fatalf("%+v %v", res, err) + } +} + +func TestTenantIsolation(t *testing.T) { + e := newEnv(t) + lines := e.debt(schoolA, studentA, 10000) + pay := e.intent(schoolA, studentA, "k1", 10000, lines) + + st, _ := e.svc.GetStatement(context.Background(), schoolB, studentA, "") + if len(st.Terms) != 0 || st.BalanceMinor != 0 || len(st.Payments) != 0 { + t.Fatalf("school B saw %+v", st) + } + _, _, err := e.svc.CreateIntent(context.Background(), schoolB, "u", IntentInput{StudentID: studentA, AmountMinor: 100, Rail: RailFake, InvoiceLineIDs: lines, IdempotencyKey: "kb"}) + var app *kit.AppError + if !errors.As(err, &app) || app.Code != "INVOICE_LINE_NOT_FOUND" { + t.Fatalf("cross-school intent: %v", err) + } + if _, err := e.svc.GetPayment(context.Background(), schoolB, pay.ID); err == nil { + t.Fatal("school B read school A's payment") + } + // same idempotency key in another school is a different payment + e.debt(schoolB, studentB, 10000) + if code, out := e.call(schoolB, "GET", "/v1/payments/"+pay.ID, ""); code != 404 || out["code"] != "PAYMENT_NOT_FOUND" { + t.Fatalf("%d %v", code, out) + } + // a park by school A is not resolvable by school B + body := fmt.Sprintf(`{"id":"x1","amountMinor":500,"schoolId":%q}`, schoolA) + _, _ = e.svc.ReceiveWebhook(context.Background(), "fake", http.Header{}, []byte(body)) + rowsA, _ := e.svc.ListUnmatched(context.Background(), schoolA, false, "", 10) + if _, err := e.svc.ResolveUnmatched(context.Background(), schoolB, "x", rowsA[0].PaymentID, ResolveInput{StudentID: studentB}); err == nil { + t.Fatal("school B resolved school A's unmatched payment") + } + if rowsB, _ := e.svc.ListUnmatched(context.Background(), schoolB, false, "", 10); len(rowsB) != 0 { + t.Fatal("school B sees A's queue") + } +} + +func TestProviderErrorsMapToDistinctCodes(t *testing.T) { + cases := []struct { + fail provider.FailureCode + status int + }{ + {provider.InsufficientFunds, 402}, {provider.WrongPin, 402}, {provider.UserCancelled, 402}, + {provider.Timeout, 504}, {provider.RailDown, 503}, + } + for _, c := range cases { + t.Run(string(c.fail), func(t *testing.T) { + e := newEnvWith(t, NewMemoryStore(), failingProvider{err: &provider.Error{Code: c.fail, Message: "x"}}) + lines := e.debt(schoolA, studentA, 10000) + body := fmt.Sprintf(`{"studentId":%q,"amountMinor":10000,"rail":"fake","invoiceLineIds":[%q],"idempotencyKey":"k"}`, studentA, lines[0]) + code, out := e.call(schoolA, "POST", "/v1/payments/intent", body) + if code != c.status || out["code"] != "PAYMENT_"+string(c.fail) { + t.Fatalf("%d %v", code, out) + } + details, _ := out["details"].(map[string]any) + pay, err := e.svc.GetPayment(context.Background(), schoolA, fmt.Sprint(details["paymentId"])) + if err != nil || pay.Status != StatusFailed || pay.FailureCode != string(c.fail) { + t.Fatalf("%+v %v", pay, err) + } + }) + } +} + +func TestFakePhoneTriggersTimeout(t *testing.T) { + e := newEnv(t) + lines := e.debt(schoolA, studentA, 10000) + _, _, err := e.svc.CreateIntent(context.Background(), schoolA, "u", IntentInput{StudentID: studentA, AmountMinor: 100, Rail: RailFake, InvoiceLineIDs: lines, IdempotencyKey: "k", PayerPhone: provider.FakePhoneTimeout}) + var app *kit.AppError + if !errors.As(err, &app) || app.Code != "PAYMENT_TIMEOUT" { + t.Fatalf("%v", err) + } +} + +func TestDisabledAndUnsupportedRails(t *testing.T) { + e := newEnv(t) + lines := e.debt(schoolA, studentA, 10000) + mk := func(rail string) string { + return fmt.Sprintf(`{"studentId":%q,"amountMinor":100,"rail":%q,"invoiceLineIds":[%q],"idempotencyKey":"k-%s","payerPhone":"0712345678"}`, studentA, rail, lines[0], rail) + } + if code, out := e.call(schoolA, "POST", "/v1/payments/intent", mk("mpesa")); code != 503 || out["code"] != "PAYMENT_RAIL_DOWN" { + t.Fatalf("mpesa off: %d %v", code, out) + } + if code, out := e.call(schoolA, "POST", "/v1/payments/intent", mk("card")); code != 422 || out["code"] != "PAYMENT_RAIL_UNSUPPORTED" { + t.Fatalf("card: %d %v", code, out) + } +} + +func TestInvoiceIssueFromStructureIsAtomicAndNotRepeatable(t *testing.T) { + e := newEnv(t) + code, fs := e.call(schoolA, "POST", "/v1/fee-structures", `{"name":"Day scholar","term":"2026-T1","level":"Grade 4","items":[{"name":"Tuition","amountMinor":5000000,"mandatory":true},{"name":"Trip","amountMinor":100000,"mandatory":false}]}`) + if code != 201 { + t.Fatalf("%d %v", code, fs) + } + if code, out := e.call(schoolA, "POST", "/v1/fee-structures", `{"name":"Day scholar","term":"2026-T1","level":"Grade 4","items":[{"name":"X","amountMinor":1,"mandatory":true}]}`); code != 409 || out["code"] != "FEE_STRUCTURE_EXISTS" { + t.Fatalf("%d %v", code, out) + } + body := fmt.Sprintf(`{"studentIds":[%q],"feeStructureId":%q}`, studentA, fs["id"]) + if code, out := e.call(schoolA, "POST", "/v1/invoices", body); code != 201 { + t.Fatalf("%d %v", code, out) + } + both := fmt.Sprintf(`{"studentIds":[%q,%q],"feeStructureId":%q}`, studentB, studentA, fs["id"]) + if code, out := e.call(schoolA, "POST", "/v1/invoices", both); code != 409 || out["code"] != "INVOICE_ALREADY_ISSUED" { + t.Fatalf("%d %v", code, out) + } + if e.balance(schoolA, studentA) != 5000000 || e.balance(schoolA, studentB) != 0 { + t.Fatalf("balances %d %d: the failed run must not leave student B debited", e.balance(schoolA, studentA), e.balance(schoolA, studentB)) + } +} + +func TestValidationAndAuthAtTheGate(t *testing.T) { + e := newEnv(t) + if code, out := e.call("", "GET", "/v1/unmatched", ""); code != 401 || out["code"] != "AUTH_UNAUTHORIZED" { + t.Fatalf("%d %v", code, out) + } + if code, out := e.call(schoolA, "POST", "/v1/payments/intent", `{"amountMinor":-5}`); code != 400 || out["code"] != "VALIDATION_FAILED" || out["requestId"] == "" { + t.Fatalf("%d %v", code, out) + } + if code, _ := e.call(schoolA, "GET", "/v1/students/not-a-uuid/statement", ""); code != 400 { + t.Fatalf("bad path id: %d", code) + } + if code, _ := e.call(schoolA, "GET", "/v1/reconciliation?from=2026-02-01&to=2026-01-01", ""); code != 400 { + t.Fatalf("bad range: %d", code) + } +} + +func TestReconciliationRows(t *testing.T) { + e := newEnv(t) + lines := e.debt(schoolA, studentA, 10000) + pay := e.intent(schoolA, studentA, "k1", 10000, lines) + _, _ = e.webhook("e1", pay, "") + from := time.Now().Add(-time.Hour).UTC().Format(time.RFC3339) + to := time.Now().Add(time.Hour).UTC().Format(time.RFC3339) + code, out := e.call(schoolA, "GET", "/v1/reconciliation?from="+from+"&to="+to, "") + rows, _ := out["rows"].([]any) + if code != 200 || len(rows) != 1 { + t.Fatalf("%d %v", code, out) + } + row := rows[0].(map[string]any) + if row["status"] != StatusConfirmed || row["allocatedMinor"] != float64(10000) || row["receiptNumber"] != "RCT-000001" { + t.Fatalf("%v", row) + } + if code, out := e.call(schoolB, "GET", "/v1/reconciliation?from="+from+"&to="+to, ""); code != 200 || len(out["rows"].([]any)) != 0 { + t.Fatalf("school B rows: %v", out) + } +} + +func TestWebhookEdgeCases(t *testing.T) { + e := newEnv(t) + post := func(path, body string) (int, map[string]any) { return e.call("", "POST", path, body) } + if code, out := post("/v1/webhooks/nope", `{"id":"1"}`); code != 404 || out["code"] != "RESOURCE_NOT_FOUND" { + t.Fatalf("%d %v", code, out) + } + if code, out := post("/v1/webhooks/fake", `not json`); code != 400 || out["code"] != "WEBHOOK_INVALID" { + t.Fatalf("%d %v", code, out) + } + // an event for an unknown school is stored once and applied to nothing + for i, want := range []bool{false, true} { + code, out := post("/v1/webhooks/fake", `{"id":"orphan-1","amountMinor":5}`) + if code != 200 || out["duplicate"] != want || out["outcome"] != OutcomeRecorded { + t.Fatalf("delivery %d: %d %v", i, code, out) + } + } + if e.store.EventCount() != 1 { + t.Fatalf("%d events stored", e.store.EventCount()) + } +} diff --git a/services/payments/internal/fees/handler.go b/services/payments/internal/fees/handler.go new file mode 100644 index 0000000..39f8788 --- /dev/null +++ b/services/payments/internal/fees/handler.go @@ -0,0 +1,285 @@ +package fees + +import ( + "io" + "net/http" + "strconv" + + "github.com/go-chi/chi/v5" + kit "github.com/stackable/kyfaru-kit" +) + +const maxWebhookBytes = 1 << 20 // 1 MiB cap on provider callbacks + +// Handler exposes the HTTP routes; it only parses, validates and maps errors. +type Handler struct { + svc *Service +} + +// NewHandler builds a Handler over a Service. +func NewHandler(svc *Service) *Handler { return &Handler{svc: svc} } + +// Routes mounts the service-token routes under auth and the provider webhook without it. +func (h *Handler) Routes(r chi.Router, auth func(http.Handler) http.Handler) { + r.Post("/v1/webhooks/{provider}", kit.Handle(h.webhook)) + r.Group(func(g chi.Router) { + g.Use(auth) + g.Post("/v1/fee-structures", kit.Handle(h.createFeeStructure)) + g.Get("/v1/fee-structures", kit.Handle(h.listFeeStructures)) + g.Post("/v1/invoices", kit.Handle(h.issueInvoices)) + g.Get("/v1/students/{id}/statement", kit.Handle(h.statement)) + g.Post("/v1/payments/intent", kit.Handle(h.intent)) + g.Get("/v1/payments/{id}", kit.Handle(h.getPayment)) + g.Get("/v1/unmatched", kit.Handle(h.listUnmatched)) + g.Post("/v1/unmatched/{id}/resolve", kit.Handle(h.resolve)) + g.Get("/v1/reconciliation", kit.Handle(h.reconciliation)) + }) +} + +func claims(r *http.Request) (*kit.ServiceClaims, error) { + c, ok := kit.ClaimsFrom(r.Context()) + if !ok { + return nil, kit.Unauthorized("Missing service token.") + } + return c, nil +} + +func pathID(r *http.Request, name string) (string, error) { + id := chi.URLParam(r, name) + if !isUUID(id) { + return "", kit.Validation("Path id must be a UUID.", map[string]string{name: "must be a UUID"}) + } + return id, nil +} + +func pageParams(r *http.Request) (after string, limit int, err error) { + limit = defaultPageSize + if v := r.URL.Query().Get("limit"); v != "" { + if limit, err = strconv.Atoi(v); err != nil || limit < 1 || limit > maxPageSize { + return "", 0, kit.Validation("limit must be between 1 and 500.", map[string]string{"limit": "1-500"}) + } + } + after = r.URL.Query().Get("after") + if after != "" && !isUUID(after) { + return "", 0, kit.Validation("after must be a UUID.", map[string]string{"after": "must be a UUID"}) + } + return after, limit, nil +} + +func nextCursor(n, limit int, last string) string { + if n == limit { + return last + } + return "" +} + +func (h *Handler) webhook(w http.ResponseWriter, r *http.Request) error { + body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxWebhookBytes)) + if err != nil { + return kit.Validation("Webhook body is too large or unreadable.", nil) + } + res, err := h.svc.ReceiveWebhook(r.Context(), chi.URLParam(r, "provider"), r.Header, body) + if err != nil { + return err + } + kit.WriteJSON(w, http.StatusOK, map[string]any{"received": true, "duplicate": res.Duplicate, "outcome": res.Outcome}) + return nil +} + +func (h *Handler) createFeeStructure(w http.ResponseWriter, r *http.Request) error { + c, err := claims(r) + if err != nil { + return err + } + var in FeeStructureInput + if err := kit.DecodeJSON(w, r, &in); err != nil { + return err + } + if err := in.Validate(); err != nil { + return err + } + fs, err := h.svc.CreateFeeStructure(r.Context(), c.SchoolID, in) + if err != nil { + return err + } + kit.WriteJSON(w, http.StatusCreated, fs) + return nil +} + +func (h *Handler) listFeeStructures(w http.ResponseWriter, r *http.Request) error { + c, err := claims(r) + if err != nil { + return err + } + after, limit, err := pageParams(r) + if err != nil { + return err + } + q := r.URL.Query() + items, err := h.svc.ListFeeStructures(r.Context(), c.SchoolID, q.Get("term"), q.Get("level"), after, limit) + if err != nil { + return err + } + cursor := "" + if len(items) > 0 { + cursor = nextCursor(len(items), limit, items[len(items)-1].ID) + } + kit.WriteJSON(w, http.StatusOK, map[string]any{"items": nonNil(items), "nextCursor": cursor}) + return nil +} + +func nonNil[T any](s []T) []T { + if s == nil { + return []T{} + } + return s +} + +func (h *Handler) issueInvoices(w http.ResponseWriter, r *http.Request) error { + c, err := claims(r) + if err != nil { + return err + } + var in IssueInvoicesInput + if err := kit.DecodeJSON(w, r, &in); err != nil { + return err + } + if err := in.Validate(); err != nil { + return err + } + invoices, err := h.svc.IssueInvoices(r.Context(), c.SchoolID, in) + if err != nil { + return err + } + kit.WriteJSON(w, http.StatusCreated, map[string]any{"invoices": invoices}) + return nil +} + +func (h *Handler) statement(w http.ResponseWriter, r *http.Request) error { + c, err := claims(r) + if err != nil { + return err + } + id, err := pathID(r, "id") + if err != nil { + return err + } + st, err := h.svc.GetStatement(r.Context(), c.SchoolID, id, r.URL.Query().Get("term")) + if err != nil { + return err + } + kit.WriteJSON(w, http.StatusOK, st) + return nil +} + +func (h *Handler) intent(w http.ResponseWriter, r *http.Request) error { + c, err := claims(r) + if err != nil { + return err + } + var in IntentInput + if err := kit.DecodeJSON(w, r, &in); err != nil { + return err + } + if err := in.Validate(); err != nil { + return err + } + pay, created, err := h.svc.CreateIntent(r.Context(), c.SchoolID, c.Subject, in) + if err != nil { + return err + } + status := http.StatusOK + if created { + status = http.StatusCreated + } + kit.WriteJSON(w, status, pay) + return nil +} + +func (h *Handler) getPayment(w http.ResponseWriter, r *http.Request) error { + c, err := claims(r) + if err != nil { + return err + } + id, err := pathID(r, "id") + if err != nil { + return err + } + pay, err := h.svc.GetPayment(r.Context(), c.SchoolID, id) + if err != nil { + return err + } + kit.WriteJSON(w, http.StatusOK, pay) + return nil +} + +func (h *Handler) listUnmatched(w http.ResponseWriter, r *http.Request) error { + c, err := claims(r) + if err != nil { + return err + } + after, limit, err := pageParams(r) + if err != nil { + return err + } + rows, err := h.svc.ListUnmatched(r.Context(), c.SchoolID, r.URL.Query().Get("includeResolved") == "true", after, limit) + if err != nil { + return err + } + cursor := "" + if len(rows) > 0 { + cursor = nextCursor(len(rows), limit, rows[len(rows)-1].PaymentID) + } + kit.WriteJSON(w, http.StatusOK, map[string]any{"items": nonNil(rows), "nextCursor": cursor}) + return nil +} + +func (h *Handler) resolve(w http.ResponseWriter, r *http.Request) error { + c, err := claims(r) + if err != nil { + return err + } + id, err := pathID(r, "id") + if err != nil { + return err + } + var in ResolveInput + if err := kit.DecodeJSON(w, r, &in); err != nil { + return err + } + if err := in.Validate(); err != nil { + return err + } + pay, err := h.svc.ResolveUnmatched(r.Context(), c.SchoolID, c.Subject, id, in) + if err != nil { + return err + } + kit.WriteJSON(w, http.StatusOK, pay) + return nil +} + +func (h *Handler) reconciliation(w http.ResponseWriter, r *http.Request) error { + c, err := claims(r) + if err != nil { + return err + } + from, to, err := ParseRange(r.URL.Query().Get("from"), r.URL.Query().Get("to")) + if err != nil { + return err + } + after, limit, err := pageParams(r) + if err != nil { + return err + } + limit = min(limit, maxReconRows) + rows, err := h.svc.Reconciliation(r.Context(), c.SchoolID, from, to, after, limit) + if err != nil { + return err + } + cursor := "" + if len(rows) > 0 { + cursor = nextCursor(len(rows), limit, rows[len(rows)-1].PaymentID) + } + kit.WriteJSON(w, http.StatusOK, map[string]any{"rows": nonNil(rows), "nextCursor": cursor}) + return nil +} diff --git a/services/payments/internal/fees/memory_store.go b/services/payments/internal/fees/memory_store.go new file mode 100644 index 0000000..f71f2df --- /dev/null +++ b/services/payments/internal/fees/memory_store.go @@ -0,0 +1,424 @@ +package fees + +import ( + "context" + "crypto/rand" + "fmt" + "maps" + "slices" + "sort" + "strings" + "sync" + "time" +) + +// newID returns a random UUID v4 string; ids are made in Go so both stores share them. +func newID() string { + b := make([]byte, 16) + _, _ = rand.Read(b) // crypto/rand does not fail on supported platforms + b[6] = b[6]&0x0f | 0x40 + b[8] = b[8]&0x3f | 0x80 + return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]) +} + +type memState struct { + feeStructures map[string]FeeStructure + invoices map[string]Invoice + payments map[string]Payment + allocations []Allocation + ledger []LedgerEntry + receipts map[string]Receipt // keyed by payment id + receiptSeq map[string]int64 // keyed by school id + unmatched map[string]Unmatched + outbox []OutboxEvent + events map[string]WebhookEvent +} + +func newMemState() *memState { + return &memState{ + feeStructures: map[string]FeeStructure{}, invoices: map[string]Invoice{}, payments: map[string]Payment{}, + receipts: map[string]Receipt{}, receiptSeq: map[string]int64{}, unmatched: map[string]Unmatched{}, + events: map[string]WebhookEvent{}, + } +} + +// clone copies the state; rows are values and their slices are never mutated in place. +func (s *memState) clone() *memState { + return &memState{ + feeStructures: maps.Clone(s.feeStructures), invoices: maps.Clone(s.invoices), payments: maps.Clone(s.payments), + allocations: slices.Clone(s.allocations), ledger: slices.Clone(s.ledger), receipts: maps.Clone(s.receipts), + receiptSeq: maps.Clone(s.receiptSeq), unmatched: maps.Clone(s.unmatched), outbox: slices.Clone(s.outbox), + events: maps.Clone(s.events), + } +} + +// MemoryStore is an in-process Store for tests and local runs; a transaction works on a copy and commits by swap. +type MemoryStore struct { + mu sync.Mutex + state *memState +} + +// NewMemoryStore returns an empty MemoryStore. +func NewMemoryStore() *MemoryStore { return &MemoryStore{state: newMemState()} } + +// InTx serialises transactions, so a failing fn leaves the committed state untouched. +func (m *MemoryStore) InTx(_ context.Context, schoolID string, fn func(Repo) error) error { + m.mu.Lock() + defer m.mu.Unlock() + work := m.state.clone() + if err := fn(&memRepo{s: work, school: schoolID}); err != nil { + return err + } + m.state = work + return nil +} + +// PaymentSchool scans payments for the provider transaction. +func (m *MemoryStore) PaymentSchool(_ context.Context, rail, txnID string) (string, bool, error) { + m.mu.Lock() + defer m.mu.Unlock() + for _, p := range m.state.payments { + if p.Rail == rail && p.ProviderTransactionID == txnID && txnID != "" { + return p.SchoolID, true, nil + } + } + return "", false, nil +} + +// RecordOrphanEvent stores a school-less webhook once. +func (m *MemoryStore) RecordOrphanEvent(_ context.Context, ev WebhookEvent) (bool, error) { + m.mu.Lock() + defer m.mu.Unlock() + key := ev.Provider + "\x00" + ev.ExternalEventID + if _, ok := m.state.events[key]; ok { + return false, nil + } + m.state.events[key] = ev + return true, nil +} + +// EventCount returns how many webhook events are stored. +func (m *MemoryStore) EventCount() int { + m.mu.Lock() + defer m.mu.Unlock() + return len(m.state.events) +} + +type memRepo struct { + s *memState + school string +} + +func (r *memRepo) InsertFeeStructure(_ context.Context, fs FeeStructure) error { + for _, e := range r.s.feeStructures { + if e.SchoolID == r.school && e.Term == fs.Term && e.Level == fs.Level && e.Name == fs.Name { + return ErrDuplicate + } + } + fs.SchoolID = r.school + r.s.feeStructures[fs.ID] = fs + return nil +} + +func (r *memRepo) GetFeeStructure(_ context.Context, id string) (*FeeStructure, error) { + fs, ok := r.s.feeStructures[id] + if !ok || fs.SchoolID != r.school { + return nil, nil + } + return &fs, nil +} + +func (r *memRepo) ListFeeStructures(_ context.Context, term, level, after string, limit int) ([]FeeStructure, error) { + var out []FeeStructure + for _, fs := range r.s.feeStructures { + if fs.SchoolID == r.school && (term == "" || fs.Term == term) && (level == "" || fs.Level == level) && fs.ID > after { + out = append(out, fs) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) + return out[:min(limit, len(out))], nil +} + +func (r *memRepo) InsertInvoice(_ context.Context, inv Invoice) error { + if inv.FeeStructureID != "" { + for _, e := range r.s.invoices { + if e.SchoolID == r.school && e.StudentID == inv.StudentID && e.Term == inv.Term && e.FeeStructureID == inv.FeeStructureID { + return ErrDuplicate + } + } + } + inv.SchoolID = r.school + r.s.invoices[inv.ID] = inv + return nil +} + +func (r *memRepo) paid(lineID string) int64 { + var sum int64 + for _, a := range r.s.allocations { + if a.SchoolID == r.school && a.TargetType == TargetInvoiceLine && a.TargetID == lineID { + sum += a.AmountMinor + } + } + return sum +} + +func (r *memRepo) balances(studentID, term string, ids []string) []LineBalance { + var out []LineBalance + for _, inv := range r.s.invoices { + if inv.SchoolID != r.school || inv.StudentID != studentID || (term != "" && inv.Term != term) { + continue + } + for _, l := range inv.Lines { + if ids != nil && !slices.Contains(ids, l.ID) { + continue + } + out = append(out, LineBalance{InvoiceLine: l, StudentID: inv.StudentID, Term: inv.Term, InvoiceStatus: inv.Status, PaidMinor: r.paid(l.ID)}) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) + return out +} + +func (r *memRepo) LinesForUpdate(_ context.Context, studentID string, ids []string) ([]LineBalance, error) { + return r.balances(studentID, "", append([]string{}, ids...)), nil +} + +func (r *memRepo) RefreshInvoiceStatuses(_ context.Context, invoiceIDs []string) error { + for _, id := range invoiceIDs { + inv, ok := r.s.invoices[id] + if !ok || inv.SchoolID != r.school { + continue + } + var paid int64 + for _, l := range inv.Lines { + paid += r.paid(l.ID) + } + inv.Status = invoiceStatus(paid, inv.TotalMinor) + r.s.invoices[id] = inv + } + return nil +} + +func invoiceStatus(paid, total int64) string { + switch { + case paid >= total: + return InvoicePaid + case paid > 0: + return InvoicePartial + default: + return InvoiceIssued + } +} + +func (r *memRepo) StatementLines(_ context.Context, studentID, term string, limit int) ([]LineBalance, error) { + out := r.balances(studentID, term, nil) + return out[:min(limit, len(out))], nil +} + +func (r *memRepo) InsertPaymentIfNew(_ context.Context, p Payment) (bool, error) { + for _, e := range r.s.payments { + if e.SchoolID == r.school && e.IdempotencyKey == p.IdempotencyKey { + return false, nil + } + } + p.SchoolID = r.school + r.s.payments[p.ID] = p + return true, nil +} + +func (r *memRepo) GetPayment(_ context.Context, id string) (*Payment, error) { + p, ok := r.s.payments[id] + if !ok || p.SchoolID != r.school { + return nil, nil + } + return &p, nil +} + +func (r *memRepo) GetPaymentByIdempotencyKey(_ context.Context, key string) (*Payment, error) { + for _, p := range r.s.payments { + if p.SchoolID == r.school && p.IdempotencyKey == key { + return &p, nil + } + } + return nil, nil +} + +func (r *memRepo) GetPaymentByProviderTxn(_ context.Context, rail, txnID string) (*Payment, error) { + for _, p := range r.s.payments { + if p.SchoolID == r.school && p.Rail == rail && p.ProviderTransactionID == txnID { + return &p, nil + } + } + return nil, nil +} + +func (r *memRepo) UpdatePayment(_ context.Context, p Payment) error { + old, ok := r.s.payments[p.ID] + if !ok || old.SchoolID != r.school { + return fmt.Errorf("payment %s not found", p.ID) + } + if p.ProviderTransactionID != "" { + for _, e := range r.s.payments { + if e.ID != p.ID && e.Rail == p.Rail && e.ProviderTransactionID == p.ProviderTransactionID { + return ErrDuplicate + } + } + } + p.SchoolID = r.school + r.s.payments[p.ID] = p + return nil +} + +func (r *memRepo) ListPaymentsByStudent(_ context.Context, studentID string, limit int) ([]PaymentSummary, error) { + var out []PaymentSummary + for _, p := range r.s.payments { + if p.SchoolID != r.school || p.StudentID != studentID { + continue + } + out = append(out, PaymentSummary{ID: p.ID, Rail: p.Rail, AmountMinor: p.AmountMinor, Status: p.Status, + FailureCode: p.FailureCode, ReceiptNumber: r.s.receipts[p.ID].Number, CreatedAt: p.CreatedAt}) + } + sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt.After(out[j].CreatedAt) }) + return out[:min(limit, len(out))], nil +} + +func (r *memRepo) InsertAllocations(_ context.Context, allocs []Allocation) error { + for _, a := range allocs { + a.SchoolID = r.school + r.s.allocations = append(r.s.allocations, a) + } + return nil +} + +func (r *memRepo) CreditAllocatedMinor(_ context.Context, studentID string) (int64, error) { + var sum int64 + for _, a := range r.s.allocations { + if a.SchoolID == r.school && a.TargetType == TargetStudentCredit && a.TargetID == studentID { + sum += a.AmountMinor + } + } + return sum, nil +} + +func (r *memRepo) InsertLedgerEntry(_ context.Context, e LedgerEntry) error { + e.SchoolID = r.school + r.s.ledger = append(r.s.ledger, e) + return nil +} + +func (r *memRepo) LedgerBalance(_ context.Context, account, studentID string) (int64, error) { + var sum int64 + for _, e := range r.s.ledger { + if e.SchoolID == r.school && e.Account == account && e.StudentID == studentID { + sum += e.AmountMinor + } + } + return sum, nil +} + +func (r *memRepo) NextReceiptNumber(_ context.Context) (string, error) { + r.s.receiptSeq[r.school]++ + return fmt.Sprintf("RCT-%06d", r.s.receiptSeq[r.school]), nil +} + +func (r *memRepo) InsertReceipt(_ context.Context, rc Receipt) error { + rc.SchoolID = r.school + r.s.receipts[rc.PaymentID] = rc + return nil +} + +func (r *memRepo) InsertUnmatched(_ context.Context, u Unmatched) error { + u.SchoolID = r.school + r.s.unmatched[u.PaymentID] = u + return nil +} + +func (r *memRepo) GetUnmatchedForUpdate(_ context.Context, paymentID string) (*Unmatched, error) { + u, ok := r.s.unmatched[paymentID] + if !ok || u.SchoolID != r.school { + return nil, nil + } + return &u, nil +} + +func (r *memRepo) MarkUnmatchedResolved(_ context.Context, paymentID, resolvedBy string, at time.Time) error { + u, ok := r.s.unmatched[paymentID] + if !ok || u.SchoolID != r.school { + return fmt.Errorf("unmatched %s not found", paymentID) + } + u.ResolvedBy, u.ResolvedAt = resolvedBy, &at + r.s.unmatched[paymentID] = u + return nil +} + +func (r *memRepo) ListUnmatched(_ context.Context, includeResolved bool, after string, limit int) ([]UnmatchedView, error) { + var out []UnmatchedView + for _, u := range r.s.unmatched { + if u.SchoolID != r.school || u.PaymentID <= after || (!includeResolved && u.ResolvedAt != nil) { + continue + } + p := r.s.payments[u.PaymentID] + out = append(out, UnmatchedView{PaymentID: u.PaymentID, Reason: u.Reason, Rail: p.Rail, AmountMinor: p.AmountMinor, + StudentID: p.StudentID, ProviderTransactionID: p.ProviderTransactionID, CreatedAt: u.CreatedAt, + ResolvedBy: u.ResolvedBy, ResolvedAt: u.ResolvedAt}) + } + sort.Slice(out, func(i, j int) bool { return out[i].PaymentID < out[j].PaymentID }) + return out[:min(limit, len(out))], nil +} + +func (r *memRepo) InsertOutbox(_ context.Context, e OutboxEvent) error { + e.SchoolID = r.school + r.s.outbox = append(r.s.outbox, e) + return nil +} + +func (r *memRepo) InsertWebhookEvent(_ context.Context, ev WebhookEvent) (bool, error) { + key := ev.Provider + "\x00" + ev.ExternalEventID + if _, ok := r.s.events[key]; ok { + return false, nil + } + r.s.events[key] = ev + return true, nil +} + +func (r *memRepo) Reconciliation(_ context.Context, from, to time.Time, after string, limit int) ([]ReconRow, error) { + var out []ReconRow + for _, p := range r.s.payments { + if p.SchoolID != r.school || p.ID <= after || p.CreatedAt.Before(from) || !p.CreatedAt.Before(to) { + continue + } + row := ReconRow{PaymentID: p.ID, ReceiptNumber: r.s.receipts[p.ID].Number, StudentID: p.StudentID, Rail: p.Rail, + ProviderTransactionID: p.ProviderTransactionID, Status: p.Status, AmountMinor: p.AmountMinor, CreatedAt: p.CreatedAt} + for _, a := range r.s.allocations { + if a.PaymentID != p.ID { + continue + } + if a.TargetType == TargetInvoiceLine { + row.AllocatedMinor += a.AmountMinor + } else { + row.CreditMinor += a.AmountMinor + } + } + out = append(out, row) + } + sort.Slice(out, func(i, j int) bool { return out[i].PaymentID < out[j].PaymentID }) + return out[:min(limit, len(out))], nil +} + +// isUUID reports whether s looks like a canonical UUID; used at the gate and for path params. +func isUUID(s string) bool { + if len(s) != 36 { + return false + } + for i, c := range strings.ToLower(s) { + switch { + case i == 8 || i == 13 || i == 18 || i == 23: + if c != '-' { + return false + } + case !(c >= '0' && c <= '9') && !(c >= 'a' && c <= 'f'): + return false + } + } + return true +} diff --git a/services/payments/internal/fees/payments.go b/services/payments/internal/fees/payments.go new file mode 100644 index 0000000..82a72c1 --- /dev/null +++ b/services/payments/internal/fees/payments.go @@ -0,0 +1,320 @@ +package fees + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "slices" + "time" + + kit "github.com/stackable/kyfaru-kit" + "github.com/stackable/payments/internal/provider" +) + +// intentError maps provider failures to distinct HTTP errors the UI can show. +func intentError(paymentID string, err error) error { + details := map[string]string{"paymentId": paymentID} + var pe *provider.Error + switch { + case errors.As(err, &pe): + status, msg := 402, "The payment was declined." + switch pe.Code { + case provider.InsufficientFunds: + msg = "Insufficient funds on the payer account." + case provider.WrongPin: + msg = "The PIN entered was wrong." + case provider.UserCancelled: + msg = "The payment was cancelled by the payer." + case provider.Timeout: + status, msg = http.StatusGatewayTimeout, "The payment provider did not answer in time. Please try again." + case provider.RailDown: + status, msg = http.StatusServiceUnavailable, "This payment method is unavailable right now. Please try later." + } + return domainErr(status, "PAYMENT_"+string(pe.Code), msg, details) + case errors.Is(err, provider.ErrInvalidRequest): + return domainErr(http.StatusBadRequest, "PAYMENT_REQUEST_INVALID", "The payment details are not valid for this method.", details) + default: + return kit.Upstream("The payment provider failed. Please try again.") + } +} + +// CreateIntent records a PENDING payment and asks the rail to collect it. +// The same idempotency key returns the same payment and never charges twice; created is false for a replay. +func (s *Service) CreateIntent(ctx context.Context, schoolID, payerUserID string, in IntentInput) (pay Payment, created bool, err error) { + prov, ok := s.providers[in.Rail] + if !ok && (in.Rail == RailCard || in.Rail == RailBank) { + return Payment{}, false, unprocessable("PAYMENT_RAIL_UNSUPPORTED", "This payment method is not supported yet.") + } + now := s.now() + candidate := Payment{ID: newID(), StudentID: in.StudentID, PayerUserID: payerUserID, Rail: in.Rail, AmountMinor: in.AmountMinor, + Status: StatusPending, IdempotencyKey: in.IdempotencyKey, Kind: KindInvoiceLine, InvoiceLineIDs: in.InvoiceLineIDs, + PayerPhone: in.PayerPhone, CreatedAt: now, UpdatedAt: now} + err = s.store.InTx(ctx, schoolID, func(r Repo) error { + if existing, err := r.GetPaymentByIdempotencyKey(ctx, in.IdempotencyKey); err != nil || existing != nil { + return s.replay(existing, in, &pay, err) + } + if err := checkOutstanding(ctx, r, in); err != nil { + return err + } + inserted, err := r.InsertPaymentIfNew(ctx, candidate) + if err != nil { + return err + } + if !inserted { // a concurrent request with the same key won the race + existing, err := r.GetPaymentByIdempotencyKey(ctx, in.IdempotencyKey) + return s.replay(existing, in, &pay, err) + } + pay, created = candidate, true + return nil + }) + if err != nil || !created { + return pay, false, err + } + if prov == nil { // mpesa or fake is configured off + return s.failIntent(ctx, schoolID, pay, &provider.Error{Code: provider.RailDown, Message: "rail disabled"}) + } + ref, err := prov.Initiate(ctx, provider.Payment{ID: pay.ID, AmountMinor: pay.AmountMinor, PayerPhone: in.PayerPhone, Reference: pay.ID}) + if err != nil { + return s.failIntent(ctx, schoolID, pay, err) + } + pay.ProviderTransactionID = ref.TransactionID + err = s.store.InTx(ctx, schoolID, func(r Repo) error { + pay.UpdatedAt = s.now() + return r.UpdatePayment(ctx, pay) + }) + return pay, true, err +} + +func (s *Service) replay(existing *Payment, in IntentInput, out *Payment, err error) error { + if err != nil { + return err + } + if existing.StudentID != in.StudentID || existing.AmountMinor != in.AmountMinor || existing.Rail != in.Rail || !slices.Equal(existing.InvoiceLineIDs, in.InvoiceLineIDs) { + return conflict("IDEMPOTENCY_KEY_REUSED", "This idempotency key was already used for a different payment.") + } + *out = *existing + return nil +} + +func checkOutstanding(ctx context.Context, r Repo, in IntentInput) error { + lines, err := r.LinesForUpdate(ctx, in.StudentID, in.InvoiceLineIDs) + if err != nil { + return err + } + if len(lines) != len(in.InvoiceLineIDs) { + return notFound("INVOICE_LINE_NOT_FOUND", "One or more invoice lines were not found for this student.") + } + var outstanding int64 + for _, l := range lines { + outstanding += l.Outstanding() + } + if in.AmountMinor > outstanding { + return unprocessable("PAYMENT_AMOUNT_EXCEEDS_OUTSTANDING", "The amount is more than is owed on the chosen lines.") + } + return nil +} + +func (s *Service) failIntent(ctx context.Context, schoolID string, pay Payment, cause error) (Payment, bool, error) { + pay.Status, pay.UpdatedAt = StatusFailed, s.now() + var pe *provider.Error + if errors.As(cause, &pe) { + pay.FailureCode = string(pe.Code) + } + if err := s.store.InTx(ctx, schoolID, func(r Repo) error { return r.UpdatePayment(ctx, pay) }); err != nil { + return pay, true, fmt.Errorf("mark payment failed: %w", err) + } + return pay, true, intentError(pay.ID, cause) +} + +// WebhookResult tells the provider what happened; repeats report Duplicate and change nothing. +type WebhookResult struct { + Duplicate bool `json:"duplicate"` + Outcome string `json:"outcome"` +} + +// Webhook outcomes. +const ( + OutcomeConfirmed = "CONFIRMED" + OutcomeFailed = "FAILED" + OutcomeUnmatched = "UNMATCHED" + OutcomeIgnored = "IGNORED" + OutcomeRecorded = "RECORDED" +) + +// ReceiveWebhook verifies a callback, then applies it exactly once in one transaction. +// Event, allocations, ledger credit, receipt and outbox row commit together or not at all. +func (s *Service) ReceiveWebhook(ctx context.Context, providerName string, headers http.Header, body []byte) (WebhookResult, error) { + prov, ok := s.providers[providerName] + if !ok { + return WebhookResult{}, notFound("RESOURCE_NOT_FOUND", "Unknown payment provider.") + } + ev, err := prov.VerifyWebhook(headers, body) + switch { + case errors.Is(err, provider.ErrBadSignature): + return WebhookResult{}, kit.Unauthorized("Webhook signature is not valid.") + case err != nil: + return WebhookResult{}, domainErr(http.StatusBadRequest, "WEBHOOK_INVALID", "Webhook body is not valid.", nil) + } + we := WebhookEvent{Provider: providerName, ExternalEventID: ev.EventID, Payload: json.RawMessage(body)} + schoolID, found, err := s.store.PaymentSchool(ctx, providerName, ev.TransactionID) + if err != nil { + return WebhookResult{}, err + } + if !found { + schoolID = ev.SchoolHint + } + if schoolID == "" || !isUUID(schoolID) { + inserted, err := s.store.RecordOrphanEvent(ctx, we) + return WebhookResult{Duplicate: !inserted, Outcome: OutcomeRecorded}, err + } + res := WebhookResult{Outcome: OutcomeIgnored} + err = s.store.InTx(ctx, schoolID, func(r Repo) error { + res = WebhookResult{Outcome: OutcomeIgnored} + inserted, err := r.InsertWebhookEvent(ctx, we) + if err != nil || !inserted { + res.Duplicate = err == nil + return err + } + pay, err := r.GetPaymentByProviderTxn(ctx, providerName, ev.TransactionID) + if err != nil { + return err + } + res.Outcome, err = s.applyEvent(ctx, r, providerName, pay, ev) + return err + }) + return res, err +} + +func (s *Service) applyEvent(ctx context.Context, r Repo, rail string, pay *Payment, ev provider.Event) (string, error) { + now := s.now() + if pay == nil { + if !ev.Succeeded || ev.AmountMinor <= 0 { + return OutcomeIgnored, nil // nothing was received, so there is nothing to queue + } + pay = &Payment{ID: newID(), StudentID: ev.StudentHint, Rail: rail, ProviderTransactionID: ev.TransactionID, AmountMinor: ev.AmountMinor, + Status: StatusPending, IdempotencyKey: "webhook:" + rail + ":" + ev.EventID, Kind: KindInvoiceLine, CreatedAt: now, UpdatedAt: now} + if _, err := r.InsertPaymentIfNew(ctx, *pay); err != nil { + return "", err + } + return OutcomeUnmatched, s.park(ctx, r, pay, ReasonNoMatchingPayment, now) + } + if pay.Status != StatusPending { + return OutcomeIgnored, nil // a late or repeated event for a finished payment changes nothing + } + if !ev.Succeeded { + pay.Status, pay.FailureCode, pay.UpdatedAt = StatusFailed, string(ev.FailureCode), now + return OutcomeFailed, r.UpdatePayment(ctx, *pay) + } + if ev.AmountMinor != 0 && ev.AmountMinor != pay.AmountMinor { + pay.AmountMinor = ev.AmountMinor // the rail reports what really moved + return OutcomeUnmatched, s.park(ctx, r, pay, ReasonAmountMismatch, now) + } + return OutcomeConfirmed, s.confirm(ctx, r, pay, pay.StudentID, pay.InvoiceLineIDs, now) +} + +// park books received money to the suspense account and queues it for a human. +func (s *Service) park(ctx context.Context, r Repo, pay *Payment, reason string, now time.Time) error { + pay.Status, pay.UpdatedAt = StatusUnmatched, now + if err := r.UpdatePayment(ctx, *pay); err != nil { + return err + } + e := LedgerEntry{ID: newID(), Account: AccountSuspense, Kind: LedgerCreditPayment, AmountMinor: -pay.AmountMinor, + RefType: "payment", RefID: pay.ID, CreatedAt: now} + if err := r.InsertLedgerEntry(ctx, e); err != nil { + return err + } + return r.InsertUnmatched(ctx, Unmatched{ID: newID(), PaymentID: pay.ID, Reason: reason, CreatedAt: now}) +} + +// confirm allocates a payment to lines, credits the ledger, issues the receipt and queues payment.confirmed. +// Lines already paid by another payment are skipped, and any remainder becomes student credit. +func (s *Service) confirm(ctx context.Context, r Repo, pay *Payment, studentID string, lineIDs []string, now time.Time) error { + lines, err := r.LinesForUpdate(ctx, studentID, lineIDs) + if err != nil { + return err + } + byID := map[string]LineBalance{} + for _, l := range lines { + byID[l.ID] = l + } + remaining := pay.AmountMinor + var allocs []Allocation + var invoiceIDs []string + for _, id := range lineIDs { + l, ok := byID[id] + take := min(remaining, l.Outstanding()) + if !ok || take <= 0 { + continue + } + allocs = append(allocs, Allocation{ID: newID(), PaymentID: pay.ID, TargetType: TargetInvoiceLine, TargetID: id, AmountMinor: take, CreatedAt: now}) + invoiceIDs = append(invoiceIDs, l.InvoiceID) + remaining -= take + } + if remaining > 0 { + allocs = append(allocs, Allocation{ID: newID(), PaymentID: pay.ID, TargetType: TargetStudentCredit, TargetID: studentID, AmountMinor: remaining, CreatedAt: now}) + } + if err := r.InsertAllocations(ctx, allocs); err != nil { + return err + } + credit := LedgerEntry{ID: newID(), StudentID: studentID, Account: AccountReceivable, Kind: LedgerCreditPayment, + AmountMinor: -pay.AmountMinor, RefType: "payment", RefID: pay.ID, CreatedAt: now} + if err := r.InsertLedgerEntry(ctx, credit); err != nil { + return err + } + if err := r.RefreshInvoiceStatuses(ctx, invoiceIDs); err != nil { + return err + } + pay.StudentID, pay.Status, pay.UpdatedAt = studentID, StatusConfirmed, now + if err := r.UpdatePayment(ctx, *pay); err != nil { + return err + } + number, err := r.NextReceiptNumber(ctx) + if err != nil { + return err + } + if err := r.InsertReceipt(ctx, Receipt{ID: newID(), PaymentID: pay.ID, Number: number, IssuedAt: now}); err != nil { + return err + } + payload, err := json.Marshal(map[string]any{"paymentId": pay.ID, "studentId": studentID, "amountMinor": pay.AmountMinor, + "receiptNumber": number, "creditMinor": remaining}) + if err != nil { + return fmt.Errorf("encode outbox payload: %w", err) + } + return r.InsertOutbox(ctx, OutboxEvent{ID: newID(), Type: "payment.confirmed", AggregateID: pay.ID, Payload: payload, CreatedAt: now}) +} + +// ResolveUnmatched assigns queued money to a student and confirms the payment. +// It clears the suspense account, then applies the money like a normal confirmation; a second call conflicts. +func (s *Service) ResolveUnmatched(ctx context.Context, schoolID, actor, paymentID string, in ResolveInput) (Payment, error) { + var out Payment + err := s.store.InTx(ctx, schoolID, func(r Repo) error { + u, err := r.GetUnmatchedForUpdate(ctx, paymentID) + if err != nil { + return err + } + if u == nil { + return notFound("UNMATCHED_NOT_FOUND", "Unmatched payment not found.") + } + pay, err := r.GetPayment(ctx, paymentID) + if err != nil { + return err + } + if u.ResolvedAt != nil || pay == nil || pay.Status != StatusUnmatched { + return conflict("PAYMENT_ALREADY_RESOLVED", "This payment has already been resolved.") + } + now := s.now() + clearing := LedgerEntry{ID: newID(), Account: AccountSuspense, Kind: LedgerAdjustment, AmountMinor: pay.AmountMinor, + RefType: "payment", RefID: pay.ID, CreatedAt: now} + if err := r.InsertLedgerEntry(ctx, clearing); err != nil { + return err + } + if err := s.confirm(ctx, r, pay, in.StudentID, in.InvoiceLineIDs, now); err != nil { + return err + } + out = *pay + return r.MarkUnmatchedResolved(ctx, paymentID, actor, now) + }) + return out, err +} diff --git a/services/payments/internal/fees/pg_store.go b/services/payments/internal/fees/pg_store.go new file mode 100644 index 0000000..5cb0499 --- /dev/null +++ b/services/payments/internal/fees/pg_store.go @@ -0,0 +1,434 @@ +package fees + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgxpool" + kit "github.com/stackable/kyfaru-kit" +) + +const uniqueViolation = "23505" + +// PgStore is the Postgres Store. It is written to match the migrations but has not been run against a real database. +type PgStore struct { + pool *pgxpool.Pool +} + +// NewPgStore wraps a pool. +func NewPgStore(pool *pgxpool.Pool) *PgStore { return &PgStore{pool: pool} } + +// InTx sets app.school_id for RLS and commits only if fn succeeds. +func (s *PgStore) InTx(ctx context.Context, schoolID string, fn func(Repo) error) error { + return kit.WithSchool(ctx, s.pool, schoolID, func(ctx context.Context, tx pgx.Tx) error { + return fn(&pgRepo{tx: tx, school: schoolID}) + }) +} + +// PaymentSchool uses the security-definer function because RLS hides other schools' rows. +func (s *PgStore) PaymentSchool(ctx context.Context, rail, txnID string) (string, bool, error) { + var school *string + if err := s.pool.QueryRow(ctx, `select payments.payment_school($1, $2)::text`, rail, txnID).Scan(&school); err != nil { + return "", false, fmt.Errorf("find payment school: %w", err) + } + if school == nil { + return "", false, nil + } + return *school, true, nil +} + +// RecordOrphanEvent stores a school-less webhook once. +func (s *PgStore) RecordOrphanEvent(ctx context.Context, ev WebhookEvent) (bool, error) { + return insertWebhookEvent(ctx, s.pool, ev) +} + +type execer interface { + Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error) +} + +func insertWebhookEvent(ctx context.Context, db execer, ev WebhookEvent) (bool, error) { + tag, err := db.Exec(ctx, `insert into payments.webhook_events (provider, external_event_id, payload) values ($1, $2, $3) + on conflict (provider, external_event_id) do nothing`, ev.Provider, ev.ExternalEventID, []byte(ev.Payload)) + if err != nil { + return false, fmt.Errorf("insert webhook event: %w", err) + } + return tag.RowsAffected() == 1, nil +} + +type pgRepo struct { + tx pgx.Tx + school string +} + +// nullIf stores an empty string as NULL. +func nullIf(s string) any { + if s == "" { + return nil + } + return s +} + +func mapErr(op string, err error) error { + if err == nil { + return nil + } + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) && pgErr.Code == uniqueViolation { + return ErrDuplicate + } + return fmt.Errorf("%s: %w", op, err) +} + +func (r *pgRepo) InsertFeeStructure(ctx context.Context, fs FeeStructure) error { + _, err := r.tx.Exec(ctx, `insert into payments.fee_structures (id, school_id, term, level, name, created_at) values ($1,$2,$3,$4,$5,$6)`, + fs.ID, r.school, fs.Term, fs.Level, fs.Name, fs.CreatedAt) + if err != nil { + return mapErr("insert fee structure", err) + } + for _, it := range fs.Items { + if _, err := r.tx.Exec(ctx, `insert into payments.fee_items (id, school_id, fee_structure_id, name, amount_minor, mandatory) values ($1,$2,$3,$4,$5,$6)`, + it.ID, r.school, fs.ID, it.Name, it.AmountMinor, it.Mandatory); err != nil { + return mapErr("insert fee item", err) + } + } + return nil +} + +func (r *pgRepo) loadItems(ctx context.Context, ids []string) (map[string][]FeeItem, error) { + rows, err := r.tx.Query(ctx, `select fee_structure_id::text, id::text, name, amount_minor, mandatory from payments.fee_items + where fee_structure_id = any($1::text[]::uuid[]) order by id`, ids) + if err != nil { + return nil, fmt.Errorf("load fee items: %w", err) + } + defer rows.Close() + out := map[string][]FeeItem{} + for rows.Next() { + var sid string + var it FeeItem + if err := rows.Scan(&sid, &it.ID, &it.Name, &it.AmountMinor, &it.Mandatory); err != nil { + return nil, fmt.Errorf("scan fee item: %w", err) + } + out[sid] = append(out[sid], it) + } + return out, rows.Err() +} + +func (r *pgRepo) queryStructures(ctx context.Context, sql string, args ...any) ([]FeeStructure, error) { + rows, err := r.tx.Query(ctx, sql, args...) + if err != nil { + return nil, fmt.Errorf("query fee structures: %w", err) + } + var out []FeeStructure + var ids []string + for rows.Next() { + fs := FeeStructure{SchoolID: r.school} + if err := rows.Scan(&fs.ID, &fs.Term, &fs.Level, &fs.Name, &fs.CreatedAt); err != nil { + rows.Close() + return nil, fmt.Errorf("scan fee structure: %w", err) + } + out = append(out, fs) + ids = append(ids, fs.ID) + } + rows.Close() + if err := rows.Err(); err != nil || len(out) == 0 { + return out, err + } + items, err := r.loadItems(ctx, ids) + if err != nil { + return nil, err + } + for i := range out { + out[i].Items = items[out[i].ID] + } + return out, nil +} + +func (r *pgRepo) GetFeeStructure(ctx context.Context, id string) (*FeeStructure, error) { + list, err := r.queryStructures(ctx, `select id::text, term, level, name, created_at from payments.fee_structures where school_id = $1 and id = $2`, r.school, id) + if err != nil || len(list) == 0 { + return nil, err + } + return &list[0], nil +} + +func (r *pgRepo) ListFeeStructures(ctx context.Context, term, level, after string, limit int) ([]FeeStructure, error) { + return r.queryStructures(ctx, `select id::text, term, level, name, created_at from payments.fee_structures + where school_id = $1 and ($2 = '' or term = $2) and ($3 = '' or level = $3) and id > coalesce(nullif($4::text, '')::uuid, '00000000-0000-0000-0000-000000000000'::uuid) + order by id limit $5`, r.school, term, level, after, limit) +} + +func (r *pgRepo) InsertInvoice(ctx context.Context, inv Invoice) error { + _, err := r.tx.Exec(ctx, `insert into payments.invoices (id, school_id, student_id, fee_structure_id, term, status, issued_at, total_minor) + values ($1,$2,$3,$4,$5,$6,$7,$8)`, inv.ID, r.school, inv.StudentID, nullIf(inv.FeeStructureID), inv.Term, inv.Status, inv.IssuedAt, inv.TotalMinor) + if err != nil { + return mapErr("insert invoice", err) + } + for _, l := range inv.Lines { + if _, err := r.tx.Exec(ctx, `insert into payments.invoice_lines (id, school_id, invoice_id, fee_item_id, description, amount_minor) + values ($1,$2,$3,$4,$5,$6)`, l.ID, r.school, inv.ID, nullIf(l.FeeItemID), l.Description, l.AmountMinor); err != nil { + return mapErr("insert invoice line", err) + } + } + return nil +} + +const lineBalanceSQL = `select l.id::text, l.invoice_id::text, coalesce(l.fee_item_id::text, ''), l.description, l.amount_minor, + i.student_id::text, i.term, i.status, + coalesce((select sum(a.amount_minor) from payments.payment_allocations a + where a.target_type = 'INVOICE_LINE' and a.target_id = l.id), 0)::bigint + from payments.invoice_lines l join payments.invoices i on i.id = l.invoice_id` + +func (r *pgRepo) scanBalances(ctx context.Context, sql string, args ...any) ([]LineBalance, error) { + rows, err := r.tx.Query(ctx, sql, args...) + if err != nil { + return nil, fmt.Errorf("query line balances: %w", err) + } + defer rows.Close() + var out []LineBalance + for rows.Next() { + var l LineBalance + if err := rows.Scan(&l.ID, &l.InvoiceID, &l.FeeItemID, &l.Description, &l.AmountMinor, &l.StudentID, &l.Term, &l.InvoiceStatus, &l.PaidMinor); err != nil { + return nil, fmt.Errorf("scan line balance: %w", err) + } + out = append(out, l) + } + return out, rows.Err() +} + +// LinesForUpdate locks the lines first so two confirmations for the same line queue up. +func (r *pgRepo) LinesForUpdate(ctx context.Context, studentID string, ids []string) ([]LineBalance, error) { + if _, err := r.tx.Exec(ctx, `select l.id from payments.invoice_lines l join payments.invoices i on i.id = l.invoice_id + where l.school_id = $1 and i.student_id = $2 and l.id = any($3::text[]::uuid[]) order by l.id for update of l`, r.school, studentID, ids); err != nil { + return nil, fmt.Errorf("lock invoice lines: %w", err) + } + return r.scanBalances(ctx, lineBalanceSQL+` where l.school_id = $1 and i.student_id = $2 and l.id = any($3::text[]::uuid[]) order by l.id`, r.school, studentID, ids) +} + +func (r *pgRepo) RefreshInvoiceStatuses(ctx context.Context, invoiceIDs []string) error { + _, err := r.tx.Exec(ctx, `update payments.invoices i set updated_at = now(), status = case + when p.paid >= i.total_minor then 'PAID' when p.paid > 0 then 'PARTIAL' else 'ISSUED' end + from (select l.invoice_id, coalesce(sum(a.amount_minor), 0) as paid + from payments.invoice_lines l + left join payments.payment_allocations a on a.target_type = 'INVOICE_LINE' and a.target_id = l.id + where l.invoice_id = any($2::text[]::uuid[]) group by l.invoice_id) p + where i.id = p.invoice_id and i.school_id = $1`, r.school, invoiceIDs) + if err != nil { + return fmt.Errorf("refresh invoice statuses: %w", err) + } + return nil +} + +func (r *pgRepo) StatementLines(ctx context.Context, studentID, term string, limit int) ([]LineBalance, error) { + return r.scanBalances(ctx, lineBalanceSQL+` where l.school_id = $1 and i.student_id = $2 and ($3 = '' or i.term = $3) order by l.id limit $4`, + r.school, studentID, term, limit) +} + +const paymentCols = `id::text, coalesce(student_id::text, ''), coalesce(payer_user_id, ''), rail, coalesce(provider_transaction_id, ''), + amount_minor, status, coalesce(failure_code, ''), idempotency_key, kind, array(select x::text from unnest(invoice_line_ids) x), created_at, updated_at` + +func scanPayment(row pgx.Row, school string) (*Payment, error) { + p := Payment{SchoolID: school} + err := row.Scan(&p.ID, &p.StudentID, &p.PayerUserID, &p.Rail, &p.ProviderTransactionID, &p.AmountMinor, &p.Status, + &p.FailureCode, &p.IdempotencyKey, &p.Kind, &p.InvoiceLineIDs, &p.CreatedAt, &p.UpdatedAt) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("scan payment: %w", err) + } + return &p, nil +} + +func (r *pgRepo) InsertPaymentIfNew(ctx context.Context, p Payment) (bool, error) { + tag, err := r.tx.Exec(ctx, `insert into payments.payments (id, school_id, student_id, payer_user_id, rail, provider_transaction_id, amount_minor, + status, failure_code, idempotency_key, kind, invoice_line_ids, created_at, updated_at) + values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12::text[]::uuid[],$13,$14) + on conflict (school_id, idempotency_key) do nothing`, + p.ID, r.school, nullIf(p.StudentID), nullIf(p.PayerUserID), p.Rail, nullIf(p.ProviderTransactionID), p.AmountMinor, + p.Status, nullIf(p.FailureCode), p.IdempotencyKey, p.Kind, nonNil(p.InvoiceLineIDs), p.CreatedAt, p.UpdatedAt) + if err != nil { + return false, mapErr("insert payment", err) + } + return tag.RowsAffected() == 1, nil +} + +func (r *pgRepo) GetPayment(ctx context.Context, id string) (*Payment, error) { + return scanPayment(r.tx.QueryRow(ctx, `select `+paymentCols+` from payments.payments where school_id = $1 and id = $2`, r.school, id), r.school) +} + +func (r *pgRepo) GetPaymentByIdempotencyKey(ctx context.Context, key string) (*Payment, error) { + return scanPayment(r.tx.QueryRow(ctx, `select `+paymentCols+` from payments.payments where school_id = $1 and idempotency_key = $2`, r.school, key), r.school) +} + +// GetPaymentByProviderTxn locks the row so a repeated webhook cannot confirm twice. +func (r *pgRepo) GetPaymentByProviderTxn(ctx context.Context, rail, txnID string) (*Payment, error) { + return scanPayment(r.tx.QueryRow(ctx, `select `+paymentCols+` from payments.payments + where school_id = $1 and rail = $2 and provider_transaction_id = $3 for update`, r.school, rail, txnID), r.school) +} + +func (r *pgRepo) UpdatePayment(ctx context.Context, p Payment) error { + _, err := r.tx.Exec(ctx, `update payments.payments set student_id = $3, provider_transaction_id = $4, amount_minor = $5, status = $6, + failure_code = $7, updated_at = $8 where school_id = $1 and id = $2`, + r.school, p.ID, nullIf(p.StudentID), nullIf(p.ProviderTransactionID), p.AmountMinor, p.Status, nullIf(p.FailureCode), p.UpdatedAt) + return mapErr("update payment", err) +} + +func (r *pgRepo) ListPaymentsByStudent(ctx context.Context, studentID string, limit int) ([]PaymentSummary, error) { + rows, err := r.tx.Query(ctx, `select p.id::text, p.rail, p.amount_minor, p.status, coalesce(p.failure_code, ''), coalesce(rc.number, ''), p.created_at + from payments.payments p left join payments.receipts rc on rc.payment_id = p.id + where p.school_id = $1 and p.student_id = $2 order by p.created_at desc limit $3`, r.school, studentID, limit) + if err != nil { + return nil, fmt.Errorf("list payments: %w", err) + } + defer rows.Close() + var out []PaymentSummary + for rows.Next() { + var s PaymentSummary + if err := rows.Scan(&s.ID, &s.Rail, &s.AmountMinor, &s.Status, &s.FailureCode, &s.ReceiptNumber, &s.CreatedAt); err != nil { + return nil, fmt.Errorf("scan payment summary: %w", err) + } + out = append(out, s) + } + return out, rows.Err() +} + +func (r *pgRepo) InsertAllocations(ctx context.Context, allocs []Allocation) error { + for _, a := range allocs { + if _, err := r.tx.Exec(ctx, `insert into payments.payment_allocations (id, school_id, payment_id, target_type, target_id, amount_minor, created_at) + values ($1,$2,$3,$4,$5,$6,$7)`, a.ID, r.school, a.PaymentID, a.TargetType, a.TargetID, a.AmountMinor, a.CreatedAt); err != nil { + return mapErr("insert allocation", err) + } + } + return nil +} + +func (r *pgRepo) CreditAllocatedMinor(ctx context.Context, studentID string) (int64, error) { + var sum int64 + err := r.tx.QueryRow(ctx, `select coalesce(sum(amount_minor), 0)::bigint from payments.payment_allocations + where school_id = $1 and target_type = 'STUDENT_CREDIT' and target_id = $2`, r.school, studentID).Scan(&sum) + if err != nil { + return 0, fmt.Errorf("sum credit: %w", err) + } + return sum, nil +} + +func (r *pgRepo) InsertLedgerEntry(ctx context.Context, e LedgerEntry) error { + _, err := r.tx.Exec(ctx, `insert into payments.ledger_entries (id, school_id, student_id, account, kind, amount_minor, ref_type, ref_id, created_at) + values ($1,$2,$3,$4,$5,$6,$7,$8,$9)`, e.ID, r.school, nullIf(e.StudentID), e.Account, e.Kind, e.AmountMinor, e.RefType, e.RefID, e.CreatedAt) + return mapErr("insert ledger entry", err) +} + +func (r *pgRepo) LedgerBalance(ctx context.Context, account, studentID string) (int64, error) { + var sum int64 + err := r.tx.QueryRow(ctx, `select coalesce(sum(amount_minor), 0)::bigint from payments.ledger_entries + where school_id = $1 and account = $2 and student_id is not distinct from $3::uuid`, r.school, account, nullIf(studentID)).Scan(&sum) + if err != nil { + return 0, fmt.Errorf("sum ledger: %w", err) + } + return sum, nil +} + +// NextReceiptNumber bumps a per-school counter; the row lock serialises concurrent confirmations. +func (r *pgRepo) NextReceiptNumber(ctx context.Context) (string, error) { + var n int64 + err := r.tx.QueryRow(ctx, `insert into payments.receipt_counters (school_id, last_number) values ($1, 1) + on conflict (school_id) do update set last_number = payments.receipt_counters.last_number + 1, updated_at = now() + returning last_number`, r.school).Scan(&n) + if err != nil { + return "", fmt.Errorf("next receipt number: %w", err) + } + return fmt.Sprintf("RCT-%06d", n), nil +} + +func (r *pgRepo) InsertReceipt(ctx context.Context, rc Receipt) error { + _, err := r.tx.Exec(ctx, `insert into payments.receipts (id, school_id, payment_id, number, issued_at) values ($1,$2,$3,$4,$5)`, + rc.ID, r.school, rc.PaymentID, rc.Number, rc.IssuedAt) + return mapErr("insert receipt", err) +} + +func (r *pgRepo) InsertUnmatched(ctx context.Context, u Unmatched) error { + _, err := r.tx.Exec(ctx, `insert into payments.unmatched_payments (id, school_id, payment_id, reason, created_at) values ($1,$2,$3,$4,$5)`, + u.ID, r.school, u.PaymentID, u.Reason, u.CreatedAt) + return mapErr("insert unmatched", err) +} + +func (r *pgRepo) GetUnmatchedForUpdate(ctx context.Context, paymentID string) (*Unmatched, error) { + u := Unmatched{SchoolID: r.school} + err := r.tx.QueryRow(ctx, `select id::text, payment_id::text, reason, coalesce(resolved_by, ''), resolved_at, created_at + from payments.unmatched_payments where school_id = $1 and payment_id = $2 for update`, r.school, paymentID). + Scan(&u.ID, &u.PaymentID, &u.Reason, &u.ResolvedBy, &u.ResolvedAt, &u.CreatedAt) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("get unmatched: %w", err) + } + return &u, nil +} + +func (r *pgRepo) MarkUnmatchedResolved(ctx context.Context, paymentID, resolvedBy string, at time.Time) error { + _, err := r.tx.Exec(ctx, `update payments.unmatched_payments set resolved_by = $3, resolved_at = $4, updated_at = $4 + where school_id = $1 and payment_id = $2`, r.school, paymentID, resolvedBy, at) + return mapErr("resolve unmatched", err) +} + +func (r *pgRepo) ListUnmatched(ctx context.Context, includeResolved bool, after string, limit int) ([]UnmatchedView, error) { + rows, err := r.tx.Query(ctx, `select u.payment_id::text, u.reason, p.rail, p.amount_minor, coalesce(p.student_id::text, ''), + coalesce(p.provider_transaction_id, ''), u.created_at, coalesce(u.resolved_by, ''), u.resolved_at + from payments.unmatched_payments u join payments.payments p on p.id = u.payment_id + where u.school_id = $1 and ($2 or u.resolved_at is null) and u.payment_id > coalesce(nullif($3::text, '')::uuid, '00000000-0000-0000-0000-000000000000'::uuid) + order by u.payment_id limit $4`, r.school, includeResolved, after, limit) + if err != nil { + return nil, fmt.Errorf("list unmatched: %w", err) + } + defer rows.Close() + var out []UnmatchedView + for rows.Next() { + var v UnmatchedView + if err := rows.Scan(&v.PaymentID, &v.Reason, &v.Rail, &v.AmountMinor, &v.StudentID, &v.ProviderTransactionID, &v.CreatedAt, &v.ResolvedBy, &v.ResolvedAt); err != nil { + return nil, fmt.Errorf("scan unmatched: %w", err) + } + out = append(out, v) + } + return out, rows.Err() +} + +func (r *pgRepo) InsertOutbox(ctx context.Context, e OutboxEvent) error { + _, err := r.tx.Exec(ctx, `insert into payments.outbox (id, school_id, event_type, aggregate_id, payload, created_at) values ($1,$2,$3,$4,$5,$6)`, + e.ID, r.school, e.Type, e.AggregateID, []byte(e.Payload), e.CreatedAt) + return mapErr("insert outbox", err) +} + +func (r *pgRepo) InsertWebhookEvent(ctx context.Context, ev WebhookEvent) (bool, error) { + return insertWebhookEvent(ctx, r.tx, ev) +} + +func (r *pgRepo) Reconciliation(ctx context.Context, from, to time.Time, after string, limit int) ([]ReconRow, error) { + rows, err := r.tx.Query(ctx, `select p.id::text, coalesce(rc.number, ''), coalesce(p.student_id::text, ''), p.rail, + coalesce(p.provider_transaction_id, ''), p.status, p.amount_minor, + coalesce(sum(a.amount_minor) filter (where a.target_type = 'INVOICE_LINE'), 0)::bigint, + coalesce(sum(a.amount_minor) filter (where a.target_type = 'STUDENT_CREDIT'), 0)::bigint, p.created_at + from payments.payments p + left join payments.receipts rc on rc.payment_id = p.id + left join payments.payment_allocations a on a.payment_id = p.id + where p.school_id = $1 and p.created_at >= $2 and p.created_at < $3 and p.id > coalesce(nullif($4::text, '')::uuid, '00000000-0000-0000-0000-000000000000'::uuid) + group by p.id, rc.number order by p.id limit $5`, r.school, from, to, after, limit) + if err != nil { + return nil, fmt.Errorf("reconciliation: %w", err) + } + defer rows.Close() + var out []ReconRow + for rows.Next() { + var x ReconRow + if err := rows.Scan(&x.PaymentID, &x.ReceiptNumber, &x.StudentID, &x.Rail, &x.ProviderTransactionID, &x.Status, + &x.AmountMinor, &x.AllocatedMinor, &x.CreditMinor, &x.CreatedAt); err != nil { + return nil, fmt.Errorf("scan reconciliation: %w", err) + } + out = append(out, x) + } + return out, rows.Err() +} diff --git a/services/payments/internal/fees/schema.go b/services/payments/internal/fees/schema.go new file mode 100644 index 0000000..2d2d599 --- /dev/null +++ b/services/payments/internal/fees/schema.go @@ -0,0 +1,212 @@ +package fees + +import ( + "net/http" + "slices" + "strings" + "time" + + kit "github.com/stackable/kyfaru-kit" +) + +const ( + maxNameLen = 120 + maxItems = 50 + maxStudentsPerRun = 500 // one invoice run is one transaction, so it is bounded + maxLinesPerCall = 50 + maxKeyLen = 128 + defaultPageSize = 100 + maxPageSize = 500 + maxReconRows = 5000 + statementLineCap = 500 + statementPayCap = 100 +) + +type fieldErrors map[string]string + +func (f fieldErrors) err() error { + if len(f) == 0 { + return nil + } + return kit.Validation("Some fields are invalid.", map[string]string(f)) +} + +func domainErr(status int, code, message string, details any) *kit.AppError { + return &kit.AppError{Status: status, Code: code, Message: message, Details: details} +} + +func conflict(code, message string) *kit.AppError { + return domainErr(http.StatusConflict, code, message, nil) +} + +func notFound(code, message string) *kit.AppError { + return domainErr(http.StatusNotFound, code, message, nil) +} + +func unprocessable(code, message string) *kit.AppError { + return domainErr(http.StatusUnprocessableEntity, code, message, nil) +} + +func checkText(f fieldErrors, name, v string) { + if strings.TrimSpace(v) == "" || len(v) > maxNameLen { + f[name] = "required, at most 120 characters" + } +} + +func checkIDs(f fieldErrors, name string, ids []string, max int) { + if len(ids) > max { + f[name] = "too many ids" + } + seen := map[string]bool{} + for _, id := range ids { + if !isUUID(id) || seen[id] { + f[name] = "must be unique UUIDs" + return + } + seen[id] = true + } +} + +// FeeItemInput is one item of a new fee structure. +type FeeItemInput struct { + Name string `json:"name"` + AmountMinor int64 `json:"amountMinor"` + Mandatory bool `json:"mandatory"` +} + +// FeeStructureInput is the body of POST /v1/fee-structures. +type FeeStructureInput struct { + Name string `json:"name"` + Term string `json:"term"` + Level string `json:"level"` + Items []FeeItemInput `json:"items"` +} + +// Validate rejects malformed input before any business logic runs. +func (in FeeStructureInput) Validate() error { + f := fieldErrors{} + checkText(f, "name", in.Name) + checkText(f, "term", in.Term) + checkText(f, "level", in.Level) + if len(in.Items) == 0 || len(in.Items) > maxItems { + f["items"] = "between 1 and 50 items" + } + for _, it := range in.Items { + if strings.TrimSpace(it.Name) == "" || len(it.Name) > maxNameLen || it.AmountMinor <= 0 { + f["items"] = "each item needs a name and amountMinor greater than 0" + } + } + return f.err() +} + +// LineInput is one custom invoice line. +type LineInput struct { + Description string `json:"description"` + AmountMinor int64 `json:"amountMinor"` + FeeItemID string `json:"feeItemId"` +} + +// IssueInvoicesInput is the body of POST /v1/invoices: a fee structure or custom lines, not both. +type IssueInvoicesInput struct { + StudentIDs []string `json:"studentIds"` + FeeStructureID string `json:"feeStructureId"` + Term string `json:"term"` + Lines []LineInput `json:"lines"` +} + +// Validate rejects malformed input before any business logic runs. +func (in IssueInvoicesInput) Validate() error { + f := fieldErrors{} + if len(in.StudentIDs) == 0 { + f["studentIds"] = "at least one student" + } + checkIDs(f, "studentIds", in.StudentIDs, maxStudentsPerRun) + switch { + case in.FeeStructureID != "" && len(in.Lines) > 0: + f["lines"] = "send feeStructureId or lines, not both" + case in.FeeStructureID != "": + if !isUUID(in.FeeStructureID) { + f["feeStructureId"] = "must be a UUID" + } + case len(in.Lines) == 0 || len(in.Lines) > maxLinesPerCall: + f["lines"] = "feeStructureId or between 1 and 50 lines required" + default: + checkText(f, "term", in.Term) + for _, l := range in.Lines { + if strings.TrimSpace(l.Description) == "" || len(l.Description) > maxNameLen || l.AmountMinor <= 0 || (l.FeeItemID != "" && !isUUID(l.FeeItemID)) { + f["lines"] = "each line needs a description and amountMinor greater than 0" + } + } + } + return f.err() +} + +// IntentInput is the body of POST /v1/payments/intent. +type IntentInput struct { + StudentID string `json:"studentId"` + AmountMinor int64 `json:"amountMinor"` + Rail string `json:"rail"` + InvoiceLineIDs []string `json:"invoiceLineIds"` + IdempotencyKey string `json:"idempotencyKey"` + PayerPhone string `json:"payerPhone"` +} + +// Validate rejects malformed input before any business logic runs. +func (in IntentInput) Validate() error { + f := fieldErrors{} + if !isUUID(in.StudentID) { + f["studentId"] = "must be a UUID" + } + if in.AmountMinor <= 0 { + f["amountMinor"] = "must be greater than 0" + } + if !slices.Contains([]string{RailMpesa, RailCard, RailBank, RailFake}, in.Rail) { + f["rail"] = "must be mpesa, card, bank or fake" + } + if len(in.InvoiceLineIDs) == 0 { + f["invoiceLineIds"] = "choose at least one line" + } + checkIDs(f, "invoiceLineIds", in.InvoiceLineIDs, maxLinesPerCall) + if in.IdempotencyKey == "" || len(in.IdempotencyKey) > maxKeyLen { + f["idempotencyKey"] = "required, at most 128 characters" + } + if in.Rail == RailMpesa && strings.TrimSpace(in.PayerPhone) == "" { + f["payerPhone"] = "required for mpesa" + } + return f.err() +} + +// ResolveInput is the body of POST /v1/unmatched/{id}/resolve; no lines means the money becomes student credit. +type ResolveInput struct { + StudentID string `json:"studentId"` + InvoiceLineIDs []string `json:"invoiceLineIds"` +} + +// Validate rejects malformed input before any business logic runs. +func (in ResolveInput) Validate() error { + f := fieldErrors{} + if !isUUID(in.StudentID) { + f["studentId"] = "must be a UUID" + } + checkIDs(f, "invoiceLineIds", in.InvoiceLineIDs, maxLinesPerCall) + return f.err() +} + +// ParseRange reads from and to as RFC 3339 or YYYY-MM-DD; to is exclusive. +func ParseRange(from, to string) (time.Time, time.Time, error) { + f := fieldErrors{} + parse := func(name, v string) time.Time { + for _, layout := range []string{time.RFC3339, "2006-01-02"} { + if t, err := time.Parse(layout, v); err == nil { + return t.UTC() + } + } + f[name] = "required, RFC 3339 or YYYY-MM-DD" + return time.Time{} + } + start, end := parse("from", from), parse("to", to) + if len(f) == 0 && !start.Before(end) { + f["to"] = "must be after from" + } + return start, end, f.err() +} diff --git a/services/payments/internal/fees/service.go b/services/payments/internal/fees/service.go new file mode 100644 index 0000000..c1be7a8 --- /dev/null +++ b/services/payments/internal/fees/service.go @@ -0,0 +1,225 @@ +package fees + +import ( + "context" + "errors" + "sort" + "time" + + "github.com/stackable/payments/internal/provider" +) + +// Service holds the fee, payment and ledger rules. It knows nothing about HTTP. +type Service struct { + store Store + providers map[string]provider.PaymentProvider + now func() time.Time +} + +// NewService builds a Service; each provider serves the rail and webhook path named by its Name. +func NewService(store Store, providers ...provider.PaymentProvider) *Service { + m := map[string]provider.PaymentProvider{} + for _, p := range providers { + m[p.Name()] = p + } + return &Service{store: store, providers: m, now: func() time.Time { return time.Now().UTC() }} +} + +// CreateFeeStructure saves a price list so invoices can be issued from it. +// It returns a conflict when the same term, level and name already exist. +func (s *Service) CreateFeeStructure(ctx context.Context, schoolID string, in FeeStructureInput) (FeeStructure, error) { + fs := FeeStructure{ID: newID(), Term: in.Term, Level: in.Level, Name: in.Name, CreatedAt: s.now()} + for _, it := range in.Items { + fs.Items = append(fs.Items, FeeItem{ID: newID(), Name: it.Name, AmountMinor: it.AmountMinor, Mandatory: it.Mandatory}) + } + err := s.store.InTx(ctx, schoolID, func(r Repo) error { return r.InsertFeeStructure(ctx, fs) }) + if errors.Is(err, ErrDuplicate) { + return FeeStructure{}, conflict("FEE_STRUCTURE_EXISTS", "A fee structure with this term, level and name already exists.") + } + return fs, err +} + +// ListFeeStructures returns one page of structures, ordered by id; after is the last id seen. +func (s *Service) ListFeeStructures(ctx context.Context, schoolID, term, level, after string, limit int) ([]FeeStructure, error) { + var out []FeeStructure + err := s.store.InTx(ctx, schoolID, func(r Repo) (err error) { + out, err = r.ListFeeStructures(ctx, term, level, after, limit) + return err + }) + return out, err +} + +// IssueInvoices bills each student and debits the ledger for every line, all in one transaction. +// A structure issues its mandatory items; a student already billed for that structure and term is a conflict. +func (s *Service) IssueInvoices(ctx context.Context, schoolID string, in IssueInvoicesInput) ([]Invoice, error) { + var invoices []Invoice + err := s.store.InTx(ctx, schoolID, func(r Repo) error { + term, lines, err := s.linesToIssue(ctx, r, in) + if err != nil { + return err + } + now := s.now() + for _, studentID := range in.StudentIDs { + inv := Invoice{ID: newID(), StudentID: studentID, Term: term, Status: InvoiceIssued, FeeStructureID: in.FeeStructureID, IssuedAt: now} + for _, l := range lines { + l.ID, l.InvoiceID = newID(), inv.ID + inv.Lines = append(inv.Lines, l) + inv.TotalMinor += l.AmountMinor + } + if err := r.InsertInvoice(ctx, inv); err != nil { + if errors.Is(err, ErrDuplicate) { + return domainErr(409, "INVOICE_ALREADY_ISSUED", "A student is already invoiced for this fee structure and term.", map[string]string{"studentId": studentID}) + } + return err + } + for _, l := range inv.Lines { + e := LedgerEntry{ID: newID(), StudentID: studentID, Account: AccountReceivable, Kind: LedgerDebitInvoice, + AmountMinor: l.AmountMinor, RefType: "invoice_line", RefID: l.ID, CreatedAt: now} + if err := r.InsertLedgerEntry(ctx, e); err != nil { + return err + } + } + invoices = append(invoices, inv) + } + return nil + }) + return invoices, err +} + +func (s *Service) linesToIssue(ctx context.Context, r Repo, in IssueInvoicesInput) (string, []InvoiceLine, error) { + if in.FeeStructureID == "" { + lines := make([]InvoiceLine, 0, len(in.Lines)) + for _, l := range in.Lines { + lines = append(lines, InvoiceLine{FeeItemID: l.FeeItemID, Description: l.Description, AmountMinor: l.AmountMinor}) + } + return in.Term, lines, nil + } + fs, err := r.GetFeeStructure(ctx, in.FeeStructureID) + if err != nil { + return "", nil, err + } + if fs == nil { + return "", nil, notFound("FEE_STRUCTURE_NOT_FOUND", "Fee structure not found.") + } + var lines []InvoiceLine + for _, it := range fs.Items { + if it.Mandatory { + lines = append(lines, InvoiceLine{FeeItemID: it.ID, Description: it.Name, AmountMinor: it.AmountMinor}) + } + } + if len(lines) == 0 { + return "", nil, unprocessable("FEE_STRUCTURE_EMPTY", "This fee structure has no mandatory items to invoice.") + } + return fs.Term, lines, nil +} + +// StatementLine is one invoice line with its payments applied. +type StatementLine struct { + LineID string `json:"lineId"` + InvoiceID string `json:"invoiceId"` + Description string `json:"description"` + AmountMinor int64 `json:"amountMinor"` + PaidMinor int64 `json:"paidMinor"` + OutstandingMinor int64 `json:"outstandingMinor"` +} + +// TermStatement groups a student's lines by term. +type TermStatement struct { + Term string `json:"term"` + InvoicedMinor int64 `json:"invoicedMinor"` + PaidMinor int64 `json:"paidMinor"` + BalanceMinor int64 `json:"balanceMinor"` + Lines []StatementLine `json:"lines"` +} + +// Statement is what a parent or the finance office sees for one student. +// BalanceMinor is the ledger sum: terms' balances minus unapplied credit. +type Statement struct { + StudentID string `json:"studentId"` + BalanceMinor int64 `json:"balanceMinor"` + CreditMinor int64 `json:"creditMinor"` + Terms []TermStatement `json:"terms"` + Payments []PaymentSummary `json:"payments"` +} + +// GetStatement builds the statement; term filters the lines but never the ledger balance. +func (s *Service) GetStatement(ctx context.Context, schoolID, studentID, term string) (Statement, error) { + st := Statement{StudentID: studentID, Terms: []TermStatement{}} + err := s.store.InTx(ctx, schoolID, func(r Repo) error { + balance, err := r.LedgerBalance(ctx, AccountReceivable, studentID) + if err != nil { + return err + } + lines, err := r.StatementLines(ctx, studentID, term, statementLineCap) + if err != nil { + return err + } + if st.CreditMinor, err = r.CreditAllocatedMinor(ctx, studentID); err != nil { + return err + } + if st.Payments, err = r.ListPaymentsByStudent(ctx, studentID, statementPayCap); err != nil { + return err + } + st.BalanceMinor, st.Terms = balance, groupByTerm(lines) + return nil + }) + return st, err +} + +func groupByTerm(lines []LineBalance) []TermStatement { + byTerm := map[string]*TermStatement{} + for _, l := range lines { + t := byTerm[l.Term] + if t == nil { + t = &TermStatement{Term: l.Term, Lines: []StatementLine{}} + byTerm[l.Term] = t + } + t.InvoicedMinor += l.AmountMinor + t.PaidMinor += l.PaidMinor + t.BalanceMinor += l.Outstanding() + t.Lines = append(t.Lines, StatementLine{LineID: l.ID, InvoiceID: l.InvoiceID, Description: l.Description, + AmountMinor: l.AmountMinor, PaidMinor: l.PaidMinor, OutstandingMinor: l.Outstanding()}) + } + out := make([]TermStatement, 0, len(byTerm)) + for _, t := range byTerm { + out = append(out, *t) + } + sort.Slice(out, func(i, j int) bool { return out[i].Term > out[j].Term }) + return out +} + +// Reconciliation returns payment rows created in [from, to) for the Excel export; after pages by payment id. +func (s *Service) Reconciliation(ctx context.Context, schoolID string, from, to time.Time, after string, limit int) ([]ReconRow, error) { + var rows []ReconRow + err := s.store.InTx(ctx, schoolID, func(r Repo) (err error) { + rows, err = r.Reconciliation(ctx, from, to, after, limit) + return err + }) + return rows, err +} + +// ListUnmatched returns the queue, open rows only unless includeResolved is set. +func (s *Service) ListUnmatched(ctx context.Context, schoolID string, includeResolved bool, after string, limit int) ([]UnmatchedView, error) { + var rows []UnmatchedView + err := s.store.InTx(ctx, schoolID, func(r Repo) (err error) { + rows, err = r.ListUnmatched(ctx, includeResolved, after, limit) + return err + }) + return rows, err +} + +// GetPayment returns one payment so clients can poll its status. +func (s *Service) GetPayment(ctx context.Context, schoolID, id string) (Payment, error) { + var p *Payment + err := s.store.InTx(ctx, schoolID, func(r Repo) (err error) { + p, err = r.GetPayment(ctx, id) + return err + }) + if err == nil && p == nil { + err = notFound("PAYMENT_NOT_FOUND", "Payment not found.") + } + if err != nil { + return Payment{}, err + } + return *p, nil +} diff --git a/services/payments/internal/fees/store.go b/services/payments/internal/fees/store.go new file mode 100644 index 0000000..5fee054 --- /dev/null +++ b/services/payments/internal/fees/store.go @@ -0,0 +1,58 @@ +package fees + +import ( + "context" + "time" +) + +// Repo is the data access inside one transaction; every call is scoped to one school. +type Repo interface { + InsertFeeStructure(ctx context.Context, fs FeeStructure) error + GetFeeStructure(ctx context.Context, id string) (*FeeStructure, error) + ListFeeStructures(ctx context.Context, term, level, after string, limit int) ([]FeeStructure, error) + + InsertInvoice(ctx context.Context, inv Invoice) error + // LinesForUpdate locks and returns the student's lines among ids; foreign or unknown ids are skipped. + LinesForUpdate(ctx context.Context, studentID string, ids []string) ([]LineBalance, error) + RefreshInvoiceStatuses(ctx context.Context, invoiceIDs []string) error + StatementLines(ctx context.Context, studentID, term string, limit int) ([]LineBalance, error) + + // InsertPaymentIfNew returns false when the idempotency key already exists. + InsertPaymentIfNew(ctx context.Context, p Payment) (bool, error) + GetPayment(ctx context.Context, id string) (*Payment, error) + GetPaymentByIdempotencyKey(ctx context.Context, key string) (*Payment, error) + GetPaymentByProviderTxn(ctx context.Context, rail, txnID string) (*Payment, error) + UpdatePayment(ctx context.Context, p Payment) error + ListPaymentsByStudent(ctx context.Context, studentID string, limit int) ([]PaymentSummary, error) + + InsertAllocations(ctx context.Context, allocs []Allocation) error + CreditAllocatedMinor(ctx context.Context, studentID string) (int64, error) + + InsertLedgerEntry(ctx context.Context, e LedgerEntry) error + // LedgerBalance is the signed sum for an account; an empty studentID means school-level entries. + LedgerBalance(ctx context.Context, account, studentID string) (int64, error) + + NextReceiptNumber(ctx context.Context) (string, error) + InsertReceipt(ctx context.Context, r Receipt) error + + InsertUnmatched(ctx context.Context, u Unmatched) error + GetUnmatchedForUpdate(ctx context.Context, paymentID string) (*Unmatched, error) + MarkUnmatchedResolved(ctx context.Context, paymentID, resolvedBy string, at time.Time) error + ListUnmatched(ctx context.Context, includeResolved bool, after string, limit int) ([]UnmatchedView, error) + + InsertOutbox(ctx context.Context, e OutboxEvent) error + // InsertWebhookEvent returns false when the (provider, event id) pair was already stored. + InsertWebhookEvent(ctx context.Context, ev WebhookEvent) (bool, error) + + Reconciliation(ctx context.Context, from, to time.Time, after string, limit int) ([]ReconRow, error) +} + +// Store runs work in all-or-nothing transactions. +type Store interface { + // InTx runs fn atomically for one school; an error from fn rolls everything back. + InTx(ctx context.Context, schoolID string, fn func(Repo) error) error + // PaymentSchool finds the school that owns a provider transaction; webhooks arrive without a token. + PaymentSchool(ctx context.Context, rail, txnID string) (schoolID string, found bool, err error) + // RecordOrphanEvent stores a webhook whose school cannot be determined. + RecordOrphanEvent(ctx context.Context, ev WebhookEvent) (bool, error) +} diff --git a/services/payments/internal/fees/types.go b/services/payments/internal/fees/types.go new file mode 100644 index 0000000..0af32ec --- /dev/null +++ b/services/payments/internal/fees/types.go @@ -0,0 +1,241 @@ +// Package fees holds the Fees MVP: structures, invoices, payments, the ledger, receipts and reconciliation. +package fees + +import ( + "encoding/json" + "errors" + "time" +) + +// Payment states (SDD P2-14). +const ( + StatusPending = "PENDING" + StatusConfirmed = "CONFIRMED" + StatusReversed = "REVERSED" + StatusFailed = "FAILED" + StatusUnmatched = "UNMATCHED" +) + +// Payment kinds; only INVOICE_LINE is implemented, the rest keep room in the schema. +const ( + KindInvoiceLine = "INVOICE_LINE" + KindEvent = "EVENT" + KindWallet = "WALLET" + KindFundraiser = "FUNDRAISER" +) + +// Invoice states. +const ( + InvoiceIssued = "ISSUED" + InvoicePartial = "PARTIAL" + InvoicePaid = "PAID" +) + +// Ledger entry kinds. A balance is the signed sum: debits are positive, credits negative. +const ( + LedgerDebitInvoice = "DEBIT_INVOICE" + LedgerCreditPayment = "CREDIT_PAYMENT" + LedgerRefund = "REFUND" + LedgerAdjustment = "ADJUSTMENT" +) + +// Ledger accounts. +const ( + AccountReceivable = "STUDENT_RECEIVABLE" + AccountSuspense = "UNMATCHED_SUSPENSE" +) + +// Allocation targets. +const ( + TargetInvoiceLine = "INVOICE_LINE" + TargetStudentCredit = "STUDENT_CREDIT" +) + +// Reasons a payment lands in the unmatched queue. +const ( + ReasonNoMatchingPayment = "NO_MATCHING_PAYMENT" + ReasonAmountMismatch = "AMOUNT_MISMATCH" +) + +// Rails a payment can use. +const ( + RailMpesa = "mpesa" + RailCard = "card" + RailBank = "bank" + RailFake = "fake" +) + +// ErrDuplicate is returned by a Repo when a unique constraint rejects an insert. +var ErrDuplicate = errors.New("duplicate row") + +// FeeItem is one chargeable line of a fee structure. +type FeeItem struct { + ID string `json:"id"` + Name string `json:"name"` + AmountMinor int64 `json:"amountMinor"` + Mandatory bool `json:"mandatory"` +} + +// FeeStructure is the price list for one term and level. +type FeeStructure struct { + ID string `json:"id"` + SchoolID string `json:"-"` + Term string `json:"term"` + Level string `json:"level"` + Name string `json:"name"` + Items []FeeItem `json:"items"` + CreatedAt time.Time `json:"createdAt"` +} + +// InvoiceLine is one amount a student owes. +type InvoiceLine struct { + ID string `json:"id"` + InvoiceID string `json:"invoiceId"` + FeeItemID string `json:"feeItemId,omitempty"` + Description string `json:"description"` + AmountMinor int64 `json:"amountMinor"` +} + +// Invoice is the bill issued to one student for one term. +type Invoice struct { + ID string `json:"id"` + SchoolID string `json:"-"` + StudentID string `json:"studentId"` + Term string `json:"term"` + Status string `json:"status"` + FeeStructureID string `json:"feeStructureId,omitempty"` + IssuedAt time.Time `json:"issuedAt"` + TotalMinor int64 `json:"totalMinor"` + Lines []InvoiceLine `json:"lines"` +} + +// Payment is one attempt or receipt of money. +type Payment struct { + ID string `json:"id"` + SchoolID string `json:"-"` + StudentID string `json:"studentId,omitempty"` + PayerUserID string `json:"payerUserId,omitempty"` + Rail string `json:"rail"` + ProviderTransactionID string `json:"providerTransactionId,omitempty"` + AmountMinor int64 `json:"amountMinor"` + Status string `json:"status"` + FailureCode string `json:"failureCode,omitempty"` + IdempotencyKey string `json:"-"` + Kind string `json:"kind"` + InvoiceLineIDs []string `json:"invoiceLineIds"` + PayerPhone string `json:"-"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// Allocation says how much of a payment went to one target. +type Allocation struct { + ID string + SchoolID string + PaymentID string + TargetType string + TargetID string + AmountMinor int64 + CreatedAt time.Time +} + +// LedgerEntry is one immutable line of the ledger. +type LedgerEntry struct { + ID string + SchoolID string + StudentID string // empty for school-level accounts such as the suspense account + Account string + Kind string + AmountMinor int64 + RefType string + RefID string + CreatedAt time.Time +} + +// Receipt is the proof of one confirmed payment. +type Receipt struct { + ID string `json:"id"` + SchoolID string `json:"-"` + PaymentID string `json:"paymentId"` + Number string `json:"number"` + IssuedAt time.Time `json:"issuedAt"` + DocumentID string `json:"documentId,omitempty"` +} + +// Unmatched is a queue row for money that needs a human decision. +type Unmatched struct { + ID string + SchoolID string + PaymentID string + Reason string + ResolvedBy string + ResolvedAt *time.Time + CreatedAt time.Time +} + +// UnmatchedView is an Unmatched joined with its payment for the finance screen. +type UnmatchedView struct { + PaymentID string `json:"paymentId"` + Reason string `json:"reason"` + Rail string `json:"rail"` + AmountMinor int64 `json:"amountMinor"` + StudentID string `json:"studentId,omitempty"` + ProviderTransactionID string `json:"providerTransactionId,omitempty"` + CreatedAt time.Time `json:"createdAt"` + ResolvedBy string `json:"resolvedBy,omitempty"` + ResolvedAt *time.Time `json:"resolvedAt,omitempty"` +} + +// OutboxEvent is a fact to be relayed to other services. +type OutboxEvent struct { + ID string + SchoolID string + Type string + AggregateID string + Payload json.RawMessage + CreatedAt time.Time +} + +// WebhookEvent is a provider callback stored once per (provider, event id). +type WebhookEvent struct { + Provider string + ExternalEventID string + Payload json.RawMessage +} + +// LineBalance is an invoice line with what has been paid against it. +type LineBalance struct { + InvoiceLine + StudentID string + Term string + InvoiceStatus string + PaidMinor int64 +} + +// Outstanding is what is still owed on the line. +func (l LineBalance) Outstanding() int64 { return l.AmountMinor - l.PaidMinor } + +// PaymentSummary is a payment as shown on a statement. +type PaymentSummary struct { + ID string `json:"id"` + Rail string `json:"rail"` + AmountMinor int64 `json:"amountMinor"` + Status string `json:"status"` + FailureCode string `json:"failureCode,omitempty"` + ReceiptNumber string `json:"receiptNumber,omitempty"` + CreatedAt time.Time `json:"createdAt"` +} + +// ReconRow is one payment line for the finance Excel export. +type ReconRow struct { + PaymentID string `json:"paymentId"` + ReceiptNumber string `json:"receiptNumber,omitempty"` + StudentID string `json:"studentId,omitempty"` + Rail string `json:"rail"` + ProviderTransactionID string `json:"providerTransactionId,omitempty"` + Status string `json:"status"` + AmountMinor int64 `json:"amountMinor"` + AllocatedMinor int64 `json:"allocatedMinor"` + CreditMinor int64 `json:"creditMinor"` + CreatedAt time.Time `json:"createdAt"` +} diff --git a/services/payments/internal/provider/fake.go b/services/payments/internal/provider/fake.go new file mode 100644 index 0000000..7c074fb --- /dev/null +++ b/services/payments/internal/provider/fake.go @@ -0,0 +1,69 @@ +package provider + +import ( + "context" + "encoding/json" + "fmt" + "net/http" +) + +// Phone numbers that make the fake provider fail at initiation, so demos and tests can trigger each path. +const ( + FakePhoneTimeout = "254700000003" + FakePhoneRailDown = "254700000005" +) + +// Fake is a deterministic provider for tests and demos; it never calls a network. +type Fake struct{} + +// Name returns the webhook path segment and rail name. +func (Fake) Name() string { return "fake" } + +// Initiate derives the transaction id from the payment id so repeats are stable. +func (Fake) Initiate(_ context.Context, p Payment) (Ref, error) { + switch p.PayerPhone { + case FakePhoneTimeout: + return Ref{}, &Error{Code: Timeout, Message: "fake provider timed out"} + case FakePhoneRailDown: + return Ref{}, &Error{Code: RailDown, Message: "fake provider is down"} + } + return Ref{TransactionID: "fake_" + p.ID}, nil +} + +type fakeWebhook struct { + ID string `json:"id"` + TransactionID string `json:"transactionId"` + Status string `json:"status"` + FailureCode string `json:"failureCode"` + AmountMinor int64 `json:"amountMinor"` + SchoolID string `json:"schoolId"` + StudentID string `json:"studentId"` +} + +// VerifyWebhook parses the fake JSON shape; a missing status means success. +func (Fake) VerifyWebhook(_ http.Header, body []byte) (Event, error) { + var w fakeWebhook + if err := json.Unmarshal(body, &w); err != nil || w.ID == "" { + return Event{}, fmt.Errorf("%w: fake webhook needs a JSON object with an id", ErrInvalidWebhook) + } + if w.AmountMinor < 0 { + return Event{}, fmt.Errorf("%w: negative amount", ErrInvalidWebhook) + } + ev := Event{ + EventID: w.ID, TransactionID: w.TransactionID, Succeeded: w.Status == "" || w.Status == "success", + AmountMinor: w.AmountMinor, SchoolHint: w.SchoolID, StudentHint: w.StudentID, + } + if ev.TransactionID == "" { + ev.TransactionID = w.ID + } + if !ev.Succeeded { + ev.FailureCode = FailureCode(w.FailureCode) + if ev.FailureCode == "" { + ev.FailureCode = RailDown + } + } + return ev, nil +} + +// Refund always succeeds because no money moves. +func (Fake) Refund(context.Context, Payment) error { return nil } diff --git a/services/payments/internal/provider/mpesa.go b/services/payments/internal/provider/mpesa.go new file mode 100644 index 0000000..811f03c --- /dev/null +++ b/services/payments/internal/provider/mpesa.go @@ -0,0 +1,325 @@ +package provider + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "math" + "net" + "net/http" + "os" + "strconv" + "strings" + "sync" + "time" +) + +const ( + sandboxBaseURL = "https://sandbox.safaricom.co.ke" + mpesaHTTPTimeout = 15 * time.Second + tokenSafetyMargin = 30 * time.Second // refresh the OAuth token before it really expires + minorPerShilling = 100 + maxDarajaReply = 1 << 20 // 1 MiB cap so a bad reply cannot exhaust memory +) + +// Daraja ResultCode values from the STK callback that map to our failure codes. +const ( + resultSuccess = 0 + resultInsufficientFunds = 1 + resultWrongPin = 2001 + resultUserCancelled = 1032 + resultTimeout = 1037 + resultPromptExpired = 1019 +) + +// MpesaConfig holds the Daraja credentials; BaseURL defaults to the sandbox. +type MpesaConfig struct { + ConsumerKey string + ConsumerSecret string + Shortcode string + Passkey string + CallbackURL string + BaseURL string +} + +// Mpesa is the Daraja STK-push provider (sandbox shape). +type Mpesa struct { + cfg MpesaConfig + client *http.Client + now func() time.Time + + mu sync.Mutex + token string + tokenExp time.Time +} + +// NewMpesaFromEnv returns false when any MPESA_* variable is unset, which disables the rail. +func NewMpesaFromEnv() (*Mpesa, bool) { + cfg := MpesaConfig{ + ConsumerKey: os.Getenv("MPESA_CONSUMER_KEY"), + ConsumerSecret: os.Getenv("MPESA_CONSUMER_SECRET"), + Shortcode: os.Getenv("MPESA_SHORTCODE"), + Passkey: os.Getenv("MPESA_PASSKEY"), + CallbackURL: os.Getenv("MPESA_CALLBACK_URL"), + BaseURL: os.Getenv("MPESA_BASE_URL"), + } + if cfg.ConsumerKey == "" || cfg.ConsumerSecret == "" || cfg.Shortcode == "" || cfg.Passkey == "" || cfg.CallbackURL == "" { + return nil, false + } + return NewMpesa(cfg, &http.Client{Timeout: mpesaHTTPTimeout}), true +} + +// NewMpesa builds the provider; tests pass a client that points at a local server. +func NewMpesa(cfg MpesaConfig, client *http.Client) *Mpesa { + if cfg.BaseURL == "" { + cfg.BaseURL = sandboxBaseURL + } + return &Mpesa{cfg: cfg, client: client, now: time.Now} +} + +// Name returns the webhook path segment and rail name. +func (*Mpesa) Name() string { return "mpesa" } + +// NormalizeKenyanPhone turns 07.., 01.., +254.. or 254.. into 254XXXXXXXXX. +func NormalizeKenyanPhone(raw string) (string, error) { + s := strings.NewReplacer(" ", "", "-", "", "+", "").Replace(raw) + if strings.HasPrefix(s, "0") { + s = "254" + s[1:] + } + if len(s) != 12 || !strings.HasPrefix(s, "254") { + return "", fmt.Errorf("%w: phone must be a Kenyan number", ErrInvalidRequest) + } + for _, c := range s { + if c < '0' || c > '9' { + return "", fmt.Errorf("%w: phone must be digits only", ErrInvalidRequest) + } + } + return s, nil +} + +type stkRequest struct { + BusinessShortCode string + Password string + Timestamp string + TransactionType string + Amount int64 + PartyA string + PartyB string + PhoneNumber string + CallBackURL string + AccountReference string + TransactionDesc string +} + +type stkResponse struct { + MerchantRequestID string `json:"MerchantRequestID"` + CheckoutRequestID string `json:"CheckoutRequestID"` + ResponseCode string `json:"ResponseCode"` + ResponseDescription string `json:"ResponseDescription"` + ErrorCode string `json:"errorCode"` + ErrorMessage string `json:"errorMessage"` +} + +// Initiate sends an STK push and returns the CheckoutRequestID as the transaction id. +func (m *Mpesa) Initiate(ctx context.Context, p Payment) (Ref, error) { + phone, err := NormalizeKenyanPhone(p.PayerPhone) + if err != nil { + return Ref{}, err + } + if p.AmountMinor <= 0 || p.AmountMinor%minorPerShilling != 0 { + return Ref{}, fmt.Errorf("%w: M-Pesa takes whole shillings", ErrInvalidRequest) + } + token, err := m.accessToken(ctx) + if err != nil { + return Ref{}, err + } + stamp := m.now().UTC().Format("20060102150405") + body, err := json.Marshal(stkRequest{ + BusinessShortCode: m.cfg.Shortcode, + Password: base64.StdEncoding.EncodeToString([]byte(m.cfg.Shortcode + m.cfg.Passkey + stamp)), + Timestamp: stamp, + TransactionType: "CustomerPayBillOnline", + Amount: p.AmountMinor / minorPerShilling, + PartyA: phone, + PartyB: m.cfg.Shortcode, + PhoneNumber: phone, + CallBackURL: m.cfg.CallbackURL, + AccountReference: p.Reference, + TransactionDesc: "School fees", + }) + if err != nil { + return Ref{}, fmt.Errorf("encode stk request: %w", err) + } + raw, status, err := m.do(ctx, http.MethodPost, "/mpesa/stkpush/v1/processrequest", bytes.NewReader(body), "Bearer "+token) + if err != nil { + return Ref{}, err + } + return parseSTKResponse(raw, status) +} + +func parseSTKResponse(raw []byte, status int) (Ref, error) { + if status >= http.StatusInternalServerError { + return Ref{}, &Error{Code: RailDown, Message: "daraja is unavailable"} + } + var resp stkResponse + if err := json.Unmarshal(raw, &resp); err != nil { + return Ref{}, &Error{Code: RailDown, Message: fmt.Sprintf("unreadable daraja reply (http %d)", status)} + } + if status != http.StatusOK || resp.ResponseCode != "0" || resp.CheckoutRequestID == "" { + return Ref{}, &Error{Code: RailDown, Message: "daraja rejected the request: " + firstNonEmpty(resp.ErrorMessage, resp.ResponseDescription)} + } + return Ref{TransactionID: resp.CheckoutRequestID}, nil +} + +func firstNonEmpty(values ...string) string { + for _, v := range values { + if v != "" { + return v + } + } + return "no detail" +} + +func (m *Mpesa) accessToken(ctx context.Context) (string, error) { + m.mu.Lock() + defer m.mu.Unlock() + if m.token != "" && m.now().Before(m.tokenExp) { + return m.token, nil + } + basic := base64.StdEncoding.EncodeToString([]byte(m.cfg.ConsumerKey + ":" + m.cfg.ConsumerSecret)) + raw, status, err := m.do(ctx, http.MethodGet, "/oauth/v1/generate?grant_type=client_credentials", nil, "Basic "+basic) + if err != nil { + return "", err + } + token, ttl, err := parseOAuthResponse(raw, status) + if err != nil { + return "", err + } + m.token = token + m.tokenExp = m.now().Add(ttl - tokenSafetyMargin) + return m.token, nil +} + +func parseOAuthResponse(raw []byte, status int) (string, time.Duration, error) { + var resp struct { + AccessToken string `json:"access_token"` + ExpiresIn string `json:"expires_in"` + } + if status != http.StatusOK || json.Unmarshal(raw, &resp) != nil || resp.AccessToken == "" { + return "", 0, &Error{Code: RailDown, Message: fmt.Sprintf("daraja oauth failed (http %d)", status)} + } + secs, err := strconv.Atoi(resp.ExpiresIn) + if err != nil || secs <= 0 { + secs = 3599 // Daraja documents one hour; use it when the field is missing + } + return resp.AccessToken, time.Duration(secs) * time.Second, nil +} + +// do runs one request; timeouts and network failures become TIMEOUT and RAIL_DOWN. +func (m *Mpesa) do(ctx context.Context, method, path string, body io.Reader, auth string) ([]byte, int, error) { + req, err := http.NewRequestWithContext(ctx, method, strings.TrimRight(m.cfg.BaseURL, "/")+path, body) + if err != nil { + return nil, 0, fmt.Errorf("build daraja request: %w", err) + } + req.Header.Set("Authorization", auth) + req.Header.Set("Content-Type", "application/json") + resp, err := m.client.Do(req) + if err != nil { + var netErr net.Error + if errors.Is(err, context.DeadlineExceeded) || (errors.As(err, &netErr) && netErr.Timeout()) { + return nil, 0, &Error{Code: Timeout, Message: "daraja did not answer in time"} + } + return nil, 0, &Error{Code: RailDown, Message: "daraja is unreachable"} + } + defer resp.Body.Close() + raw, err := io.ReadAll(io.LimitReader(resp.Body, maxDarajaReply)) + if err != nil { + return nil, 0, &Error{Code: RailDown, Message: "daraja reply was cut off"} + } + return raw, resp.StatusCode, nil +} + +type stkCallback struct { + Body struct { + StkCallback struct { + MerchantRequestID string `json:"MerchantRequestID"` + CheckoutRequestID string `json:"CheckoutRequestID"` + ResultCode int `json:"ResultCode"` + ResultDesc string `json:"ResultDesc"` + CallbackMetadata struct { + Item []struct { + Name string `json:"Name"` + Value json.RawMessage `json:"Value"` + } `json:"Item"` + } `json:"CallbackMetadata"` + } `json:"stkCallback"` + } `json:"Body"` +} + +// VerifyWebhook parses an STK callback. Daraja does not sign callbacks, so the service only +// trusts it for a payment it started, and the proxy should allowlist Safaricom IPs. +func (*Mpesa) VerifyWebhook(_ http.Header, body []byte) (Event, error) { + var cb stkCallback + if err := json.Unmarshal(body, &cb); err != nil { + return Event{}, fmt.Errorf("%w: not an stk callback", ErrInvalidWebhook) + } + c := cb.Body.StkCallback + if c.CheckoutRequestID == "" { + return Event{}, fmt.Errorf("%w: missing CheckoutRequestID", ErrInvalidWebhook) + } + ev := Event{EventID: c.CheckoutRequestID, TransactionID: c.CheckoutRequestID, Succeeded: c.ResultCode == resultSuccess} + if !ev.Succeeded { + ev.FailureCode = failureForResultCode(c.ResultCode) + return ev, nil + } + for _, item := range c.CallbackMetadata.Item { + if item.Name != "Amount" { + continue + } + minor, err := shillingsToMinor(item.Value) + if err != nil { + return Event{}, fmt.Errorf("%w: bad Amount", ErrInvalidWebhook) + } + ev.AmountMinor = minor + } + return ev, nil +} + +func failureForResultCode(code int) FailureCode { + switch code { + case resultInsufficientFunds: + return InsufficientFunds + case resultWrongPin: + return WrongPin + case resultUserCancelled: + return UserCancelled + case resultTimeout, resultPromptExpired: + return Timeout + default: + return RailDown + } +} + +// shillingsToMinor reads a JSON number or string such as 1 or "1.50". +func shillingsToMinor(raw json.RawMessage) (int64, error) { + var n json.Number + if err := json.Unmarshal(raw, &n); err != nil { + var s string + if err2 := json.Unmarshal(raw, &s); err2 != nil { + return 0, err + } + n = json.Number(s) + } + f, err := strconv.ParseFloat(n.String(), 64) + if err != nil || f < 0 || math.IsNaN(f) || math.IsInf(f, 0) { + return 0, errors.New("bad amount") + } + return int64(math.Round(f * minorPerShilling)), nil +} + +// Refund is not offered: Daraja reversals need the Reversal API and initiator credentials. +func (*Mpesa) Refund(context.Context, Payment) error { return ErrNotSupported } diff --git a/services/payments/internal/provider/provider.go b/services/payments/internal/provider/provider.go new file mode 100644 index 0000000..8ea2aae --- /dev/null +++ b/services/payments/internal/provider/provider.go @@ -0,0 +1,70 @@ +// Package provider hides each payment rail (M-Pesa, fake, later card and bank) behind one interface. +package provider + +import ( + "context" + "errors" + "net/http" +) + +// FailureCode is the rail-neutral reason a payment attempt failed. +type FailureCode string + +const ( + InsufficientFunds FailureCode = "INSUFFICIENT_FUNDS" + WrongPin FailureCode = "WRONG_PIN" + Timeout FailureCode = "TIMEOUT" + UserCancelled FailureCode = "USER_CANCELLED" + RailDown FailureCode = "RAIL_DOWN" +) + +// Error is a provider failure with one of the stable codes. +type Error struct { + Code FailureCode + Message string +} + +func (e *Error) Error() string { return string(e.Code) + ": " + e.Message } + +var ( + // ErrInvalidWebhook means the body is not a valid callback for this provider. + ErrInvalidWebhook = errors.New("invalid webhook") + // ErrBadSignature means the callback failed authenticity checks. + ErrBadSignature = errors.New("bad webhook signature") + // ErrInvalidRequest means the payment cannot be sent to this rail as given. + ErrInvalidRequest = errors.New("invalid payment request") + // ErrNotSupported means the rail does not offer this operation yet. + ErrNotSupported = errors.New("operation not supported by provider") +) + +// Payment is the part of a payment a rail needs to start a charge. +type Payment struct { + ID string + AmountMinor int64 + PayerPhone string + Reference string +} + +// Ref identifies the charge on the provider side. +type Ref struct { + TransactionID string +} + +// Event is a verified provider callback in rail-neutral form. +type Event struct { + EventID string + TransactionID string + Succeeded bool + FailureCode FailureCode + AmountMinor int64 // zero when the callback does not state an amount + SchoolHint string + StudentHint string +} + +// PaymentProvider is the only way the service talks to a payment rail. +type PaymentProvider interface { + Name() string + Initiate(ctx context.Context, p Payment) (Ref, error) + VerifyWebhook(headers http.Header, body []byte) (Event, error) + Refund(ctx context.Context, p Payment) error +} diff --git a/services/payments/internal/provider/provider_test.go b/services/payments/internal/provider/provider_test.go new file mode 100644 index 0000000..6f13599 --- /dev/null +++ b/services/payments/internal/provider/provider_test.go @@ -0,0 +1,196 @@ +package provider + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +const successCallback = `{"Body":{"stkCallback":{"MerchantRequestID":"29115-34620561-1","CheckoutRequestID":"ws_CO_191220191020363925","ResultCode":0,"ResultDesc":"The service request is processed successfully.","CallbackMetadata":{"Item":[{"Name":"Amount","Value":1.00},{"Name":"MpesaReceiptNumber","Value":"NLJ7RT61SV"},{"Name":"TransactionDate","Value":20191219102115},{"Name":"PhoneNumber","Value":254708374149}]}}}}` + +func failureCallback(code int) string { + return `{"Body":{"stkCallback":{"MerchantRequestID":"m","CheckoutRequestID":"ws_CO_1","ResultCode":` + itoa(code) + `,"ResultDesc":"x"}}}` +} + +func itoa(n int) string { + b, _ := json.Marshal(n) + return string(b) +} + +func TestDarajaSuccessCallbackIsParsed(t *testing.T) { + ev, err := (&Mpesa{}).VerifyWebhook(nil, []byte(successCallback)) + if err != nil { + t.Fatal(err) + } + if !ev.Succeeded || ev.TransactionID != "ws_CO_191220191020363925" || ev.EventID != ev.TransactionID || ev.AmountMinor != 100 { + t.Fatalf("event %+v", ev) + } +} + +func TestDarajaFailureCodesMapToDistinctCodes(t *testing.T) { + cases := map[int]FailureCode{ + 1: InsufficientFunds, 2001: WrongPin, 1032: UserCancelled, 1037: Timeout, 1019: Timeout, 9999: RailDown, + } + for code, want := range cases { + ev, err := (&Mpesa{}).VerifyWebhook(nil, []byte(failureCallback(code))) + if err != nil || ev.Succeeded || ev.FailureCode != want { + t.Fatalf("code %d: got %+v err %v, want %s", code, ev, err, want) + } + } +} + +func TestDarajaRejectsMalformedCallbacks(t *testing.T) { + for name, body := range map[string]string{ + "not json": `nope`, + "no checkout id": `{"Body":{"stkCallback":{"ResultCode":0}}}`, + "empty object": `{}`, + "bad amount type": `{"Body":{"stkCallback":{"CheckoutRequestID":"c","ResultCode":0,"CallbackMetadata":{"Item":[{"Name":"Amount","Value":{"x":1}}]}}}}`, + } { + if _, err := (&Mpesa{}).VerifyWebhook(nil, []byte(body)); !errors.Is(err, ErrInvalidWebhook) { + t.Fatalf("%s: err %v", name, err) + } + } +} + +func TestAmountAsStringWithDecimals(t *testing.T) { + got, err := shillingsToMinor(json.RawMessage(`"1500.50"`)) + if err != nil || got != 150050 { + t.Fatalf("got %d %v", got, err) + } +} + +func TestNormalizeKenyanPhone(t *testing.T) { + for in, want := range map[string]string{ + "0712345678": "254712345678", "+254 712 345 678": "254712345678", "254112345678": "254112345678", + } { + if got, err := NormalizeKenyanPhone(in); err != nil || got != want { + t.Fatalf("%s: got %s %v", in, got, err) + } + } + for _, bad := range []string{"", "12345", "0712abc678", "255712345678"} { + if _, err := NormalizeKenyanPhone(bad); !errors.Is(err, ErrInvalidRequest) { + t.Fatalf("%q accepted", bad) + } + } +} + +func darajaServer(t *testing.T, stkStatus int, stkBody string, seen *stkRequest) *Mpesa { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/oauth/v1/generate": + if !strings.HasPrefix(r.Header.Get("Authorization"), "Basic ") { + w.WriteHeader(http.StatusUnauthorized) + return + } + _, _ = w.Write([]byte(`{"access_token":"tok","expires_in":"3599"}`)) + case "/mpesa/stkpush/v1/processrequest": + if r.Header.Get("Authorization") != "Bearer tok" { + w.WriteHeader(http.StatusUnauthorized) + return + } + _ = json.NewDecoder(r.Body).Decode(seen) + w.WriteHeader(stkStatus) + _, _ = w.Write([]byte(stkBody)) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + t.Cleanup(srv.Close) + return NewMpesa(MpesaConfig{ConsumerKey: "k", ConsumerSecret: "s", Shortcode: "174379", Passkey: "pk", CallbackURL: "https://x/cb", BaseURL: srv.URL}, srv.Client()) +} + +func TestMpesaInitiateSendsStkPush(t *testing.T) { + var seen stkRequest + m := darajaServer(t, 200, `{"MerchantRequestID":"m","CheckoutRequestID":"ws_CO_9","ResponseCode":"0","ResponseDescription":"ok"}`, &seen) + ref, err := m.Initiate(context.Background(), Payment{ID: "p1", AmountMinor: 250000, PayerPhone: "0712345678", Reference: "p1"}) + if err != nil || ref.TransactionID != "ws_CO_9" { + t.Fatalf("ref %+v err %v", ref, err) + } + if seen.Amount != 2500 || seen.PhoneNumber != "254712345678" || seen.BusinessShortCode != "174379" || seen.CallBackURL != "https://x/cb" || seen.Password == "" { + t.Fatalf("request %+v", seen) + } +} + +func TestMpesaInitiateMapsRailFailures(t *testing.T) { + var seen stkRequest + down := darajaServer(t, 503, `{}`, &seen) + _, err := down.Initiate(context.Background(), Payment{ID: "p", AmountMinor: 100, PayerPhone: "0712345678"}) + var pe *Error + if !errors.As(err, &pe) || pe.Code != RailDown { + t.Fatalf("err %v", err) + } + rejected := darajaServer(t, 400, `{"errorCode":"400.002.02","errorMessage":"Bad Request"}`, &seen) + _, err = rejected.Initiate(context.Background(), Payment{ID: "p", AmountMinor: 100, PayerPhone: "0712345678"}) + if !errors.As(err, &pe) || pe.Code != RailDown { + t.Fatalf("err %v", err) + } +} + +func TestMpesaInitiateTimeoutIsTimeout(t *testing.T) { + m := darajaServer(t, 200, `{}`, &stkRequest{}) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, err := m.Initiate(ctx, Payment{ID: "p", AmountMinor: 100, PayerPhone: "0712345678"}) + var pe *Error + if !errors.As(err, &pe) || (pe.Code != RailDown && pe.Code != Timeout) { + t.Fatalf("err %v", err) + } +} + +func TestMpesaRejectsFractionalShillings(t *testing.T) { + m := darajaServer(t, 200, `{}`, &stkRequest{}) + _, err := m.Initiate(context.Background(), Payment{ID: "p", AmountMinor: 150, PayerPhone: "0712345678"}) + if !errors.Is(err, ErrInvalidRequest) { + t.Fatalf("err %v", err) + } +} + +func TestMpesaDisabledWithoutEnv(t *testing.T) { + for _, k := range []string{"MPESA_CONSUMER_KEY", "MPESA_CONSUMER_SECRET", "MPESA_SHORTCODE", "MPESA_PASSKEY", "MPESA_CALLBACK_URL"} { + t.Setenv(k, "") + } + if _, ok := NewMpesaFromEnv(); ok { + t.Fatal("mpesa enabled with no config") + } + t.Setenv("MPESA_CONSUMER_KEY", "k") + t.Setenv("MPESA_CONSUMER_SECRET", "s") + t.Setenv("MPESA_SHORTCODE", "1") + t.Setenv("MPESA_PASSKEY", "p") + t.Setenv("MPESA_CALLBACK_URL", "https://x") + if _, ok := NewMpesaFromEnv(); !ok { + t.Fatal("mpesa disabled with full config") + } +} + +func TestMpesaRefundNotSupported(t *testing.T) { + if err := (&Mpesa{}).Refund(context.Background(), Payment{}); !errors.Is(err, ErrNotSupported) { + t.Fatalf("err %v", err) + } +} + +func TestFakeProvider(t *testing.T) { + f := Fake{} + ref, err := f.Initiate(context.Background(), Payment{ID: "p1"}) + if err != nil || ref.TransactionID != "fake_p1" { + t.Fatalf("%+v %v", ref, err) + } + for phone, want := range map[string]FailureCode{FakePhoneTimeout: Timeout, FakePhoneRailDown: RailDown} { + _, err := f.Initiate(context.Background(), Payment{ID: "p", PayerPhone: phone}) + var pe *Error + if !errors.As(err, &pe) || pe.Code != want { + t.Fatalf("%s: %v", phone, err) + } + } + ev, err := f.VerifyWebhook(nil, []byte(`{"id":"e1","transactionId":"fake_p1","status":"failed","failureCode":"WRONG_PIN"}`)) + if err != nil || ev.Succeeded || ev.FailureCode != WrongPin || ev.TransactionID != "fake_p1" { + t.Fatalf("%+v %v", ev, err) + } + if _, err := f.VerifyWebhook(nil, []byte(`{"amountMinor":5}`)); !errors.Is(err, ErrInvalidWebhook) { + t.Fatalf("err %v", err) + } +} diff --git a/services/payments/migrations/00001_create_webhook_events.sql b/services/payments/migrations/00001_create_webhook_events.sql new file mode 100644 index 0000000..a4363de --- /dev/null +++ b/services/payments/migrations/00001_create_webhook_events.sql @@ -0,0 +1,16 @@ +-- +goose Up +create schema if not exists payments; + +create table payments.webhook_events ( + id uuid primary key default gen_random_uuid(), + provider text not null, + external_event_id text not null, + payload jsonb not null, + received_at timestamptz not null default now(), + processed_at timestamptz, + unique (provider, external_event_id) +); + +-- +goose Down +drop table payments.webhook_events; +drop schema payments; diff --git a/services/payments/migrations/00002_fees_invoices.sql b/services/payments/migrations/00002_fees_invoices.sql new file mode 100644 index 0000000..5b6a99f --- /dev/null +++ b/services/payments/migrations/00002_fees_invoices.sql @@ -0,0 +1,73 @@ +-- +goose Up +create table payments.fee_structures ( + id uuid primary key, + school_id uuid not null, + term text not null, + level text not null, + name text not null, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + unique (school_id, term, level, name) +); + +create table payments.fee_items ( + id uuid primary key, + school_id uuid not null, + fee_structure_id uuid not null references payments.fee_structures (id), + name text not null, + amount_minor bigint not null check (amount_minor > 0), + mandatory boolean not null default true, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); +create index fee_items_structure_idx on payments.fee_items (fee_structure_id); + +create table payments.invoices ( + id uuid primary key, + school_id uuid not null, + student_id uuid not null, + fee_structure_id uuid references payments.fee_structures (id), + term text not null, + status text not null default 'ISSUED' check (status in ('ISSUED', 'PARTIAL', 'PAID')), + issued_at timestamptz not null default now(), + total_minor bigint not null check (total_minor > 0), + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); +create index invoices_student_idx on payments.invoices (school_id, student_id, term); +create index invoices_structure_idx on payments.invoices (fee_structure_id); +-- one invoice per student, term and structure, so a repeated run cannot double-bill +create unique index invoices_once_per_structure on payments.invoices (school_id, student_id, term, fee_structure_id) + where fee_structure_id is not null; + +create table payments.invoice_lines ( + id uuid primary key, + school_id uuid not null, + invoice_id uuid not null references payments.invoices (id), + fee_item_id uuid references payments.fee_items (id), + description text not null, + amount_minor bigint not null check (amount_minor > 0), + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); +create index invoice_lines_invoice_idx on payments.invoice_lines (invoice_id); +create index invoice_lines_fee_item_idx on payments.invoice_lines (fee_item_id); + +-- +goose StatementBegin +do $$ +declare t text; +begin + foreach t in array array['fee_structures', 'fee_items', 'invoices', 'invoice_lines'] loop + execute format('alter table payments.%I enable row level security', t); + execute format( + 'create policy tenant_isolation on payments.%I using (school_id = current_setting(''app.school_id'', true)::uuid) with check (school_id = current_setting(''app.school_id'', true)::uuid)', + t); + end loop; +end $$; +-- +goose StatementEnd + +-- +goose Down +drop table payments.invoice_lines; +drop table payments.invoices; +drop table payments.fee_items; +drop table payments.fee_structures; diff --git a/services/payments/migrations/00003_payments_ledger.sql b/services/payments/migrations/00003_payments_ledger.sql new file mode 100644 index 0000000..6702f9b --- /dev/null +++ b/services/payments/migrations/00003_payments_ledger.sql @@ -0,0 +1,142 @@ +-- +goose Up +create table payments.payments ( + id uuid primary key, + school_id uuid not null, + student_id uuid, + payer_user_id text, + rail text not null check (rail in ('mpesa', 'card', 'bank', 'fake')), + provider_transaction_id text, + amount_minor bigint not null check (amount_minor > 0), + status text not null default 'PENDING' check (status in ('PENDING', 'CONFIRMED', 'REVERSED', 'FAILED', 'UNMATCHED')), + failure_code text, + idempotency_key text not null, + kind text not null default 'INVOICE_LINE' check (kind in ('INVOICE_LINE', 'EVENT', 'WALLET', 'FUNDRAISER')), + invoice_line_ids uuid[] not null default '{}', + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + unique (school_id, idempotency_key) +); +-- a provider transaction id is unique per provider, across schools, so a webhook finds exactly one payment +create unique index payments_provider_txn_uniq on payments.payments (rail, provider_transaction_id) + where provider_transaction_id is not null; +create index payments_student_idx on payments.payments (school_id, student_id); +create index payments_created_idx on payments.payments (school_id, created_at); + +create table payments.payment_allocations ( + id uuid primary key, + school_id uuid not null, + payment_id uuid not null references payments.payments (id), + target_type text not null check (target_type in ('INVOICE_LINE', 'STUDENT_CREDIT')), + target_id uuid not null, + amount_minor bigint not null check (amount_minor > 0), + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); +create index payment_allocations_payment_idx on payments.payment_allocations (payment_id); +create index payment_allocations_target_idx on payments.payment_allocations (school_id, target_type, target_id); + +-- append-only: a balance is the signed sum of one account; debits are positive, credits negative +create table payments.ledger_entries ( + id uuid primary key, + school_id uuid not null, + student_id uuid, + account text not null, + kind text not null check (kind in ('DEBIT_INVOICE', 'CREDIT_PAYMENT', 'REFUND', 'ADJUSTMENT')), + amount_minor bigint not null check (amount_minor <> 0), + ref_type text not null, + ref_id uuid not null, + created_at timestamptz not null default now() +); +create index ledger_entries_balance_idx on payments.ledger_entries (school_id, account, student_id); +create index ledger_entries_ref_idx on payments.ledger_entries (ref_type, ref_id); + +-- +goose StatementBegin +create function payments.ledger_entries_immutable() returns trigger language plpgsql as $$ +begin + raise exception 'payments.ledger_entries is append-only (% blocked)', tg_op; +end $$; +-- +goose StatementEnd + +create trigger ledger_entries_no_update_delete + before update or delete on payments.ledger_entries + for each row execute function payments.ledger_entries_immutable(); + +create trigger ledger_entries_no_truncate + before truncate on payments.ledger_entries + for each statement execute function payments.ledger_entries_immutable(); + +create table payments.receipt_counters ( + id uuid primary key default gen_random_uuid(), + school_id uuid not null unique, + last_number bigint not null default 0, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create table payments.receipts ( + id uuid primary key, + school_id uuid not null, + payment_id uuid not null unique references payments.payments (id), + number text not null, + issued_at timestamptz not null default now(), + document_id uuid, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + unique (school_id, number) +); + +create table payments.unmatched_payments ( + id uuid primary key, + school_id uuid not null, + payment_id uuid not null unique references payments.payments (id), + reason text not null, + resolved_by text, + resolved_at timestamptz, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); +create index unmatched_open_idx on payments.unmatched_payments (school_id, payment_id) where resolved_at is null; + +create table payments.outbox ( + id uuid primary key, + school_id uuid not null, + event_type text not null, + aggregate_id uuid not null, + payload jsonb not null, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + published_at timestamptz +); +create index outbox_unpublished_idx on payments.outbox (created_at) where published_at is null; + +-- +goose StatementBegin +do $$ +declare t text; +begin + foreach t in array array['payments', 'payment_allocations', 'ledger_entries', 'receipt_counters', 'receipts', 'unmatched_payments', 'outbox'] loop + execute format('alter table payments.%I enable row level security', t); + execute format( + 'create policy tenant_isolation on payments.%I using (school_id = current_setting(''app.school_id'', true)::uuid) with check (school_id = current_setting(''app.school_id'', true)::uuid)', + t); + end loop; +end $$; +-- +goose StatementEnd + +-- Webhooks carry no token, so this owner-run function is the only cross-school lookup. +-- +goose StatementBegin +create function payments.payment_school(p_rail text, p_txn text) returns uuid +language sql stable security definer set search_path = payments, pg_temp as $$ + select school_id from payments.payments where rail = p_rail and provider_transaction_id = p_txn +$$; +-- +goose StatementEnd + +-- +goose Down +drop function payments.payment_school(text, text); +drop table payments.outbox; +drop table payments.unmatched_payments; +drop table payments.receipts; +drop table payments.receipt_counters; +drop table payments.ledger_entries; +drop function payments.ledger_entries_immutable(); +drop table payments.payment_allocations; +drop table payments.payments; diff --git a/services/payments/queries/webhook_events.sql b/services/payments/queries/webhook_events.sql new file mode 100644 index 0000000..a7df58e --- /dev/null +++ b/services/payments/queries/webhook_events.sql @@ -0,0 +1,4 @@ +-- name: InsertWebhookEvent :execrows +insert into payments.webhook_events (provider, external_event_id, payload) +values ($1, $2, $3) +on conflict (provider, external_event_id) do nothing; diff --git a/services/payments/sqlc.yaml b/services/payments/sqlc.yaml new file mode 100644 index 0000000..f93bc1e --- /dev/null +++ b/services/payments/sqlc.yaml @@ -0,0 +1,10 @@ +version: "2" +sql: + - engine: postgresql + schema: migrations + queries: queries + gen: + go: + package: db + out: internal/db + sql_package: pgx/v5 diff --git a/stackable-app/AGENTS.md b/stackable-app/AGENTS.md new file mode 100644 index 0000000..643577d --- /dev/null +++ b/stackable-app/AGENTS.md @@ -0,0 +1,9 @@ + + +# This is NOT the Next.js you know + +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices. + +This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean. + + diff --git a/stackable-app/CLAUDE.md b/stackable-app/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/stackable-app/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/stackable-app/app/(developer)/error.tsx b/stackable-app/app/(developer)/error.tsx new file mode 100644 index 0000000..3c3260f --- /dev/null +++ b/stackable-app/app/(developer)/error.tsx @@ -0,0 +1,3 @@ +"use client"; + +export { default } from "@/components/dashboard/PortalError"; diff --git a/stackable-app/app/(developer)/loading.tsx b/stackable-app/app/(developer)/loading.tsx new file mode 100644 index 0000000..00d9154 --- /dev/null +++ b/stackable-app/app/(developer)/loading.tsx @@ -0,0 +1 @@ +export { default } from "@/components/dashboard/PortalLoading"; diff --git a/stackable-app/app/(parent)/error.tsx b/stackable-app/app/(parent)/error.tsx new file mode 100644 index 0000000..3c3260f --- /dev/null +++ b/stackable-app/app/(parent)/error.tsx @@ -0,0 +1,3 @@ +"use client"; + +export { default } from "@/components/dashboard/PortalError"; diff --git a/stackable-app/app/(parent)/family/fees/page.tsx b/stackable-app/app/(parent)/family/fees/page.tsx index 416ef71..d67d955 100644 --- a/stackable-app/app/(parent)/family/fees/page.tsx +++ b/stackable-app/app/(parent)/family/fees/page.tsx @@ -1,17 +1,11 @@ -export default function FeesPage() { +import { ComingSoon } from "@/components/ui"; + +export default function SchoolFeesPage() { return ( -
-

School Fees

-
-
-
🏗️
-

Coming Soon

-

- This section is under construction. School fees information will be available - here soon. -

-
-
-
+ ); } diff --git a/stackable-app/app/(parent)/family/inbox/page.tsx b/stackable-app/app/(parent)/family/inbox/page.tsx index 435fa5c..85dd06c 100644 --- a/stackable-app/app/(parent)/family/inbox/page.tsx +++ b/stackable-app/app/(parent)/family/inbox/page.tsx @@ -1,17 +1,11 @@ +import { ComingSoon } from "@/components/ui"; + export default function InboxPage() { return ( -
-

Inbox

-
-
-
🏗️
-

Coming Soon

-

- This section is under construction. Your messages and inbox will be available - here soon. -

-
-
-
+ ); } diff --git a/stackable-app/app/(parent)/family/notices/page.tsx b/stackable-app/app/(parent)/family/notices/page.tsx index 422784e..82ec085 100644 --- a/stackable-app/app/(parent)/family/notices/page.tsx +++ b/stackable-app/app/(parent)/family/notices/page.tsx @@ -1,17 +1,11 @@ +import { ComingSoon } from "@/components/ui"; + export default function NoticesPage() { return ( -
-

Notices

-
-
-
🏗️
-

Coming Soon

-

- This section is under construction. School notices and announcements will be - available here soon. -

-
-
-
+ ); } diff --git a/stackable-app/app/(parent)/loading.tsx b/stackable-app/app/(parent)/loading.tsx new file mode 100644 index 0000000..00d9154 --- /dev/null +++ b/stackable-app/app/(parent)/loading.tsx @@ -0,0 +1 @@ +export { default } from "@/components/dashboard/PortalLoading"; diff --git a/stackable-app/app/(principal)/error.tsx b/stackable-app/app/(principal)/error.tsx new file mode 100644 index 0000000..3c3260f --- /dev/null +++ b/stackable-app/app/(principal)/error.tsx @@ -0,0 +1,3 @@ +"use client"; + +export { default } from "@/components/dashboard/PortalError"; diff --git a/stackable-app/app/(principal)/loading.tsx b/stackable-app/app/(principal)/loading.tsx new file mode 100644 index 0000000..00d9154 --- /dev/null +++ b/stackable-app/app/(principal)/loading.tsx @@ -0,0 +1 @@ +export { default } from "@/components/dashboard/PortalLoading"; diff --git a/stackable-app/app/(staff)/error.tsx b/stackable-app/app/(staff)/error.tsx new file mode 100644 index 0000000..3c3260f --- /dev/null +++ b/stackable-app/app/(staff)/error.tsx @@ -0,0 +1,3 @@ +"use client"; + +export { default } from "@/components/dashboard/PortalError"; diff --git a/stackable-app/app/(staff)/layout.tsx b/stackable-app/app/(staff)/layout.tsx new file mode 100644 index 0000000..fe845e6 --- /dev/null +++ b/stackable-app/app/(staff)/layout.tsx @@ -0,0 +1,8 @@ +import { requireRole } from "@/lib/api/guard"; +import { DashboardShell } from "@/components/dashboard/DashboardShell"; + +// Staff portal (/staff/*) for finance, secretary and driver. Menu: lib/nav.ts -> NAV.staff. +export default async function StaffLayout({ children }: { children: React.ReactNode }) { + await requireRole("staff"); + return {children}; +} diff --git a/stackable-app/app/(staff)/loading.tsx b/stackable-app/app/(staff)/loading.tsx new file mode 100644 index 0000000..00d9154 --- /dev/null +++ b/stackable-app/app/(staff)/loading.tsx @@ -0,0 +1 @@ +export { default } from "@/components/dashboard/PortalLoading"; diff --git a/stackable-app/app/(staff)/staff/[...slug]/page.tsx b/stackable-app/app/(staff)/staff/[...slug]/page.tsx new file mode 100644 index 0000000..0df074a --- /dev/null +++ b/stackable-app/app/(staff)/staff/[...slug]/page.tsx @@ -0,0 +1,8 @@ +import { ComingSoon } from "@/components/ui"; +import { prettifySegment } from "@/lib/nav"; + +// Every unbuilt /staff page shows the same placeholder; remove a route's tag in lib/nav.ts when it is built. +export default async function StaffComingSoonPage({ params }: { params: Promise<{ slug: string[] }> }) { + const { slug } = await params; + return ; +} diff --git a/stackable-app/app/(staff)/staff/page.tsx b/stackable-app/app/(staff)/staff/page.tsx new file mode 100644 index 0000000..cd5b4a6 --- /dev/null +++ b/stackable-app/app/(staff)/staff/page.tsx @@ -0,0 +1,11 @@ +import { ComingSoon } from "@/components/ui"; + +export default function StaffHomePage() { + return ( + + ); +} diff --git a/stackable-app/app/(staff)/staff/settings/page.tsx b/stackable-app/app/(staff)/staff/settings/page.tsx new file mode 100644 index 0000000..9c0da07 --- /dev/null +++ b/stackable-app/app/(staff)/staff/settings/page.tsx @@ -0,0 +1,10 @@ +import TwoFactorSetup from "@/components/auth/TwoFactorSetup"; + +export default function StaffSettingsPage() { + return ( +
+

Settings

+ +
+ ); +} diff --git a/stackable-app/app/(student)/error.tsx b/stackable-app/app/(student)/error.tsx new file mode 100644 index 0000000..3c3260f --- /dev/null +++ b/stackable-app/app/(student)/error.tsx @@ -0,0 +1,3 @@ +"use client"; + +export { default } from "@/components/dashboard/PortalError"; diff --git a/stackable-app/app/(student)/learn/homework/page.tsx b/stackable-app/app/(student)/learn/homework/page.tsx index a0c0007..2af1abd 100644 --- a/stackable-app/app/(student)/learn/homework/page.tsx +++ b/stackable-app/app/(student)/learn/homework/page.tsx @@ -1,20 +1,11 @@ -import { NotebookPen } from "lucide-react"; +import { ComingSoon } from "@/components/ui"; export default function HomeworkPage() { return ( -
-

Homework

-
-
- -
-
-

Coming Soon

-

- Homework assignments and submissions will be available here shortly. -

-
-
-
+ ); } diff --git a/stackable-app/app/(student)/learn/subjects/page.tsx b/stackable-app/app/(student)/learn/subjects/page.tsx index ee39f6a..7fc2e18 100644 --- a/stackable-app/app/(student)/learn/subjects/page.tsx +++ b/stackable-app/app/(student)/learn/subjects/page.tsx @@ -1,20 +1,11 @@ -import { BookOpenText } from "lucide-react"; +import { ComingSoon } from "@/components/ui"; -export default function SubjectsPage() { +export default function MySubjectsPage() { return ( -
-

My Subjects

-
-
- -
-
-

Coming Soon

-

- Your subject details and resources will be available here shortly. -

-
-
-
+ ); } diff --git a/stackable-app/app/(student)/loading.tsx b/stackable-app/app/(student)/loading.tsx new file mode 100644 index 0000000..00d9154 --- /dev/null +++ b/stackable-app/app/(student)/loading.tsx @@ -0,0 +1 @@ +export { default } from "@/components/dashboard/PortalLoading"; diff --git a/stackable-app/app/(teacher)/error.tsx b/stackable-app/app/(teacher)/error.tsx new file mode 100644 index 0000000..3c3260f --- /dev/null +++ b/stackable-app/app/(teacher)/error.tsx @@ -0,0 +1,3 @@ +"use client"; + +export { default } from "@/components/dashboard/PortalError"; diff --git a/stackable-app/app/(teacher)/loading.tsx b/stackable-app/app/(teacher)/loading.tsx new file mode 100644 index 0000000..00d9154 --- /dev/null +++ b/stackable-app/app/(teacher)/loading.tsx @@ -0,0 +1 @@ +export { default } from "@/components/dashboard/PortalLoading"; diff --git a/stackable-app/app/(teacher)/teach/classes/page.tsx b/stackable-app/app/(teacher)/teach/classes/page.tsx index 1d06e9c..bfd0b01 100644 --- a/stackable-app/app/(teacher)/teach/classes/page.tsx +++ b/stackable-app/app/(teacher)/teach/classes/page.tsx @@ -1,13 +1,11 @@ +import { ComingSoon } from "@/components/ui"; + export default function MyClassesPage() { return ( -
-

My Classes

-
-

Coming Soon

-

- Detailed class management will be available here. -

-
-
+ ); } diff --git a/stackable-app/app/(teacher)/teach/subjects/page.tsx b/stackable-app/app/(teacher)/teach/subjects/page.tsx index 1bde246..6bc8720 100644 --- a/stackable-app/app/(teacher)/teach/subjects/page.tsx +++ b/stackable-app/app/(teacher)/teach/subjects/page.tsx @@ -1,13 +1,11 @@ +import { ComingSoon } from "@/components/ui"; + export default function MySubjectsPage() { return ( -
-

My Subjects

-
-

Coming Soon

-

- Subject management and resources will be available here. -

-
-
+ ); } diff --git a/stackable-app/app/api/health/route.ts b/stackable-app/app/api/health/route.ts new file mode 100644 index 0000000..dc73018 --- /dev/null +++ b/stackable-app/app/api/health/route.ts @@ -0,0 +1,18 @@ +// GET /api/health -> liveness: the process is up. Public, no dependencies checked. +import { NextResponse } from "next/server"; + +export const dynamic = "force-dynamic"; + +const startedAt = Date.now(); + +export function GET() { + return NextResponse.json( + { + status: "ok", + name: "gateway", + version: process.env.BUILD_VERSION ?? "dev", + uptimeSeconds: Math.round((Date.now() - startedAt) / 1000), + }, + { headers: { "Cache-Control": "no-store" } }, + ); +} diff --git a/stackable-app/app/api/ready/route.ts b/stackable-app/app/api/ready/route.ts new file mode 100644 index 0000000..614188f --- /dev/null +++ b/stackable-app/app/api/ready/route.ts @@ -0,0 +1,34 @@ +// GET /api/ready -> readiness: 503 when the database is down, "degraded" when only an optional dependency is. +import { NextResponse } from "next/server"; +import { getRedis } from "@/lib/api/redis"; +import { pingDatabase } from "@/lib/repositories/dev-console.repo"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +type Check = { name: string; ok: boolean; required: boolean; latencyMs: number | null }; + +async function probe(name: string, required: boolean, run: () => Promise): Promise { + const startedAt = performance.now(); + try { + // 5 s: a sleeping serverless database can take seconds on the first query. + await Promise.race([run(), new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), 5000))]); + return { name, ok: true, required, latencyMs: Math.round(performance.now() - startedAt) }; + } catch { + return { name, ok: false, required, latencyMs: null }; + } +} + +export async function GET() { + const redisConfigured = Boolean(process.env.UPSTASH_REDIS_REST_URL && process.env.UPSTASH_REDIS_REST_TOKEN); + const checks = await Promise.all([ + probe("database", true, pingDatabase), + ...(redisConfigured ? [probe("redis", false, () => getRedis().ping())] : []), + ]); + const requiredDown = checks.some((c) => c.required && !c.ok); + const status = requiredDown ? "unavailable" : checks.some((c) => !c.ok) ? "degraded" : "ready"; + return NextResponse.json( + { status, checks: checks.map(({ name, ok, latencyMs }) => ({ name, ok, latencyMs })) }, + { status: requiredDown ? 503 : 200, headers: { "Cache-Control": "no-store" } }, + ); +} diff --git a/stackable-app/app/dashboard/ai/flashcard-maker/page.tsx b/stackable-app/app/dashboard/ai/flashcard-maker/page.tsx index 827b31a..262cf1d 100644 --- a/stackable-app/app/dashboard/ai/flashcard-maker/page.tsx +++ b/stackable-app/app/dashboard/ai/flashcard-maker/page.tsx @@ -1,8 +1,11 @@ +import { ComingSoon } from "@/components/ui"; -export default function flashcardMaker() { - return ( -
-

Flashcard Maker

-
- ); +export default function AIFlashcardMakerPage() { + return ( + + ); } diff --git a/stackable-app/app/dashboard/ai/homework-assistant/page.tsx b/stackable-app/app/dashboard/ai/homework-assistant/page.tsx index cddae34..d5aa98b 100644 --- a/stackable-app/app/dashboard/ai/homework-assistant/page.tsx +++ b/stackable-app/app/dashboard/ai/homework-assistant/page.tsx @@ -1,8 +1,11 @@ +import { ComingSoon } from "@/components/ui"; -export default function homeworkAssistant() { - return ( -
-

Homework Assistant

-
- ); +export default function AIHomeworkAssistantPage() { + return ( + + ); } diff --git a/stackable-app/app/dashboard/ai/quiz-generator/page.tsx b/stackable-app/app/dashboard/ai/quiz-generator/page.tsx index 8825ef7..b15129c 100644 --- a/stackable-app/app/dashboard/ai/quiz-generator/page.tsx +++ b/stackable-app/app/dashboard/ai/quiz-generator/page.tsx @@ -1,8 +1,11 @@ +import { ComingSoon } from "@/components/ui"; -export default function quizGenerator() { - return ( -
-

Quiz Generator

-
- ); +export default function AIQuizGeneratorPage() { + return ( + + ); } diff --git a/stackable-app/app/dashboard/ai/summarizer/page.tsx b/stackable-app/app/dashboard/ai/summarizer/page.tsx index cea97a4..8b21a8e 100644 --- a/stackable-app/app/dashboard/ai/summarizer/page.tsx +++ b/stackable-app/app/dashboard/ai/summarizer/page.tsx @@ -1,8 +1,11 @@ +import { ComingSoon } from "@/components/ui"; -export default function Summarizer() { - return ( -
-

Summarizer

-
- ); +export default function AISummarizerPage() { + return ( + + ); } diff --git a/stackable-app/app/dashboard/analytics/page.tsx b/stackable-app/app/dashboard/analytics/page.tsx index 0c7486b..ee1d1ce 100644 --- a/stackable-app/app/dashboard/analytics/page.tsx +++ b/stackable-app/app/dashboard/analytics/page.tsx @@ -1,9 +1,11 @@ -import React from 'react' +import { ComingSoon } from "@/components/ui"; -const analytics = () => { +export default function AnalyticsPage() { return ( -
analytics
- ) + + ); } - -export default analytics \ No newline at end of file diff --git a/stackable-app/app/dashboard/calender/page.tsx b/stackable-app/app/dashboard/calender/page.tsx index 4605eae..cec0ba6 100644 --- a/stackable-app/app/dashboard/calender/page.tsx +++ b/stackable-app/app/dashboard/calender/page.tsx @@ -1,5 +1,11 @@ -export default function Page() { - return ( -
Calender
- ); -} \ No newline at end of file +import { ComingSoon } from "@/components/ui"; + +export default function CalendarPage() { + return ( + + ); +} diff --git a/stackable-app/app/dashboard/classes/page.tsx b/stackable-app/app/dashboard/classes/page.tsx index a959bc8..f6c0d2f 100644 --- a/stackable-app/app/dashboard/classes/page.tsx +++ b/stackable-app/app/dashboard/classes/page.tsx @@ -1,8 +1,11 @@ +import { ComingSoon } from "@/components/ui"; export default function ClassesPage() { - return ( -
-

Classes

-
- ); + return ( + + ); } diff --git a/stackable-app/app/dashboard/cognitive-abilities-test/page.tsx b/stackable-app/app/dashboard/cognitive-abilities-test/page.tsx index 3f517de..292f878 100644 --- a/stackable-app/app/dashboard/cognitive-abilities-test/page.tsx +++ b/stackable-app/app/dashboard/cognitive-abilities-test/page.tsx @@ -1,8 +1,11 @@ +import { ComingSoon } from "@/components/ui"; -export default function catPage() { - return ( -
-

Cognitive Abilities Test

-
- ); +export default function CognitiveAbilitiesTestPage() { + return ( + + ); } diff --git a/stackable-app/app/dashboard/error.tsx b/stackable-app/app/dashboard/error.tsx new file mode 100644 index 0000000..3c3260f --- /dev/null +++ b/stackable-app/app/dashboard/error.tsx @@ -0,0 +1,3 @@ +"use client"; + +export { default } from "@/components/dashboard/PortalError"; diff --git a/stackable-app/app/dashboard/exams/page.tsx b/stackable-app/app/dashboard/exams/page.tsx index 8f1da3f..85f0a5d 100644 --- a/stackable-app/app/dashboard/exams/page.tsx +++ b/stackable-app/app/dashboard/exams/page.tsx @@ -1,8 +1,11 @@ +import { ComingSoon } from "@/components/ui"; -export default function examsPage() { - return ( -
-

Exams

-
- ); +export default function ExamsPage() { + return ( + + ); } diff --git a/stackable-app/app/dashboard/grades-reports/page.tsx b/stackable-app/app/dashboard/grades-reports/page.tsx index 03408f7..8e13239 100644 --- a/stackable-app/app/dashboard/grades-reports/page.tsx +++ b/stackable-app/app/dashboard/grades-reports/page.tsx @@ -1,8 +1,11 @@ +import { ComingSoon } from "@/components/ui"; -export default function GradesReportsPage() { - return ( -
-

Grades & Reports

-
- ); +export default function GradesAndReportsPage() { + return ( + + ); } diff --git a/stackable-app/app/dashboard/homework/page.tsx b/stackable-app/app/dashboard/homework/page.tsx index 56196a7..c56fb9a 100644 --- a/stackable-app/app/dashboard/homework/page.tsx +++ b/stackable-app/app/dashboard/homework/page.tsx @@ -1,8 +1,11 @@ +import { ComingSoon } from "@/components/ui"; -export default function homeworkPage() { - return ( -
-

Homework

-
- ); +export default function HomeworkPage() { + return ( + + ); } diff --git a/stackable-app/app/dashboard/inbox/page.tsx b/stackable-app/app/dashboard/inbox/page.tsx index c8154fa..2e6a520 100644 --- a/stackable-app/app/dashboard/inbox/page.tsx +++ b/stackable-app/app/dashboard/inbox/page.tsx @@ -1,5 +1,11 @@ -export default function inbox() { - return ( -
Inbox
- ); -} \ No newline at end of file +import { ComingSoon } from "@/components/ui"; + +export default function InboxPage() { + return ( + + ); +} diff --git a/stackable-app/app/dashboard/library/page.tsx b/stackable-app/app/dashboard/library/page.tsx index 1cd7b85..a3b14d3 100644 --- a/stackable-app/app/dashboard/library/page.tsx +++ b/stackable-app/app/dashboard/library/page.tsx @@ -1,8 +1,11 @@ +import { ComingSoon } from "@/components/ui"; export default function LibraryPage() { - return ( -
-

Library

-
- ); + return ( + + ); } diff --git a/stackable-app/app/dashboard/live-activity/page.tsx b/stackable-app/app/dashboard/live-activity/page.tsx index 983ebb8..e4be944 100644 --- a/stackable-app/app/dashboard/live-activity/page.tsx +++ b/stackable-app/app/dashboard/live-activity/page.tsx @@ -1,9 +1,11 @@ -import React from 'react' +import { ComingSoon } from "@/components/ui"; -const page = () => { +export default function LiveActivityPage() { return ( -
page
- ) + + ); } - -export default page \ No newline at end of file diff --git a/stackable-app/app/dashboard/live-activity/real-time/page.tsx b/stackable-app/app/dashboard/live-activity/real-time/page.tsx index 91e7f35..3cb55ad 100644 --- a/stackable-app/app/dashboard/live-activity/real-time/page.tsx +++ b/stackable-app/app/dashboard/live-activity/real-time/page.tsx @@ -1,8 +1,11 @@ +import { ComingSoon } from "@/components/ui"; -export default function Page() { - return ( -
-

Page

-
- ); +export default function RealTimeEventsPage() { + return ( + + ); } diff --git a/stackable-app/app/dashboard/live-activity/transport-status/page.tsx b/stackable-app/app/dashboard/live-activity/transport-status/page.tsx index 0480e66..da48b27 100644 --- a/stackable-app/app/dashboard/live-activity/transport-status/page.tsx +++ b/stackable-app/app/dashboard/live-activity/transport-status/page.tsx @@ -1,5 +1,11 @@ -export default function Page() { - return ( -
- ); -} \ No newline at end of file +import { ComingSoon } from "@/components/ui"; + +export default function TransportStatusPage() { + return ( + + ); +} diff --git a/stackable-app/app/dashboard/loading.tsx b/stackable-app/app/dashboard/loading.tsx new file mode 100644 index 0000000..00d9154 --- /dev/null +++ b/stackable-app/app/dashboard/loading.tsx @@ -0,0 +1 @@ +export { default } from "@/components/dashboard/PortalLoading"; diff --git a/stackable-app/app/dashboard/message-app/page.tsx b/stackable-app/app/dashboard/message-app/page.tsx index 22becdd..cafc75d 100644 --- a/stackable-app/app/dashboard/message-app/page.tsx +++ b/stackable-app/app/dashboard/message-app/page.tsx @@ -1,5 +1,11 @@ -export default function Page() { - return ( -
new
- ); -} \ No newline at end of file +import { ComingSoon } from "@/components/ui"; + +export default function MessagesPage() { + return ( + + ); +} diff --git a/stackable-app/app/dashboard/notifications/page.tsx b/stackable-app/app/dashboard/notifications/page.tsx index 18956db..39c77a1 100644 --- a/stackable-app/app/dashboard/notifications/page.tsx +++ b/stackable-app/app/dashboard/notifications/page.tsx @@ -1,9 +1,11 @@ -import React from 'react' +import { ComingSoon } from "@/components/ui"; -const notifications = () => { +export default function NotificationsPage() { return ( -
notifications
- ) + + ); } - -export default notifications \ No newline at end of file diff --git a/stackable-app/app/dashboard/parents/page.tsx b/stackable-app/app/dashboard/parents/page.tsx index 0b1b9d2..93396d4 100644 --- a/stackable-app/app/dashboard/parents/page.tsx +++ b/stackable-app/app/dashboard/parents/page.tsx @@ -1,7 +1,11 @@ -export default function Parents() { - return ( - <> - Parents - - ) - }; \ No newline at end of file +import { ComingSoon } from "@/components/ui"; + +export default function ParentsAndGuardiansPage() { + return ( + + ); +} diff --git a/stackable-app/app/dashboard/parents/parents-resources/page.tsx b/stackable-app/app/dashboard/parents/parents-resources/page.tsx index b7146f8..404d30a 100644 --- a/stackable-app/app/dashboard/parents/parents-resources/page.tsx +++ b/stackable-app/app/dashboard/parents/parents-resources/page.tsx @@ -1,7 +1,11 @@ -export default function parentResources() { - return ( - <> - Resources - - ) - }; \ No newline at end of file +import { ComingSoon } from "@/components/ui"; + +export default function ParentResourcesPage() { + return ( + + ); +} diff --git a/stackable-app/app/dashboard/payments/events/maintenance/page.tsx b/stackable-app/app/dashboard/payments/events/maintenance/page.tsx index 3de74e1..0933fb5 100644 --- a/stackable-app/app/dashboard/payments/events/maintenance/page.tsx +++ b/stackable-app/app/dashboard/payments/events/maintenance/page.tsx @@ -1,5 +1,11 @@ -export default function Page() { - return ( -
Page
- ); -} \ No newline at end of file +import { ComingSoon } from "@/components/ui"; + +export default function EventMaintenancePage() { + return ( + + ); +} diff --git a/stackable-app/app/dashboard/payments/events/page.tsx b/stackable-app/app/dashboard/payments/events/page.tsx index 0480e66..1c3c593 100644 --- a/stackable-app/app/dashboard/payments/events/page.tsx +++ b/stackable-app/app/dashboard/payments/events/page.tsx @@ -1,5 +1,11 @@ -export default function Page() { - return ( -
- ); -} \ No newline at end of file +import { ComingSoon } from "@/components/ui"; + +export default function EventPaymentsPage() { + return ( + + ); +} diff --git a/stackable-app/app/dashboard/payments/page.tsx b/stackable-app/app/dashboard/payments/page.tsx index 983ebb8..9cf8a65 100644 --- a/stackable-app/app/dashboard/payments/page.tsx +++ b/stackable-app/app/dashboard/payments/page.tsx @@ -1,9 +1,11 @@ -import React from 'react' +import { ComingSoon } from "@/components/ui"; -const page = () => { +export default function PaymentsPage() { return ( -
page
- ) + + ); } - -export default page \ No newline at end of file diff --git a/stackable-app/app/dashboard/payments/salaries/page.tsx b/stackable-app/app/dashboard/payments/salaries/page.tsx index 589254d..b8fe654 100644 --- a/stackable-app/app/dashboard/payments/salaries/page.tsx +++ b/stackable-app/app/dashboard/payments/salaries/page.tsx @@ -1,5 +1,11 @@ -export default function Page() { - return ( -
salaries
- ); -} \ No newline at end of file +import { ComingSoon } from "@/components/ui"; + +export default function SalariesPage() { + return ( + + ); +} diff --git a/stackable-app/app/dashboard/payments/school-fees/page.tsx b/stackable-app/app/dashboard/payments/school-fees/page.tsx index b9d3567..f9ded15 100644 --- a/stackable-app/app/dashboard/payments/school-fees/page.tsx +++ b/stackable-app/app/dashboard/payments/school-fees/page.tsx @@ -1,5 +1,11 @@ -export default function Page() { - return ( -
fees
- ); -} \ No newline at end of file +import { ComingSoon } from "@/components/ui"; + +export default function SchoolFeesPage() { + return ( + + ); +} diff --git a/stackable-app/app/dashboard/quizes/page.tsx b/stackable-app/app/dashboard/quizes/page.tsx index 778c1ab..79e897b 100644 --- a/stackable-app/app/dashboard/quizes/page.tsx +++ b/stackable-app/app/dashboard/quizes/page.tsx @@ -1,8 +1,11 @@ +import { ComingSoon } from "@/components/ui"; -export default function quizesPage() { - return ( -
-

Quizes

-
- ); +export default function QuizzesPage() { + return ( + + ); } diff --git a/stackable-app/app/dashboard/staff/duty-allocation/page.tsx b/stackable-app/app/dashboard/staff/duty-allocation/page.tsx index 31270f4..3a17e05 100644 --- a/stackable-app/app/dashboard/staff/duty-allocation/page.tsx +++ b/stackable-app/app/dashboard/staff/duty-allocation/page.tsx @@ -1,8 +1,11 @@ +import { ComingSoon } from "@/components/ui"; export default function DutyAllocationPage() { - return ( -
-

Duty Allocation

-
- ); + return ( + + ); } diff --git a/stackable-app/app/dashboard/staff/page.tsx b/stackable-app/app/dashboard/staff/page.tsx index fdc579a..13a9eba 100644 --- a/stackable-app/app/dashboard/staff/page.tsx +++ b/stackable-app/app/dashboard/staff/page.tsx @@ -1,8 +1,11 @@ +import { ComingSoon } from "@/components/ui"; export default function StaffPage() { - return ( -
-

Staff Dashboard

-
- ); + return ( + + ); } diff --git a/stackable-app/app/dashboard/staff/staff-resources/page.tsx b/stackable-app/app/dashboard/staff/staff-resources/page.tsx index 0fc9209..772a081 100644 --- a/stackable-app/app/dashboard/staff/staff-resources/page.tsx +++ b/stackable-app/app/dashboard/staff/staff-resources/page.tsx @@ -1,8 +1,11 @@ +import { ComingSoon } from "@/components/ui"; export default function StaffResourcesPage() { - return ( -
-

Staff Resources

-
- ); + return ( + + ); } diff --git a/stackable-app/app/dashboard/students/allocation/page.tsx b/stackable-app/app/dashboard/students/allocation/page.tsx index 0de65da..276c31e 100644 --- a/stackable-app/app/dashboard/students/allocation/page.tsx +++ b/stackable-app/app/dashboard/students/allocation/page.tsx @@ -1,8 +1,11 @@ +import { ComingSoon } from "@/components/ui"; -export default function AllocationPage() { - return ( -
-

Allocation

-
- ); +export default function StudentAllocationPage() { + return ( + + ); } diff --git a/stackable-app/app/dashboard/students/students-resources/page.tsx b/stackable-app/app/dashboard/students/students-resources/page.tsx index 7352ca6..8e54953 100644 --- a/stackable-app/app/dashboard/students/students-resources/page.tsx +++ b/stackable-app/app/dashboard/students/students-resources/page.tsx @@ -1,8 +1,11 @@ +import { ComingSoon } from "@/components/ui"; export default function StudentResourcesPage() { - return ( -
-

Student Resources

-
- ); + return ( + + ); } diff --git a/stackable-app/app/dashboard/teachers/load-allocation/page.tsx b/stackable-app/app/dashboard/teachers/load-allocation/page.tsx index d50f0e2..9153262 100644 --- a/stackable-app/app/dashboard/teachers/load-allocation/page.tsx +++ b/stackable-app/app/dashboard/teachers/load-allocation/page.tsx @@ -1,8 +1,11 @@ +import { ComingSoon } from "@/components/ui"; export default function LoadAllocationPage() { - return ( -
-

Load Allocation

-
- ); + return ( + + ); } diff --git a/stackable-app/app/dashboard/teachers/teachers-resources/page.tsx b/stackable-app/app/dashboard/teachers/teachers-resources/page.tsx index 0b45c1e..e018dd2 100644 --- a/stackable-app/app/dashboard/teachers/teachers-resources/page.tsx +++ b/stackable-app/app/dashboard/teachers/teachers-resources/page.tsx @@ -1,8 +1,11 @@ +import { ComingSoon } from "@/components/ui"; export default function TeacherResourcesPage() { - return ( -
-

Teacher Resources

-
- ); + return ( + + ); } diff --git a/stackable-app/app/demo/page.tsx b/stackable-app/app/demo/page.tsx new file mode 100644 index 0000000..ea1585d --- /dev/null +++ b/stackable-app/app/demo/page.tsx @@ -0,0 +1,52 @@ +"use client"; + +// Public demo entry: pick a role, explore the real screens with sample data. No login, nothing is saved. +import { Icon } from "@/components/ui"; +import { startDemo } from "@/components/demo/DemoProvider"; +import { ROLE_HOME, type Role } from "@/lib/validation/shared"; + +const ROLES_SHOWN: Array<{ role: Role; label: string; blurb: string; icon: string }> = [ + { role: "parent", label: "Parent", blurb: "Children, fees, notices and safety alerts.", icon: "user-hands" }, + { role: "student", label: "Student", blurb: "Homework, the AI tutor, tests and grades.", icon: "square-academic-cap" }, + { role: "teacher", label: "Teacher", blurb: "Classes, marking, attendance and timetable.", icon: "users-group-two-rounded" }, + { role: "admin", label: "Principal", blurb: "School overview, people, academics and approvals.", icon: "buildings-2" }, + { role: "finance", label: "Finance", blurb: "Fee structures, invoices and reconciliation.", icon: "wallet-money" }, + { role: "secretary", label: "Secretary", blurb: "Records, pickup checks and announcements.", icon: "folder-with-files" }, + { role: "driver", label: "Driver", blurb: "Route manifest and bus duty.", icon: "bus" }, + { role: "super-admin", label: "Platform admin", blurb: "Schools, users, audit trail and integrations.", icon: "code-square" }, +]; + +export default function DemoPage() { + return ( +
+

Try Stackable

+

+ Choose a role to explore the screens with sample data. No sign-in needed. Anything you change is kept only + for this browser session and disappears when it ends. +

+
    + {ROLES_SHOWN.map(({ role, label, blurb, icon }) => ( +
  • + +
  • + ))} +
+

+ Some pages show "Coming soon" while we build them. Have an account? Sign in. +

+
+ ); +} diff --git a/stackable-app/app/error.tsx b/stackable-app/app/error.tsx new file mode 100644 index 0000000..123073c --- /dev/null +++ b/stackable-app/app/error.tsx @@ -0,0 +1,28 @@ +"use client"; + +import * as Sentry from "@sentry/nextjs"; +import { useEffect } from "react"; +import { Button, EmptyState } from "@/components/ui"; + +export default function RootError({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + useEffect(() => { + Sentry.captureException(error); + }, [error]); + + return ( +
+ Try again} + /> +
+ ); +} diff --git a/stackable-app/app/layout.tsx b/stackable-app/app/layout.tsx index 2b631c7..f8dbd4f 100644 --- a/stackable-app/app/layout.tsx +++ b/stackable-app/app/layout.tsx @@ -10,6 +10,7 @@ import Acumin from 'next/font/local'; import { ToastProvider } from "../components/toast/ToastProvider"; import { ConfirmationProvider } from "../components/confirmation/ConfirmationProvider"; import { QueryProvider } from "@/components/providers/QueryProvider"; +import { DemoProvider } from "@/components/demo/DemoProvider"; import { PostHogProvider } from "@/components/providers/PostHogProvider"; import { ThemeProvider as NextThemesProvider } from "next-themes"; import type { ThemeProviderProps } from "next-themes"; @@ -128,11 +129,13 @@ export default function RootLayout({ className={`${geistSans.variable} ${openSans.variable} ${geistMono.variable} ${Abeezee.variable} ${inter.variable} ${poppins.variable} ${acumin.variable} ${plusJakartaSans.variable} ${manrope.variable} ${spaceGrotesk.variable} antialiased `}> - - - {children} - - + + + + {children} + + + diff --git a/stackable-app/app/not-found.tsx b/stackable-app/app/not-found.tsx new file mode 100644 index 0000000..8441348 --- /dev/null +++ b/stackable-app/app/not-found.tsx @@ -0,0 +1,18 @@ +import { Button, EmptyState } from "@/components/ui"; + +export default function NotFound() { + return ( +
+ + Go home + + } + /> +
+ ); +} diff --git a/stackable-app/components/admin/users/Badges.tsx b/stackable-app/components/admin/users/Badges.tsx index d450b76..15d9140 100644 --- a/stackable-app/components/admin/users/Badges.tsx +++ b/stackable-app/components/admin/users/Badges.tsx @@ -15,6 +15,10 @@ const ROLE_TONE: Record = { pupil: "warning", parent: "neutral", staff: "neutral", + "dept-head": "success", + finance: "info", + secretary: "neutral", + driver: "neutral", }; export function RoleBadge({ role, size = "md" }: { role: Role; size?: "sm" | "md" }) { diff --git a/stackable-app/components/admin/users/userUtils.ts b/stackable-app/components/admin/users/userUtils.ts index d0ce45b..20224d2 100644 --- a/stackable-app/components/admin/users/userUtils.ts +++ b/stackable-app/components/admin/users/userUtils.ts @@ -17,6 +17,10 @@ export const ROLE_LABEL: Record = { student: "Student", pupil: "Pupil", staff: "Staff", + "dept-head": "Department head", + finance: "Finance", + secretary: "Secretary", + driver: "Driver", }; export const STATUS_LABEL: Record = { diff --git a/stackable-app/components/dashboard/MobileBottomNav.tsx b/stackable-app/components/dashboard/MobileBottomNav.tsx index 5203176..8285dbd 100644 --- a/stackable-app/components/dashboard/MobileBottomNav.tsx +++ b/stackable-app/components/dashboard/MobileBottomNav.tsx @@ -16,6 +16,7 @@ import { usePathname } from "next/navigation"; import type { Ref } from "react"; import { Icon } from "@/components/ui/Icon"; import { cn } from "@/lib/cn"; +import { useMe } from "@/hooks/useMe"; import { activeNavHref, primaryNav } from "@/lib/nav"; import type { Portal } from "@/lib/validation/shared"; import { NavIcon } from "@/components/sidebar/sidebar"; @@ -35,8 +36,9 @@ const ITEM = export function MobileBottomNav({ portal, menuOpen, onOpenMenu, moreRef, inert }: MobileBottomNavProps) { const pathname = usePathname(); - const activeHref = activeNavHref(portal, pathname); - const items = primaryNav(portal, 4); + const role = useMe().data?.role; + const activeHref = activeNavHref(portal, pathname, role); + const items = primaryNav(portal, 4, role); return (