diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..6ff95815 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,107 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +Stack: SvelteKit (adapter-node) + Svelte 5 (runes) + Prisma (pg adapter) + Vitest, pnpm. + +## Commands + +Full command table in `README.md`; environment setup and code-style/hooks in `CONTRIBUTING.md`. Most-used: + +- `pnpm dev` — dev server +- `pnpm test` — Vitest (watch). Single run: `pnpm test run `; by name: `pnpm test run -t ""`. +- `pnpm check` — `svelte-check` + type check (fails on warnings) +- `pnpm db:generate` after editing `prisma/schema.prisma`; `pnpm db:migrate` to create/apply a migration + +## Architecture + +Two apps in one SvelteKit project, split by route group, each with its own auth realm: + +- `src/routes/(main)/` — learner-facing app (`learner.session` cookie) +- `src/routes/admin/` — admin app (`admin.session` cookie; requires an active `UserAdmin`) + +**Hook dispatch.** Root `src/hooks.server.ts` routes each request to the matching group hook (`(main)/hooks.server.ts` or `admin/hooks.server.ts`) by `/admin` prefix — group hooks are _not_ auto-run by SvelteKit. Each is a `sequence()` of request logging (scoped pino logger + `X-Request-Id` on `event.locals.logger`) then auth/route-protection. **Auth is enforced centrally in these hooks**; endpoint-level `if (!user) 401` is defense-in-depth only. + +**Auth.** Custom `Auth(valkey, …)` factory in `src/lib/server/auth/` (Google OAuth, sessions in Valkey). Exposes `learnerAuth` and `adminAuth`. + +**Data.** Prisma Client is generated to `src/generated/prisma/` and re-exported (client + enums + model types) from `src/lib/server/db.ts`, which owns the single `db` instance over `@prisma/adapter-pg`. Import Prisma types from `$lib/server/db`, not the generated path. + +**Server integrations** (`src/lib/server/`): `s3.ts` + `cloudfront.ts` (media + signed URLs), `openai.ts` (AI chat), `weaviate.ts` (vector search grounding the chat), `valkey.ts` (sessions + cache), `logger.ts` (pino). Feature logic under `auth/`, `chat/`, `unit/`, `cache/`. + +**Domain (Prisma).** `Collection` → `LearningUnit` (content, sources, sentiments, tags, status) → `LearningJourney` (+ checkpoints, `QuestionAnswer` quizzes); AI chat via `Thread`/`Message`; onboarding via `UserProfile`/`UserInterest`. + +**UI.** Components in `src/lib/components//`; shared rune-based state in `src/lib/states/*.svelte.ts`. + +## Gotchas + +- `src/generated/prisma/` is machine-generated — never hand-edit; regenerate with + `pnpm db:generate` after changing `prisma/schema.prisma`. (It's the only place + using `type X = {}`; hand-written code uses `interface`.) +- `vendor/xlsx-0.20.3.tgz` is a vendored dependency, consumed only by the admin + quiz export. +- **Prisma query args** order keys to follow **SQL clause order** — `select`, + `where`, `orderBy`, `take`, `skip`, `cursor`. Type the args object with the + generated `*FindManyArgs` / `*FindUniqueArgs` via `satisfies`, and derive row + types with `*GetPayload`; never `as const`. Keep Prisma type + annotations when simplifying code. + +## Specs and decisions + +Two linked conventions govern design docs: + +- **Specs** live in `docs/superpowers/specs/YYYY-MM-DD--design.md`. Start + from `docs/superpowers/specs/TEMPLATE.md` and follow + `docs/superpowers/specs/README.md`. A spec carries the WHAT/WHY plus contracts + and boundaries (the unit triple: does / uses / depends, with guarantees and + requires) and records only the **chosen** solution. It must NOT contain + implementation — no function bodies or loop internals; that belongs in the + plan. Cover architecture, components, data flow, error handling, and testing. + Write contract signatures as declaration-level TypeScript (no bodies), using + `interface` for object shapes (the repo lints `consistent-type-definitions: +interface`) and reserving `type` for unions, function, and mapped/utility types. + +- **Decisions** live in `docs/decisions/NNNN-.md` as + [MADR 4.0](https://adr.github.io/madr/) ADRs (start from + `docs/decisions/TEMPLATE.md` — the full template; keep its optional + sections, including Pros and Cons of the Options). When a spec involves a real + architectural choice, the alternatives and rationale go in an ADR; the spec + states the chosen outcome and links the ADR. See `docs/decisions/README.md`. + **Author order: brainstorm → ADR → spec → plan.** The decision and its + rationale are settled in the ADR first; the spec then derives its contracts from + the chosen outcome. (The superpowers brainstorming skill writes the spec — pause + after the design is approved to write the ADR, then return to the spec.) + **ADRs stay prose** — decision, rationale, alternatives, consequences, described + conceptually. An ADR may name external/framework symbols it builds on + (`RequestEvent`, `Readable.toWeb`) but must NOT reference identifiers the spec + defines — no backticked contract names like `fetchBatch`/`onError`, no function + signature or `interface` block. Describe the role ("a batch-fetch closure", "an + error callback"), not the name, so a rename in the spec can never make an + accepted ADR stale; the names live in the spec. + +- **Contracts flow down; never back-fill.** The signature is owned by the spec's + Contracts & boundaries (declaration-level TS); the ADR holds the rationale in + prose; the plan is _derived_ — it may mirror a contract in code for buildability + but is never its source of truth. If plan or implementation work reveals a needed + change to a signature, parameter, return type, error semantics, or a unit's + dependency, **change the source doc first, then regenerate the affected plan + section** — never edit the plan and back-fill the spec/ADR. A pure signature + change (rename, reorder, add a field) is **spec-only**; an approach change with + trade-offs **supersedes the ADR** (new ADR) and updates the spec signature. Plan + self-review adds one check: do the signatures/guarantees in the plan match the + spec's Contracts & boundaries? Grep the contract name across spec, plan, and ADR. + +- **Diagrams:** use Mermaid fenced code blocks in both specs and ADRs. + +## GitHub workflow + +- Use the `gh` CLI for all GitHub operations (issues, PRs) — never raw git for PR + actions and never the web UI. +- **gh-first, documented up front.** Every implementation plan documents the gh + steps at its very start — read the issue (`gh issue view <#>`, when one exists), + create the feature branch, open a **draft** PR (`gh pr create --draft`, body per + `.github/PULL_REQUEST_TEMPLATE.md`) — and documents marking it ready + (`gh pr ready`) as its final step. The gh workflow is established before the + first task, not appended at the end. +- **Document-only; run on approval.** Writing these commands into the plan is not + running them. The agent creates no branch, commit, push, or PR until you + explicitly ask — the "don't commit/PR unless asked" rule stands. diff --git a/Dockerfile b/Dockerfile index 1f2b3ced..06023a86 100644 --- a/Dockerfile +++ b/Dockerfile @@ -27,7 +27,6 @@ ENV PRISMA_SKIP_POSTINSTALL_GENERATE=true # Fetch all dependencies into the virtual store. COPY pnpm-lock.yaml pnpm-workspace.yaml ./ COPY patches ./patches -COPY vendor ./vendor RUN pnpm fetch diff --git a/docs/decisions/0001-stream-report-exports-with-exceljs.md b/docs/decisions/0001-stream-report-exports-with-exceljs.md new file mode 100644 index 00000000..1cc25f34 --- /dev/null +++ b/docs/decisions/0001-stream-report-exports-with-exceljs.md @@ -0,0 +1,69 @@ +--- +status: 'accepted' +date: 2026-06-08 +decision-makers: santosral +consulted: +informed: +--- + +# Stream report exports end-to-end with ExcelJS via a generic helper + +## Context and Problem Statement + +The admin quiz export (`admin/api/download`) loads every matching row into memory, builds the whole workbook, and serializes it to a single buffer before sending, so memory scales linearly with row count. As data grows a single export can spike memory and destabilize the server. We also have a second report (onboarding) coming that would otherwise copy the same buffered pattern. How should report exports be produced so memory stays bounded and the logic is reusable? + +## Decision Drivers + +- Bounded memory regardless of dataset size — neither the full result set nor the full file should be held in memory. +- Reusable across reports so additional exports plug in without re-implementing streaming. +- Keep Prisma's typed query API. +- Avoid duplicating the error-prone streaming loop per endpoint. + +## Considered Options + +- Stream end-to-end with `ExcelJS.stream.xlsx.WorkbookWriter` piped to the HTTP response, encapsulated in a single generic streaming helper +- Buffer-then-send (the current approach) — build the whole workbook in memory, send one buffer +- Thin streaming utilities only — expose helpers but let each endpoint own its streaming loop +- A config/registry-driven export framework that endpoints register against + +## Decision Outcome + +Chosen option: "Stream end-to-end with ExcelJS via a generic streaming helper", because it is the only option that bounds memory on both the DB read and the file write while keeping each endpoint tiny and declarative. + +The helper writes through a Node `stream.PassThrough`, whose readable side is converted with `Readable.toWeb()` and returned as the SvelteKit `Response` body. The write loop runs un-awaited so the response starts streaming immediately; because it is not awaited it carries its own `.catch`. Once streaming starts the status and headers are already sent, so a mid-export error cannot become a clean 500 — the helper hands the failure back to the caller through an injected error callback (which logs server-side) and destroys the stream, leaving the browser with a failed/incomplete download to retry. Keeping the helper logger-agnostic and free of any SvelteKit request object decouples it from the framework and makes it trivially unit-testable. (Cursor batching of the DB read is a separate decision — see [ADR-0002](./0002-keyset-cursor-pagination-on-primary-key.md).) + +### Consequences + +- Good, because memory is bounded regardless of dataset size, removing the DoS vector. +- Good, because one tested helper owns all the streaming complexity and endpoints only declare their columns, a batch-fetch closure, and an error callback. +- Good, because migrating the quiz export off the vendored `xlsx` tarball lets us remove that dependency entirely. +- Bad, because a mid-stream error after headers are sent cannot be a clean error response — the admin sees a broken download and retries. +- Bad, because the un-awaited write loop must carry its own `.catch` or a failure surfaces as an unhandled rejection. +- Bad, because it adds `exceljs` as a dependency. + +### Confirmation + +The quiz endpoint is rewritten to call the streaming helper and the vendored `xlsx` dependency is removed; tests assert the helper drives the batch fetcher to exhaustion, produces a readable stream, and aborts + reports the failure on a fetch error. + +## Pros and Cons of the Options + +### Stream end-to-end via a generic streaming helper + +- Good, because it bounds memory on both read and write. +- Good, because the streaming loop is written and tested once. +- Bad, because the abstraction must be generic enough for every report (a columns + batch-fetch contract). + +### Buffer-then-send (current approach) + +- Good, because errors can still become a clean 500 (nothing is sent yet). +- Bad, because memory scales with row count — the problem we are removing. + +### Thin streaming utilities only + +- Good, because no large abstraction to design. +- Bad, because every endpoint re-implements the error-prone streaming/abort loop. + +### Config/registry-driven framework + +- Good, because exports become pure data. +- Bad, because it is over-engineering for the current two reports (YAGNI). diff --git a/docs/decisions/0002-keyset-cursor-pagination-on-primary-key.md b/docs/decisions/0002-keyset-cursor-pagination-on-primary-key.md new file mode 100644 index 00000000..d1ac556a --- /dev/null +++ b/docs/decisions/0002-keyset-cursor-pagination-on-primary-key.md @@ -0,0 +1,61 @@ +--- +status: 'accepted' +date: 2026-06-08 +decision-makers: santosral +consulted: +informed: +--- + +# Read streaming exports with keyset cursor pagination on the primary key + +## Context and Problem Statement + +The streaming export ([ADR-0001](./0001-stream-report-exports-with-exceljs.md)) reads rows from the database in batches inside a loop that runs until the data is exhausted. How should each batch be fetched so the per-batch cost stays constant at any depth and rows are neither skipped nor duplicated under concurrent writes? + +## Decision Drivers + +- Constant per-batch cost regardless of how deep into the result set the loop has read. +- Stability under concurrent writes — no skipped or duplicated rows. +- Keep Prisma's typed query API. + +## Considered Options + +- Keyset (cursor) pagination ordered by the model's primary key +- Offset pagination (`skip` / `take`) +- A true DB server-side cursor via the raw `pg` driver (`pg-query-stream`) + +## Decision Outcome + +Chosen option: "Keyset pagination ordered by the primary key", because it is linear and index-backed (constant cost per batch) and stable under concurrent writes, while staying within Prisma's typed cursor API. + +A consequence is that the export is **no longer ordered by `user.name`** (today's behavior). Name lives on the related `User` and is non-unique, so it cannot back a clean keyset cursor; since an admin can sort any column in Excel, server-side name ordering is dropped in favor of primary-key ordering. + +### Consequences + +- Good, because batch cost is constant and index-backed at any depth. +- Good, because the read is stable under concurrent inserts/deletes (no skip/duplicate). +- Good, because it stays within Prisma's typed API. +- Bad, because the exported rows are ordered by primary key rather than `user.name`; mitigated because the admin can sort the downloaded file in Excel. +- Neutral, because a true server-side cursor (`pg-query-stream`) would remove repeated queries entirely but bypasses Prisma's typed API — deferred as an escalation path if scale ever demands it. + +### Confirmation + +Endpoint tests assert the cursor advances across multiple batches and the `where` filter is honored; the `fetchBatch` contract returns `{ rows, nextCursor }` and the loop terminates when `nextCursor` is undefined. + +## Pros and Cons of the Options + +### Keyset cursor on the primary key + +- Good, because cost per batch is constant and index-backed. +- Good, because it is stable under concurrent writes. +- Bad, because ordering is tied to the key, not a human-friendly column. + +### Offset pagination (`skip` / `take`) + +- Good, because it can order by any column, including `user.name`. +- Bad, because cost grows with depth and rows can be skipped or duplicated when the underlying set changes mid-export. + +### Raw `pg` server-side cursor (`pg-query-stream`) + +- Good, because it removes repeated queries entirely. +- Bad, because it bypasses Prisma's typed API — too much for current scale (deferred). diff --git a/docs/decisions/README.md b/docs/decisions/README.md new file mode 100644 index 00000000..b1c0095f --- /dev/null +++ b/docs/decisions/README.md @@ -0,0 +1,40 @@ +# Architecture Decision Records + +This directory holds Architecture Decision Records (ADRs) following the +[MADR 4.0](https://adr.github.io/madr/) standard. Each ADR captures one +architectural decision: the problem, the options considered, the chosen option, +and its consequences (good _and_ bad). + +## Relationship to specs + +Design specs live in [`../superpowers/specs/`](../superpowers/specs/). A spec +records only the **chosen** solution and links out to the relevant ADR here for +the full rationale and the alternatives that were rejected. The decision lives in +the ADR; the spec consumes its outcome. + +> Rule of thumb: if you're writing "we considered X but chose Y because…", that +> belongs in an ADR, not the spec. The spec just states Y and links the ADR. + +## Conventions + +- **One decision per file**, named `NNNN-kebab-title.md` (zero-padded, e.g. + `0001-stream-report-exports-with-exceljs.md`). Numbers are sequential and never + reused. +- **Start from the template:** copy [`TEMPLATE.md`](./TEMPLATE.md) (the + official MADR 4.0 full template). Keep its optional sections — including Pros + and Cons of the Options — so every decision records its alternatives and + rationale. +- **Frontmatter** carries `status`, `date`, `decision-makers`, `consulted`, + `informed`. For a solo/small-team change, `status` + `date` is enough; leave + the rest blank. +- **Status lifecycle:** `proposed` → `accepted` → (later) `deprecated` or + `superseded by ADR-NNNN`. A `rejected` option is recorded too if it was + seriously considered. +- **Immutable once accepted:** don't rewrite an accepted ADR. If the decision + changes, write a new ADR that supersedes it and update the old one's status. + +## Index + +- [0001 — Stream report exports end-to-end with ExcelJS via a generic helper](./0001-stream-report-exports-with-exceljs.md) — accepted +- [0002 — Read streaming exports with keyset cursor pagination on the primary key](./0002-keyset-cursor-pagination-on-primary-key.md) — accepted +- [0003 — Nested per-report download routes and inline query-param tabs](./0003-report-export-routing-and-tabs.md) — accepted diff --git a/docs/decisions/TEMPLATE.md b/docs/decisions/TEMPLATE.md new file mode 100644 index 00000000..07098ae9 --- /dev/null +++ b/docs/decisions/TEMPLATE.md @@ -0,0 +1,85 @@ +--- +# These are optional metadata elements. Feel free to remove any of them. +status: '{proposed | rejected | accepted | deprecated | … | superseded by ADR-0123}' +date: { YYYY-MM-DD when the decision was last updated } +decision-makers: { list everyone involved in the decision } +consulted: + { + list everyone whose opinions are sought (typically subject-matter experts); and with whom there is a two-way communication, + } +informed: + { + list everyone who is kept up-to-date on progress; and with whom there is a one-way communication, + } +--- + +# {short title, representative of solved problem and found solution} + +## Context and Problem Statement + +{Describe the context and problem statement, e.g., in free form using two to three sentences or in the form of an illustrative story. You may want to articulate the problem in form of a question and add links to collaboration boards or issue management systems.} + +<!-- This is an optional element. Feel free to remove. --> + +## Decision Drivers + +- {decision driver 1, e.g., a force, facing concern, …} +- {decision driver 2, e.g., a force, facing concern, …} +- … <!-- numbers of drivers can vary --> + +## Considered Options + +- {title of option 1} +- {title of option 2} +- {title of option 3} +- … <!-- numbers of options can vary --> + +## Decision Outcome + +Chosen option: "{title of option 1}", because {justification. e.g., only option, which meets k.o. criterion decision driver | which resolves force {force} | … | comes out best (see below)}. + +<!-- This is an optional element. Feel free to remove. --> + +### Consequences + +- Good, because {positive consequence, e.g., improvement of one or more desired qualities, …} +- Bad, because {negative consequence, e.g., compromising one or more desired qualities, …} +- … <!-- numbers of consequences can vary --> + +<!-- This is an optional element. Feel free to remove. --> + +### Confirmation + +{Describe how the implementation of/compliance with the ADR can/will be confirmed. Are the design that was decided for and its implementation in line with the decision made? E.g., a design/code review or a test with a library such as ArchUnit can help validate this. Note that although we classify this element as optional, it is included in many ADRs.} + +<!-- This is an optional element. Feel free to remove. --> + +## Pros and Cons of the Options + +### {title of option 1} + +<!-- This is an optional element. Feel free to remove. --> + +{example | description | pointer to more information | …} + +- Good, because {argument a} +- Good, because {argument b} +- Neutral, because {argument c} +- Bad, because {argument d} +- … <!-- numbers of pros and cons can vary --> + +### {title of other option} + +{example | description | pointer to more information | …} + +- Good, because {argument a} +- Good, because {argument b} +- Neutral, because {argument c} +- Bad, because {argument d} +- … + +<!-- This is an optional element. Feel free to remove. --> + +## More Information + +{You might want to provide additional evidence/confidence for the decision outcome here and/or document the team agreement on the decision and/or define when/how this decision the decision should be realized and if/when it should be re-visited. Links to other decisions and resources might appear here as well.} diff --git a/docs/superpowers/plans/2026-06-08-streaming-report-exports.md b/docs/superpowers/plans/2026-06-08-streaming-report-exports.md new file mode 100644 index 00000000..02f6a9da --- /dev/null +++ b/docs/superpowers/plans/2026-06-08-streaming-report-exports.md @@ -0,0 +1,809 @@ +# Streaming Report Exports Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the buffered admin quiz `.xlsx` export with a memory-bounded, end-to-end streaming export built on a reusable `generateReport` helper. + +**Architecture:** A generic `generateReport(options)` helper pipes an `ExcelJS.stream.xlsx.WorkbookWriter` through a Node `PassThrough`, returns its readable side as the SvelteKit `Response` body, and drives an un-awaited write loop that pulls keyset-cursor-batched pages from a caller-supplied `fetchBatch` closure. The helper has no `RequestEvent` or logging dependency — on a mid-stream failure it always destroys the stream and invokes an optional `onError` callback if the caller supplied one. The quiz endpoint moves to `admin/api/download/quiz` and is rewritten to declare only its columns, `fetchBatch`, and an `onError` that logs; the vendored `xlsx` dependency is removed. + +**Tech Stack:** SvelteKit (adapter-node, Node 24), Svelte 5, Prisma (pg adapter), `exceljs`, Vitest. Package manager: `pnpm`. + +**Scope:** This is **PR1 of 2** (Spec A — [streaming foundation + quiz migration](../specs/2026-06-08-streaming-report-exports-design.md)). The onboarding report (Spec B) is a separate plan, stacked on this branch, written after PR1 lands. Decisions: [ADR-0001](../../decisions/0001-stream-report-exports-with-exceljs.md) (streaming via generic helper), [ADR-0002](../../decisions/0002-keyset-cursor-pagination-on-primary-key.md) (keyset cursor on primary key). + +**Conventions (from CLAUDE.md + project memory):** + +- Conventional commit titles, **no scope, title only, no body, no Co-Authored-By**. +- `chore:` for dependency add/remove; `feat:`/`refactor:` for code. +- Tests: AAA with clear Arrange/Act/Assert separation, **inline setup, no extracted test helpers**. +- `interface` for object shapes (lint: `consistent-type-definitions: interface`); `type` only for unions/function/mapped types. Inline type imports. +- Import Prisma types from `$lib/server/db`, never the generated path. +- Prisma query args follow **SQL clause order** — `select`, `where`, `orderBy`, `take`, `skip`, `cursor`. Type the args object with the generated `*FindManyArgs`/`*FindUniqueArgs` via `satisfies`, and derive row types with `*GetPayload<typeof args>` — never `as const`. +- `gh` CLI for all GitHub operations. Branch + **draft** PR are documented up front (below) and `gh pr ready` at the end; **document-only — run none of them until explicitly approved.** +- Run `pnpm` (not `npx`). `pnpm test run <file>` for a single-file run; `pnpm check` for type/svelte check. + +--- + +## File Structure + +| Path | Action | Responsibility | +| ---------------------------------------------------- | -------------- | ------------------------------------------------------------------------------------- | +| `src/lib/server/reports/helpers.ts` | create | Shared pure `sanitizeSpreadsheetCell` and `formatTimestamp` utilities. | +| `src/lib/server/reports/helpers.test.ts` | create | Unit tests for the sanitizer and the timestamp formatter. | +| `src/lib/server/reports/generateReport.ts` | create | `generateReport` helper; owns all streaming, headers, sanitization, abort-on-error. | +| `src/lib/server/reports/generateReport.test.ts` | create | Unit tests for the helper. | +| `src/lib/server/reports/index.ts` | create | Barrel re-exporting `generateReport` and the helpers. | +| `src/routes/admin/api/download/quiz/+server.ts` | create (moved) | Quiz export endpoint; declares columns + `fetchBatch`, delegates to `generateReport`. | +| `src/routes/admin/api/download/quiz/+server.test.ts` | create | Endpoint tests (auth, columns, row mapping, cursor, filter). | +| `src/routes/admin/api/download/+server.ts` | delete | Old buffered endpoint (sole `xlsx` consumer). | +| `src/routes/admin/(protected)/reports/+page.svelte` | modify | Point the download link at the nested `/quiz` route. | +| `package.json` / `pnpm-lock.yaml` | modify | Add `exceljs`; remove `xlsx`. | +| `vendor/xlsx-0.20.3.tgz` | delete | Vendored tarball, no longer referenced. | + +--- + +## Task 0: Branch and draft PR (gh — run on approval) + +> **Documented per the gh-first workflow; the agent runs none of these until you explicitly approve** (the no-commit/PR rule stands). Spec A has no tracking issue, so there is no `gh issue view` step — issue-backed plans start with `gh issue view <#>`. + +- [ ] **Step 1: Create the feature branch** + +```bash +git switch -c feat/streaming-report-exports +``` + +- [ ] **Step 2: Open a draft PR** (once Task 1's first commit exists on the branch; fill the body from `.github/PULL_REQUEST_TEMPLATE.md`) + +```bash +gh pr create --draft --title "feat: stream report exports" +``` + +Commits from the tasks below push to this branch/PR. The final task marks it ready. + +--- + +## Task 1: Add the `exceljs` dependency + +**Files:** + +- Modify: `package.json`, `pnpm-lock.yaml` + +- [ ] **Step 1: Install exceljs** + +Run: `pnpm add exceljs` +Expected: `package.json` gains `"exceljs"` under `dependencies`; `pnpm-lock.yaml` updated. (`exceljs` ships its own types — no `@types/exceljs` needed.) + +- [ ] **Step 2: Verify it resolves** + +Run: `pnpm check` +Expected: PASS (no new errors). + +- [ ] **Step 3: Commit** + +```bash +git add package.json pnpm-lock.yaml +git commit -m "chore: add exceljs dependency" +``` + +--- + +## Task 2: Shared pure utilities (`sanitizeSpreadsheetCell`, `formatTimestamp`) + +Two pure helpers co-located in `reports/helpers.ts`, both shared by the quiz export and the onboarding report (Spec B): `sanitizeSpreadsheetCell` neutralizes CSV/formula injection by prefixing a leading formula-trigger character with `'`; `formatTimestamp` formats a `Date` as a `DDMMYYYYHHmmss` filename prefix. + +**Files:** + +- Create: `src/lib/server/reports/helpers.ts` +- Test: `src/lib/server/reports/helpers.test.ts` + +- [ ] **Step 1: Write the failing tests** + +Create `src/lib/server/reports/helpers.test.ts`: + +```ts +import { describe, expect, test } from 'vitest'; + +import { formatTimestamp, sanitizeSpreadsheetCell } from './helpers.js'; + +describe('sanitizeSpreadsheetCell', () => { + test('prefixes a leading equals sign with a quote', () => { + const value = '=SUM(A1:A2)'; + + const result = sanitizeSpreadsheetCell(value); + + expect(result).toBe("'=SUM(A1:A2)"); + }); + + test('prefixes leading +, -, @, tab, and carriage return', () => { + const values = ['+1', '-1', '@cmd', '\tx', '\rx']; + + const results = values.map(sanitizeSpreadsheetCell); + + expect(results).toEqual(["'+1", "'-1", "'@cmd", "'\tx", "'\rx"]); + }); + + test('leaves safe values unchanged', () => { + const values = ['Ann', 'a@x.co'.slice(1), 'a=b', '']; + + const results = values.map(sanitizeSpreadsheetCell); + + expect(results).toEqual(['Ann', '@x.co', 'a=b', '']); + }); +}); + +describe('formatTimestamp', () => { + test('formats a date as DDMMYYYYHHmmss', () => { + const date = new Date(2026, 5, 8, 14, 30, 45); + + const result = formatTimestamp(date); + + expect(result).toBe('08062026143045'); + }); + + test('zero-pads single-digit day, month, hour, minute, and second', () => { + const date = new Date(2026, 0, 3, 4, 5, 6); + + const result = formatTimestamp(date); + + expect(result).toBe('03012026040506'); + }); +}); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `pnpm test run src/lib/server/reports/helpers.test.ts` +Expected: FAIL — cannot resolve `./helpers.js` / `sanitizeSpreadsheetCell` and `formatTimestamp` are not exported. + +- [ ] **Step 3: Write the minimal implementation** + +Create `src/lib/server/reports/helpers.ts`: + +```ts +export const sanitizeSpreadsheetCell = (value: string): string => { + if (/^[=+\-@\t\r]/.test(value)) { + return `'${value}`; + } + return value; +}; + +export const formatTimestamp = (date: Date): string => { + const dd = String(date.getDate()).padStart(2, '0'); + const mm = String(date.getMonth() + 1).padStart(2, '0'); + const yyyy = date.getFullYear(); + const hh = String(date.getHours()).padStart(2, '0'); + const min = String(date.getMinutes()).padStart(2, '0'); + const ss = String(date.getSeconds()).padStart(2, '0'); + return `${dd}${mm}${yyyy}${hh}${min}${ss}`; +}; +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `pnpm test run src/lib/server/reports/helpers.test.ts` +Expected: PASS (5 tests — 3 sanitizer + 2 timestamp). + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/server/reports/helpers.ts src/lib/server/reports/helpers.test.ts +git commit -m "feat: add spreadsheet cell sanitizer and timestamp formatter" +``` + +--- + +## Task 3: `generateReport` helper + +Turns a column definition + a cursor-driven batch fetcher into a streamed `.xlsx` HTTP response. Bounded memory; sets headers incl. `Cache-Control: no-store`; sanitizes every string cell; un-awaited write loop always destroys the stream on error and calls the caller-supplied `onError` if one was given. The helper takes no `RequestEvent` and has no logging dependency — error observation is inverted to the caller via an optional `onError` callback. + +**Files:** + +- Create: `src/lib/server/reports/generateReport.ts`, `src/lib/server/reports/index.ts` +- Test: `src/lib/server/reports/generateReport.test.ts` + +- [ ] **Step 1: Write the failing tests** + +Create `src/lib/server/reports/generateReport.test.ts`: + +```ts +import { describe, expect, test, vi } from 'vitest'; + +import { generateReport } from './generateReport.js'; + +describe('generateReport', () => { + test('drives fetchBatch until nextCursor is undefined and streams an xlsx body', async () => { + const onError = vi.fn(); + const fetchBatch = vi + .fn() + .mockResolvedValueOnce({ rows: [{ a: 'x' }], nextCursor: 'c1' }) + .mockResolvedValueOnce({ rows: [{ a: 'y' }], nextCursor: undefined }); + + const response = generateReport({ + filename: 'report.xlsx', + sheetName: 'Sheet', + columns: [{ header: 'A', value: (row: { a: string }) => row.a }], + fetchBatch, + onError, + }); + + const reader = response.body!.getReader(); + const chunks: Uint8Array[] = []; + while (true) { + const { value, done } = await reader.read(); + if (done) { + break; + } + chunks.push(value); + } + const bytes = chunks.reduce((acc, c) => acc + c.byteLength, 0); + + expect(fetchBatch).toHaveBeenCalledTimes(2); + expect(fetchBatch).toHaveBeenNthCalledWith(1, undefined); + expect(fetchBatch).toHaveBeenNthCalledWith(2, 'c1'); + expect(response.headers.get('Cache-Control')).toBe('no-store'); + expect(onError).not.toHaveBeenCalled(); + expect(bytes).toBeGreaterThan(0); + expect(chunks[0][0]).toBe(0x50); // 'P' — xlsx is a zip + expect(chunks[0][1]).toBe(0x4b); // 'K' + }); + + test('calls onError and aborts the stream when fetchBatch rejects', async () => { + const onError = vi.fn(); + const fetchBatch = vi.fn().mockRejectedValue(new Error('boom')); + + const response = generateReport({ + filename: 'report.xlsx', + sheetName: 'Sheet', + columns: [{ header: 'A', value: (row: { a: string }) => row.a }], + fetchBatch, + onError, + }); + + const drain = async () => { + const reader = response.body!.getReader(); + while (true) { + const { done } = await reader.read(); + if (done) { + break; + } + } + }; + + await expect(drain()).rejects.toThrow(); + expect(onError).toHaveBeenCalledWith(expect.any(Error)); + }); + + test('aborts the stream without throwing when fetchBatch rejects and no onError is given', async () => { + const fetchBatch = vi.fn().mockRejectedValue(new Error('boom')); + + const response = generateReport({ + filename: 'report.xlsx', + sheetName: 'Sheet', + columns: [{ header: 'A', value: (row: { a: string }) => row.a }], + fetchBatch, + }); + + const drain = async () => { + const reader = response.body!.getReader(); + while (true) { + const { done } = await reader.read(); + if (done) { + break; + } + } + }; + + await expect(drain()).rejects.toThrow(); + }); +}); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `pnpm test run src/lib/server/reports/generateReport.test.ts` +Expected: FAIL — cannot resolve `./generateReport.js` / `generateReport` is not exported. + +- [ ] **Step 3: Write the implementation** + +Create `src/lib/server/reports/generateReport.ts` (imports `sanitizeSpreadsheetCell` from the sibling `helpers.ts`): + +```ts +import { PassThrough, Readable } from 'node:stream'; + +import ExcelJS from 'exceljs'; + +import { sanitizeSpreadsheetCell } from './helpers.js'; + +const SPREADSHEET_CONTENT_TYPE = + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'; + +export interface Column<Row> { + header: string; + value: (row: Row) => string | number | boolean; +} + +export interface GenerateReportOptions<Row, Cursor> { + filename: string; + sheetName: string; + columns: Column<Row>[]; + fetchBatch: ( + cursor: Cursor | undefined, + ) => Promise<{ rows: Row[]; nextCursor: Cursor | undefined }>; + onError?: (err: unknown) => void; +} + +export const generateReport = <Row, Cursor>( + options: GenerateReportOptions<Row, Cursor>, +): Response => { + const { filename, sheetName, columns, fetchBatch, onError } = options; + + const passThrough = new PassThrough(); + const workbook = new ExcelJS.stream.xlsx.WorkbookWriter({ stream: passThrough }); + const sheet = workbook.addWorksheet(sheetName); + + const write = async (): Promise<void> => { + try { + sheet.addRow(columns.map((column) => column.header)).commit(); + + let cursor: Cursor | undefined = undefined; + do { + const { rows, nextCursor } = await fetchBatch(cursor); + for (const row of rows) { + const cells = columns.map((column) => { + const cell = column.value(row); + return typeof cell === 'string' ? sanitizeSpreadsheetCell(cell) : cell; + }); + sheet.addRow(cells).commit(); + } + cursor = nextCursor; + } while (cursor !== undefined); + + sheet.commit(); + await workbook.commit(); + } catch (err) { + if (onError) { + onError(err); + } + passThrough.destroy(err instanceof Error ? err : new Error('generate report failed')); + } + }; + + // Detached on purpose: the Response must return while the workbook is still + // being written, so the stream can flow to the client. The loop owns its own + // error handling above; `void` marks the intentional fire-and-forget. + void write(); + + return new Response(Readable.toWeb(passThrough) as ReadableStream<Uint8Array>, { + headers: { + 'Content-Type': SPREADSHEET_CONTENT_TYPE, + 'Content-Disposition': `attachment; filename="${filename}"`, + 'Cache-Control': 'no-store', + }, + }); +}; +``` + +> Note: the `do/while` calls `fetchBatch(cursor)` starting at `undefined` and stops as soon as a batch returns `nextCursor: undefined`. The header row is written before the loop so an empty result set still yields a valid header-only workbook. + +Then create the barrel `src/lib/server/reports/index.ts` so consumers import the folder: + +```ts +export * from './helpers.js'; +export * from './generateReport.js'; +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `pnpm test run src/lib/server/reports/generateReport.test.ts` +Expected: PASS (3 helper tests). + +- [ ] **Step 5: Type-check** + +Run: `pnpm check` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add src/lib/server/reports/generateReport.ts src/lib/server/reports/generateReport.test.ts src/lib/server/reports/index.ts +git commit -m "feat: add generateReport streaming xlsx helper" +``` + +--- + +## Task 4: Move and rewrite the quiz endpoint + +Move `admin/api/download` → `admin/api/download/quiz` and rewrite it to use `generateReport`. Business logic preserved: same `where` (`questionAnswers: { some: {} }`, optional `quizId`), same columns, same filename format. Behavioral deltas are internal (streaming), row order (`user.name` → primary key), and the `Cache-Control: no-store` header. + +**Files:** + +- Create: `src/routes/admin/api/download/quiz/+server.ts` +- Test: `src/routes/admin/api/download/quiz/+server.test.ts` +- Delete: `src/routes/admin/api/download/+server.ts` + +- [ ] **Step 1: Write the failing tests** + +Create `src/routes/admin/api/download/quiz/+server.test.ts`: + +```ts +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +import { GET } from './+server.js'; + +const { mockGenerateReport, mockFindMany, mockFindUnique } = vi.hoisted(() => ({ + mockGenerateReport: vi.fn(() => new Response('ok')), + mockFindMany: vi.fn(), + mockFindUnique: vi.fn(), +})); + +vi.mock('$lib/server/reports', async (importActual) => { + const actual = await importActual<typeof import('$lib/server/reports')>(); + return { ...actual, generateReport: mockGenerateReport }; +}); + +vi.mock('$lib/server/db.js', () => ({ + db: { + learningJourney: { findMany: mockFindMany }, + learningUnit: { findUnique: mockFindUnique }, + }, +})); + +const silentLogger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + child: vi.fn(), +}; +silentLogger.child.mockReturnValue(silentLogger); + +const buildEvent = (url: string, user: { id: string } | null) => + ({ + locals: { logger: silentLogger, session: { user } }, + url: new URL(url), + }) as unknown as Parameters<typeof GET>[0]; + +beforeEach(() => { + vi.clearAllMocks(); + silentLogger.child.mockReturnValue(silentLogger); + mockGenerateReport.mockReturnValue(new Response('ok')); +}); + +describe('GET /admin/api/download/quiz', () => { + test('returns 401 and does not stream when unauthenticated', async () => { + const event = buildEvent('http://localhost/admin/api/download/quiz', null); + + const response = await GET(event); + + expect(response.status).toBe(401); + expect(mockGenerateReport).not.toHaveBeenCalled(); + }); + + test('declares the report columns, sheet name, and filename', async () => { + mockFindUnique.mockResolvedValue({ title: 'My Quiz' }); + const event = buildEvent('http://localhost/admin/api/download/quiz?quizId=quiz-1', { + id: 'admin-1', + }); + + await GET(event); + + const options = mockGenerateReport.mock.calls[0][0]; + expect(options.columns.map((c) => c.header)).toEqual([ + 'Name', + 'Email', + 'Quiz Title', + 'Is Completed', + 'Number of Attempts', + ]); + expect(options.sheetName).toBe('Quiz Report'); + expect(options.filename).toMatch(/^\d{14}_My_Quiz_user_report\.xlsx$/); + }); + + test('maps a record to row values', async () => { + mockFindUnique.mockResolvedValue({ title: 'My Quiz' }); + const event = buildEvent('http://localhost/admin/api/download/quiz?quizId=quiz-1', { + id: 'admin-1', + }); + const record = { + id: '1', + isCompleted: true, + numberOfAttempts: 3, + user: { name: 'Ann', email: 'a@x.co' }, + learningUnit: { title: 'Quiz X' }, + }; + + await GET(event); + + const options = mockGenerateReport.mock.calls[0][0]; + expect(options.columns.map((c) => c.value(record))).toEqual([ + 'Ann', + 'a@x.co', + 'Quiz X', + 'Yes', + 3, + ]); + }); + + test('fetchBatch advances the keyset cursor and honors the quizId filter', async () => { + mockFindUnique.mockResolvedValue({ title: 'My Quiz' }); + const event = buildEvent('http://localhost/admin/api/download/quiz?quizId=quiz-1', { + id: 'admin-1', + }); + const fullBatch = Array.from({ length: 100 }, (_, i) => ({ id: `id-${i}` })); + mockFindMany.mockResolvedValueOnce(fullBatch).mockResolvedValueOnce([{ id: 'id-100' }]); + + await GET(event); + const { fetchBatch } = mockGenerateReport.mock.calls[0][0]; + const first = await fetchBatch(undefined); + const second = await fetchBatch('id-99'); + + expect(mockFindMany.mock.calls[0][0]).toMatchObject({ + where: { learningUnit: { questionAnswers: { some: {} }, id: 'quiz-1' } }, + orderBy: { id: 'asc' }, + take: 100, + }); + expect(mockFindMany.mock.calls[0][0]).not.toHaveProperty('cursor'); + expect(first.nextCursor).toBe('id-99'); + expect(mockFindMany.mock.calls[1][0]).toMatchObject({ skip: 1, cursor: { id: 'id-99' } }); + expect(second.nextCursor).toBeUndefined(); + }); + + test('returns 404 and does not stream when a given quizId resolves to no quiz', async () => { + mockFindUnique.mockResolvedValue(null); + const event = buildEvent('http://localhost/admin/api/download/quiz?quizId=missing', { + id: 'admin-1', + }); + + const response = await GET(event); + + expect(response.status).toBe(404); + expect(mockGenerateReport).not.toHaveBeenCalled(); + }); + + test('omits the id filter and the title lookup when no quizId is given', async () => { + const event = buildEvent('http://localhost/admin/api/download/quiz', { id: 'admin-1' }); + mockFindMany.mockResolvedValue([]); + + await GET(event); + const { fetchBatch } = mockGenerateReport.mock.calls[0][0]; + await fetchBatch(undefined); + + expect(mockFindUnique).not.toHaveBeenCalled(); + expect(mockFindMany.mock.calls[0][0].where).toEqual({ + learningUnit: { questionAnswers: { some: {} } }, + }); + }); +}); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `pnpm test run src/routes/admin/api/download/quiz/+server.test.ts` +Expected: FAIL — cannot resolve `./+server.js`. + +- [ ] **Step 3: Create the new endpoint** + +Create `src/routes/admin/api/download/quiz/+server.ts`: + +```ts +import { json } from '@sveltejs/kit'; + +import { + db, + type LearningJourneyFindManyArgs, + type LearningJourneyGetPayload, + type LearningUnitFindUniqueArgs, + type LearningUnitGetPayload, +} from '$lib/server/db.js'; +import { formatTimestamp, generateReport } from '$lib/server/reports'; + +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async (event) => { + const logger = event.locals.logger.child({ handler: 'api_download_quiz_report' }); + + const { user } = event.locals.session; + if (!user) { + logger.warn('User not authenticated'); + return json(null, { status: 401 }); + } + + const quizId = event.url.searchParams.get('quizId')?.trim() || undefined; + + const batchSize = 100; + + const recordArgs = { + select: { + id: true, + isCompleted: true, + numberOfAttempts: true, + user: { select: { name: true, email: true } }, + learningUnit: { select: { title: true } }, + }, + where: { + learningUnit: { + questionAnswers: { some: {} }, + ...(quizId && { id: quizId }), + }, + }, + orderBy: { id: 'asc' }, + take: batchSize, + } satisfies LearningJourneyFindManyArgs; + + type QuizRow = LearningJourneyGetPayload<typeof recordArgs>; + + let quiz: LearningUnitGetPayload<{ select: { title: true } }> | null = null; + if (quizId) { + const quizArgs = { + select: { title: true }, + where: { id: quizId }, + } satisfies LearningUnitFindUniqueArgs; + + try { + quiz = await db.learningUnit.findUnique(quizArgs); + } catch (err) { + logger.error({ err }, 'Failed to look up quiz title'); + return json(null, { status: 500 }); + } + + if (!quiz) { + logger.warn({ quizId }, 'Quiz not found'); + return json(null, { status: 404 }); + } + } + + const quizTitle = (quiz?.title ?? 'quiz').replace(/[^a-zA-Z0-9]/g, '_'); + const filename = `${formatTimestamp(new Date())}_${quizTitle}_user_report.xlsx`; + + return generateReport<QuizRow, string>({ + filename, + sheetName: 'Quiz Report', + columns: [ + { header: 'Name', value: (row) => row.user.name }, + { header: 'Email', value: (row) => row.user.email }, + { header: 'Quiz Title', value: (row) => row.learningUnit.title }, + { header: 'Is Completed', value: (row) => (row.isCompleted ? 'Yes' : 'No') }, + { header: 'Number of Attempts', value: (row) => row.numberOfAttempts }, + ], + fetchBatch: async (cursor) => { + const rows = await db.learningJourney.findMany({ + ...recordArgs, + ...(cursor && { skip: 1, cursor: { id: cursor } }), + }); + const nextCursor = rows.length === batchSize ? rows[rows.length - 1].id : undefined; + return { rows, nextCursor }; + }, + onError: (err) => logger.error({ err }, 'Failed while streaming quiz report'), + }); +}; +``` + +- [ ] **Step 4: Delete the old endpoint** + +Run: `git rm src/routes/admin/api/download/+server.ts` +Expected: the old buffered endpoint (sole `xlsx` consumer) is removed. + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `pnpm test run src/routes/admin/api/download/quiz/+server.test.ts` +Expected: PASS (6 tests). + +- [ ] **Step 6: Type-check** + +Run: `pnpm check` +Expected: PASS. (If `./$types` is unresolved, run `pnpm exec svelte-kit sync` first — `pnpm check` runs sync itself.) + +- [ ] **Step 7: Commit** + +```bash +git add src/routes/admin/api/download/quiz/+server.ts src/routes/admin/api/download/quiz/+server.test.ts src/routes/admin/api/download/+server.ts +git commit -m "feat: stream quiz report export via generateReport" +``` + +--- + +## Task 5: Point the reports download link at the nested route + +This is an href change only. **Keep the existing native anchor (`LinkButton` + `data-sveltekit-reload`); do not convert the download to a `fetch`.** The anchor lets the browser stream the response straight to disk with near-zero client memory — the whole point of the feature. The cost is that a non-200 (401/404/500) is a full navigation showing the raw body rather than an in-page error; that is an accepted trade-off (the quiz dropdown only offers valid ids, so the 404 path is near-unreachable from the UI). See the spec's Error handling. + +**Files:** + +- Modify: `src/routes/admin/(protected)/reports/+page.svelte:16` + +- [ ] **Step 1: Update the download href** + +In `src/routes/admin/(protected)/reports/+page.svelte`, change the `downloadHref`: + +```svelte +const downloadHref = $derived( `/admin/api/download/quiz?quizId=${encodeURIComponent(selectedId)}`, +); +``` + +(Was `/admin/api/download?quizId=...`.) + +- [ ] **Step 2: Type-check** + +Run: `pnpm check` +Expected: PASS. + +- [ ] **Step 3: Manual verification** + +Run: `pnpm dev`, sign in to `/admin`, open **Generate Report**, select a quiz, click **Download XLSX**. +Expected: a `.xlsx` downloads from `/admin/api/download/quiz`, opens in a spreadsheet app with the five columns and one row per result. (No page-level test harness exists for this route component; this is verified manually.) + +- [ ] **Step 4: Commit** + +```bash +git add "src/routes/admin/(protected)/reports/+page.svelte" +git commit -m "fix: point quiz report download link to nested route" +``` + +--- + +## Task 6: Remove the vendored `xlsx` dependency + +Safe only now that no code imports `xlsx` (Task 4 deleted the last consumer). + +**Files:** + +- Modify: `package.json`, `pnpm-lock.yaml` +- Delete: `vendor/xlsx-0.20.3.tgz` + +- [ ] **Step 1: Confirm nothing imports xlsx** + +Run: `grep -rn "xlsx" src/` +Expected: no matches. + +- [ ] **Step 2: Remove the dependency** + +Run: `pnpm remove xlsx` +Expected: the `"xlsx": "file:vendor/xlsx-0.20.3.tgz"` entry is gone from `package.json`; `pnpm-lock.yaml` updated. + +- [ ] **Step 3: Delete the vendored tarball** + +Run: `git rm vendor/xlsx-0.20.3.tgz` +Expected: tarball removed. + +- [ ] **Step 4: Verify the build still type-checks and tests pass** + +Run: `pnpm check && pnpm test run src/lib/server/reports/helpers.test.ts src/lib/server/reports/generateReport.test.ts src/routes/admin/api/download/quiz/+server.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add package.json pnpm-lock.yaml vendor/xlsx-0.20.3.tgz +git commit -m "chore: remove vendored xlsx dependency" +``` + +--- + +## Task 7: Mark the PR ready (gh — run on approval) + +Once Tasks 1-6 pass and the branch is pushed, take the draft PR out of draft. + +- [ ] **Step 1: Mark ready for review** + +```bash +gh pr ready +``` + +--- + +## Self-Review + +**Spec coverage (Spec A):** + +- Goal "bounded memory, true streaming of read + write" → Task 3 (`generateReport` un-awaited loop + `PassThrough` + `Readable.toWeb`); Task 4 (`fetchBatch` keyset batching). ✅ +- Goal "reusable export helper" → Task 3 generic `generateReport<Row, Cursor>`; Spec B consumes it. ✅ +- Component 1 "add exceljs, remove vendored xlsx" → Task 1, Task 6. ✅ +- Component 2 "`src/lib/server/reports/` folder — `helpers.ts` (shared `sanitizeSpreadsheetCell` + `formatTimestamp`), `generateReport.ts` (helper), `index.ts` (barrel)" → Task 2 (both utilities), Task 3 (helper + barrel). ✅ +- Component 3 "quiz endpoint moved + rewritten; download link updated" → Task 4, Task 5. ✅ +- Error handling "401 before stream; 404 on missing quiz before stream; mid-stream error calls `onError` + destroys stream" → Task 4 (401, 404 when a supplied `quizId` resolves to null, `onError` logs via handler logger), Task 3 (`write()` `try/catch` calls `onError` + `passThrough.destroy`; the call is `void`-detached so the `Response` returns while streaming). ✅ +- Security "sanitize all string cells; `Cache-Control: no-store`; per-handler 401 defense-in-depth" → Task 2/3 (sanitize), Task 3 (header), Task 4 (401). ✅ +- Testing "sanitizer cases; timestamp formatting; helper drives to exhaustion + aborts; endpoint auth/404/columns/cursor/filter; empty set → header-only" → Task 2 (sanitizer + `formatTimestamp`), Task 3, Task 4 tests (incl. 404 when a given `quizId` resolves to no quiz); header-only behavior is the header-row-before-loop note in Task 3. ✅ + +**Placeholder scan:** No TBD/TODO; every code/test step shows full content. ✅ + +**Type consistency:** `generateReport<Row, Cursor>(options): Response`, `GenerateReportOptions.fetchBatch: (cursor) => Promise<{ rows; nextCursor }>`, `GenerateReportOptions.onError?: (err) => void` (optional), `Column.value: (row) => string | number | boolean`, `sanitizeSpreadsheetCell(value: string): string`, `formatTimestamp(date: Date): string` — used identically in the helper, its tests, and the quiz endpoint (`generateReport<QuizRow, string>`, `fetchBatch` returns `{ rows, nextCursor }`, columns return string/number, `formatTimestamp(new Date())` builds the filename prefix). `Column` and `GenerateReportOptions` are `export`ed from `generateReport.ts` and re-exported via the barrel, so Spec B can name them. ✅ + +**Note on `User.name`/`email`:** both are non-nullable `String` in `prisma/schema.prisma`, so the quiz columns need no null-coalescing. diff --git a/docs/superpowers/specs/2026-06-08-streaming-report-exports-design.md b/docs/superpowers/specs/2026-06-08-streaming-report-exports-design.md new file mode 100644 index 00000000..b8c56d95 --- /dev/null +++ b/docs/superpowers/specs/2026-06-08-streaming-report-exports-design.md @@ -0,0 +1,139 @@ +# Streaming Report Exports (ExcelJS) — Design + +**Status:** Approved +**Issue:** none +**Depends on / Related:** foundation for [Onboarding Report](./2026-06-08-onboarding-report-design.md) (Spec B). This is Spec A; Spec B builds on the helper defined here. + +## Overview + +The admin quiz export (`admin/api/download`) loads every matching row into memory, builds the whole workbook, and serializes it to a single buffer before sending, so memory scales linearly with row count. As data grows, a single export can spike memory and destabilize the server. There is also no reusable export path, so the upcoming onboarding report would otherwise copy the same buffered pattern. This spec replaces that with a memory-bounded, end-to-end streaming export built on ExcelJS: rows are read from the database in cursor-batched pages straight into an ExcelJS `WorkbookWriter`, whose output is piped to the HTTP response, so neither the full result set nor the full file is ever held in memory. The streaming logic lives in one reusable helper, fixing the memory model once ahead of the onboarding report (Spec B) adding a second export. + +Scope: the **existing quiz export only**. The onboarding report (Spec B) reuses the helper introduced here; no UI/pagination changes to the reports page and no change to the report's columns or filename format (output is equivalent to today, minus row ordering — see Architecture). + +The deploy adapter is `@sveltejs/adapter-node` on Node 24, so streaming `Response` bodies (via `Readable.toWeb`) are supported. `xlsx` (SheetJS) is currently imported in exactly one place — `src/routes/admin/api/download/+server.ts` — and vendored as a local tarball (`file:vendor/xlsx-0.20.3.tgz`), so migrating the quiz export removes that dependency entirely; `exceljs` is not yet a dependency and is added by this work. + +## Goals + +- **Goal:** bounded memory regardless of dataset size — true streaming of both the DB read and the file write. +- **Goal:** a reusable export helper so additional reports plug in without re-implementing streaming. + +## Architecture + +> Decision: stream end-to-end with ExcelJS through a generic `generateReport` helper. See [ADR-0001](../../decisions/0001-stream-report-exports-with-exceljs.md). +> Decision: read each batch with keyset cursor pagination on the primary key. See [ADR-0002](../../decisions/0002-keyset-cursor-pagination-on-primary-key.md). + +`ExcelJS.stream.xlsx.WorkbookWriter` writes to a Node `stream.PassThrough`. The readable side is converted with `Readable.toWeb(passThrough)` and returned as the SvelteKit `Response` body. The write loop runs **un-awaited** so the response starts streaming immediately while rows are still being fetched and written; because it is not awaited, the loop carries its own `.catch` (see Error handling). Each iteration pulls one cursor-batched page (ordered by primary key — quiz: `LearningJourney.id`) via `fetchBatch`, writes its rows, and continues until `nextCursor` is undefined, then commits the worksheet and workbook. + +Because the export is ordered by primary key rather than `user.name` (today's behavior), the on-disk row order differs from before; the admin can sort any column in Excel. Per-cell sanitization (`sanitizeSpreadsheetCell`) is applied to every string cell as it is written. + +```mermaid +flowchart LR + DB[(Postgres)] --> Fetch[fetchBatch cursor] + subgraph loop [write loop — un-awaited] + Fetch -->|rows| Write[write rows to sheet] + Write --> More{nextCursor?} + More -->|yes| Fetch + More -->|no| Commit[commit sheet + workbook] + end + Write --> Writer[ExcelJS WorkbookWriter] + Commit --> Writer + Writer --> PT[PassThrough] + PT -->|Readable.toWeb| Resp[HTTP Response body] +``` + +## Contracts & boundaries + +### `generateReport` + +- **Does:** turns a column definition + a cursor-driven batch fetcher into a downloadable `.xlsx` HTTP response. Delivery is streamed end-to-end (a guarantee below), but the name stays intent-level — callers never depend on the mechanism. +- **Use:** declaration-level signature below; the caller passes columns, a `fetchBatch` closure over its Prisma query, and an optional `onError` callback. +- **Depends on:** `exceljs` and the caller-supplied `fetchBatch`; `onError` is optional. It takes no `RequestEvent` and has no logging dependency — error observation is inverted to the caller via `onError`, so the helper stays fully decoupled from SvelteKit, from any logger, and from app/business logic. A caller that does not care to observe failures simply omits `onError`. +- **Guarantees:** bounded memory (streams read + write); sets response headers including `Cache-Control: no-store`; sanitizes every string cell; commits the workbook when the cursor is exhausted; on a mid-stream error, **always destroys the stream** (the client sees a broken/incomplete download), and invokes `onError` if one was provided. +- **Requires:** `fetchBatch` returns `{ rows, nextCursor }` and yields `nextCursor: undefined` exactly when the data is exhausted. `onError` is optional; when omitted, a mid-stream failure still destroys the stream but is not surfaced to the caller (unobserved by design — observation is the caller's choice, never the helper's). The helper never throws on a fetch failure, so omitting `onError` cannot produce an unhandled rejection. + +```ts +interface Column<Row> { + header: string; + value: (row: Row) => string | number | boolean; +} + +interface GenerateReportOptions<Row, Cursor> { + filename: string; + sheetName: string; + columns: Column<Row>[]; + fetchBatch: (cursor: Cursor | undefined) => Promise<{ rows: Row[]; nextCursor: Cursor | undefined }>; + onError?: (err: unknown) => void; +} + +generateReport<Row, Cursor>(options: GenerateReportOptions<Row, Cursor>): Response; +``` + +### `sanitizeSpreadsheetCell` + +- **Does:** neutralizes CSV/formula injection in a single string cell. +- **Use:** `sanitizeSpreadsheetCell(value: string): string`. +- **Depends on:** nothing. +- **Guarantees:** prefixes a leading `=`, `+`, `-`, `@`, tab, or CR with `'`; leaves safe values unchanged. +- **Requires:** a string input. + +### `formatTimestamp` + +- **Does:** formats a `Date` as a `DDMMYYYYHHmmss` string for report filename prefixes. +- **Use:** `formatTimestamp(date: Date): string`. +- **Depends on:** nothing. +- **Guarantees:** returns 14 digits — day, month, year, hours, minutes, seconds, each zero-padded, in local time. Carries no business meaning and no filename suffix; each caller assembles its own filename around the prefix. +- **Requires:** a `Date`. + +Shared by both exports (this report and the onboarding report, Spec B); co-located with `sanitizeSpreadsheetCell` in `reports/helpers.ts`. The per-report filename suffix (e.g. `_user_report.xlsx`) stays in each endpoint. + +Information-hiding test: an endpoint declares only its columns, `fetchBatch`, and (optionally) `onError`; the streaming loop, PassThrough bridge, headers, commit, and stream-destroy-on-error are all internal to the helper and can change without touching consumers. The helper always owns the recovery (destroying the stream); it notifies the caller through `onError` only when one is provided, so logging stays out of the helper. + +## Components / changes + +### 1. Add `exceljs`, remove vendored `xlsx` + +Add `exceljs` to dependencies; remove the `xlsx` entry and the `vendor/xlsx-0.20.3.tgz` tarball once the quiz endpoint no longer imports it. + +### 2. `src/lib/server/reports/` (new) + +A new feature folder following the repo's `src/lib/server/{auth,chat,unit,cache}` convention — responsibility-named source files, a barrel `index.ts` that re-exports them (`export * from './x.js'`), co-located `*.test.ts`, and consumers importing the folder (`$lib/server/reports`). It holds: + +- `generateReport.ts` — the generic streaming helper. +- `helpers.ts` — the shared pure `sanitizeSpreadsheetCell` and `formatTimestamp` utilities defined under Contracts & boundaries (both referenced by Spec B). +- `index.ts` — barrel re-exporting both. + +### 3. `src/routes/admin/api/download/quiz/+server.ts` (moved + modified) + +Moved from `admin/api/download/+server.ts` (sole `xlsx` consumer). Rewritten to use `generateReport`. **Business logic / scope is preserved**: the download still returns all rows for the selected quiz (same `where` filter, same columns, same filename), independent of the table's UI pagination. The only behavioral deltas are internal (streaming), row order (name → primary key), and the security hardening below. + +- Auth check (401 if no `user`) — defense-in-depth atop the `/admin` hook guard. +- Not-found check: when a `quizId` is supplied but the lookup returns no quiz, respond 404 before streaming — a bad id is an error, not an empty report. +- `columns`: Name, Email, Quiz Title, Is Completed (`Yes`/`No`), Number of Attempts — same as today. +- `fetchBatch`: Prisma cursor on `LearningJourney.id`, preserving the existing `where` (`questionAnswers: { some: {} }`, optional `quizId`), selecting `user { name, email }`, `learningUnit { title }`, `isCompleted`, `numberOfAttempts`. +- `onError`: logs the failure through the endpoint's handler logger (`event.locals.logger`). The helper itself stays logger-agnostic. +- Filename `DDMMYYYYHHmmss_{quizTitle}_user_report.xlsx`, sheet `"Quiz Report"`. The `DDMMYYYYHHmmss` prefix comes from the shared `formatTimestamp`; the endpoint assembles the rest. `quizTitle` comes from a one-off `db.learningUnit.findUnique` (when `quizId` is set), run in the endpoint **before** calling `generateReport` and passed in as `filename` — the `fetchBatch` contract covers row batches only, not this header lookup. +- Update the download link in `src/routes/admin/(protected)/reports/+page.svelte` to `/admin/api/download/quiz?quizId=...`. + +## Error handling + +- **Before streaming:** unauthenticated request → 401; a supplied `quizId` that resolves to no quiz → 404 (the endpoint does not conflate "not found" with "empty result"); the `quizTitle` lookup runs before `generateReport`, so a lookup failure is a clean 500 and a missing quiz a clean 404 — both before any bytes are sent. +- **Frontend on error:** the download stays a native anchor navigation (`data-sveltekit-reload`), deliberately not a `fetch`, so the browser streams the response straight to disk with near-zero client memory — the point of the feature. The trade-off is that a non-200 (401/404/500) is a full navigation showing the raw body rather than an in-page error. This is acceptable because the quiz dropdown only offers valid ids, so the 404 path is near-unreachable from the UI (it covers deleted/stale ids and direct URL access). Graceful in-page error UX would require buffering the file in browser memory (blob) or the File System Access API, and is intentionally out of scope. +- **Mid-stream (after headers sent):** the status and headers are already committed, so an error during the fetch/write loop cannot become a clean 500. The un-awaited write loop's `.catch` always destroys the `PassThrough` (the browser sees a failed/incomplete download and the admin retries) and, if the caller supplied `onError`, invokes it — the quiz endpoint supplies one that logs server-side through its handler logger. A buffer-then-send fallback was rejected (see [ADR-0001](../../decisions/0001-stream-report-exports-with-exceljs.md)) because it reintroduces the memory cost being removed. + +## Security considerations + +- **CSV / formula injection (OWASP A03).** `user.name` is end-user-controlled; `sanitizeSpreadsheetCell` neutralizes formula-triggering leading characters on all string cells. +- **Information disclosure (STRIDE).** `Cache-Control: no-store` on the PII response; only required fields selected. +- **Access control (OWASP A01; STRIDE: EoP / Info Disclosure).** Unchanged — `/admin/**` is gated by `src/routes/admin/hooks.server.ts`; the per-handler 401 stays as defense-in-depth. +- **Denial of Service (STRIDE).** Addressed directly by this spec — cursor-batched reads + streamed output bound memory. +- **CSRF / Tampering.** N/A — read-only `GET`, no state mutation. + +## Testing + +AAA, inline setup, no shared test helpers: + +- `sanitizeSpreadsheetCell`: prefixes leading `= + - @`, tab, CR; leaves safe values unchanged. +- `formatTimestamp`: returns 14 zero-padded digits (`DDMMYYYYHHmmss`) for a known `Date`. +- `generateReport`: drives `fetchBatch` until `nextCursor` is undefined; produces a readable stream; on a `fetchBatch` error, aborts the stream and calls `onError` when provided, and aborts without throwing (no unhandled rejection) when `onError` is omitted. +- Quiz endpoint: 401 when unauthenticated; 404 when a supplied `quizId` resolves to no quiz; correct columns/row mapping; cursor advances across multiple batches; honors `quizId` filter. +- Boundary conditions: empty result set yields a valid header-only workbook; with no `quizId`, the existing `where` runs without the optional filter. diff --git a/docs/superpowers/specs/README.md b/docs/superpowers/specs/README.md new file mode 100644 index 00000000..384ddafa --- /dev/null +++ b/docs/superpowers/specs/README.md @@ -0,0 +1,51 @@ +# Specs + +Design specs for features and refactors, produced by the brainstorming flow +before any implementation. Start every spec from [`TEMPLATE.md`](./TEMPLATE.md). + +- **Specs:** `docs/superpowers/specs/YYYY-MM-DD-<topic>-design.md` +- **Plans:** `docs/superpowers/plans/` (written after the spec is approved) +- **Decisions:** `../../decisions/` — ADRs in [MADR 4.0](https://adr.github.io/madr/) format + +## What a spec is for + +A spec answers **WHAT** we're building and **WHY**, and defines the **contracts +and boundaries** other work depends on. It is the contract between intent and +implementation. It does **not** contain implementation — no function bodies, no +loop internals. That belongs in the plan. + +## What it must cover (from the brainstorming skill) + +- **Architecture, components, data flow, error handling, testing** — the skill's + required coverage for any design. +- **Contracts / boundaries — the unit triple.** For each new or changed unit, + state _what it does, how you use it, and what it depends on_, plus what it + **guarantees** and what it **requires** of callers. Apply the information-hiding + test: can someone understand the unit without reading its internals, and can + you change the internals without breaking consumers? If not, the boundary needs + work. Interface **signatures** belong here; bodies do not. +- **Chosen solution only.** A spec records the decision you landed on. The + alternatives you considered and the full rationale go in an **ADR** under + `docs/decisions/` (MADR 4.0); the spec links to it. See the decisions + [README](../../decisions/README.md). +- **YAGNI.** No unrequested features or speculative generality; state what's + deliberately excluded in the overview's scope-boundary line. + +## Diagrams + +Use **Mermaid** fenced code blocks for all diagrams (flow, sequence, ER) so they +render in the repo and in review: + +````markdown +```mermaid +flowchart LR + A[Request] --> B[Handler] --> C[(DB)] +``` +```` + +## Review bar + +Before handing a spec to planning it should pass the skill reviewer's bar: +**Completeness** (no TBD/placeholders), **Consistency** (no contradictions), +**Clarity** (no requirement readable two ways), **Scope** (focused enough for a +single plan), **YAGNI** (nothing unrequested). diff --git a/docs/superpowers/specs/TEMPLATE.md b/docs/superpowers/specs/TEMPLATE.md new file mode 100644 index 00000000..98cc93a6 --- /dev/null +++ b/docs/superpowers/specs/TEMPLATE.md @@ -0,0 +1,147 @@ +# <Feature> — Design + +<!-- +SPEC TEMPLATE — read before filling in. See README.md for the full conventions. + +A spec answers WHAT we're building and WHY, and defines the CONTRACTS and +BOUNDARIES other work depends on. It does NOT contain implementation: no function +bodies, no loop internals. That is the plan's job. + +Decisions: record only the CHOSEN solution here. The alternatives considered and +the full rationale go in an ADR under docs/decisions/ (MADR 4.0); link it. + +Altitude test for anything you add: + - Contract / interface / chosen outcome a reviewer or sibling spec must consume -> here + - Internal mechanics a competent engineer could write 3 valid ways -> the plan + - "We considered X but chose Y because…" -> an ADR in docs/decisions/, linked from here + +Diagrams: use Mermaid fenced blocks. Scale each section to its complexity and +DELETE optional sections that don't apply (an empty section reads as unanswered). +Delete these comments in the real spec. +--> + +**Status:** Draft <!-- Draft | Approved --> +**Issue:** <link to tracking issue, or "none"> +**Depends on / Related:** <link sibling specs/ADRs, or delete> + +## Overview + +<!-- +Problem first, then the solution, then why this approach — one tight description. +A reader should finish knowing what's broken/missing, what we'll build, and why +it's worth doing. State the scope boundary explicitly (what this does NOT cover, +especially if a sibling spec covers it). +--> + +## Goals + +<!-- Bullets. The success criteria the design must meet. Lead with the quality +attribute being optimized (e.g. bounded memory, p99 latency, auditability) when +there is one. Scope exclusions belong in the Overview's scope-boundary line, not +here. --> + +- **Goal:** … + +## Requirements + +<!-- OPTIONAL — feature specs. Externally-defined must-haves (e.g. from the +issue). A column/format table is ideal when output shape is fixed. Delete for +pure refactors. --> + +## Data model + +<!-- OPTIONAL — when the work touches the schema or non-obvious relations. Name +only the models/fields/relations involved and how they map to the feature; don't +restate the whole schema. An ER diagram (Mermaid `erDiagram`) helps when +relations are the point. Delete if not applicable. --> + +## Architecture + +<!-- +Describe the CHOSEN design and how it works at the contract level — components and +how data flows between them. Use a Mermaid diagram when the flow is non-trivial. +Do NOT relitigate alternatives here; for any significant decision, link the ADR +that records the options and rationale: + +> Decision: <chosen approach>. See [ADR-0001](../../decisions/0001-<slug>.md). + +```mermaid +flowchart LR + A[Request] --> B[Handler] --> C[(DB)] + +```` +--> + +## Contracts & boundaries + +<!-- +For each NEW or CHANGED unit (module, helper, endpoint), state the boundary so a +consumer never needs to read the internals: + +### `unit name` +- **Does:** <one line — its single purpose> +- **Use:** <how a caller invokes it; include the type SIGNATURE, no body> +- **Depends on:** <what it needs> +- **Guarantees:** <postconditions — what callers can rely on> +- **Requires:** <preconditions — what the caller must provide / hold> + +Information-hiding test: can the internals change without breaking consumers? If +not, the boundary is wrong. + +Write these as declaration-level TypeScript — signatures only, never bodies. Use +`interface` for object shapes (this repo lints `consistent-type-definitions: +interface` via the typescript-eslint stylistic preset); reserve `type` for +unions, function types, and mapped/utility types. Type imports are inline +(`import { type Foo }`). + +```ts +interface Thing<T> { … } +doThing(input): Output; +```` + +--> + +## Components / changes + +<!-- +Concrete, file-level list of what changes — one numbered entry per file/unit: +path, new|moved|modified, and WHAT it does at the contract level (columns, +inputs, outputs, filters). No code bodies. Call out preserved behavior explicitly +when rewriting existing code. +--> + +### 1. `path/to/file` (new | moved | modified) + +… + +## Error handling + +<!-- How failures are handled at each boundary: what's validated, what's caught, +what status/log/abort results. Call out failures that can't become a clean error +(e.g. mid-stream after headers are sent). --> + +## Security considerations + +<!-- +Threats via STRIDE / OWASP Top 10, scaled to the feature. Per relevant threat: +name it (with tag), state the exposure, state the mitigation (or why N/A). Be +explicit about what's handled centrally (e.g. an auth hook) vs. in this change. +Say "N/A — <reason>" rather than omitting a category. +--> + +- **Access control (OWASP A01; STRIDE: EoP / Info Disclosure).** … +- **Injection (OWASP A03).** … +- **Information disclosure (STRIDE).** … +- **CSRF / Tampering.** … +- **Denial of Service (STRIDE).** … + +## Testing + +<!-- +Test cases that prove the spec is satisfied, at the behavior level. Follow the +project's test conventions (AAA, inline setup, no shared helpers). Group by unit. +Include boundary conditions as cases (empty result set, missing optional field, +single vs multiple batches); error-path conditions belong in Error handling. +State what's covered elsewhere (e.g. a dependency spec) to avoid implying +duplication. +--> diff --git a/package.json b/package.json index 4a67dfd5..c456ed98 100644 --- a/package.json +++ b/package.json @@ -25,9 +25,9 @@ "@prisma/adapter-pg": "^7.7.0", "@prisma/client": "^7.7.0", "@valkey/valkey-glide": "2.0.1", + "exceljs": "^4.4.0", "openai": "^5.22.0", - "weaviate-client": "^3.13.0", - "xlsx": "file:vendor/xlsx-0.20.3.tgz" + "weaviate-client": "^3.13.0" }, "devDependencies": { "@eslint/js": "^9.39.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e0062b00..03f3b4aa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -37,15 +37,15 @@ importers: '@valkey/valkey-glide': specifier: 2.0.1 version: 2.0.1 + exceljs: + specifier: ^4.4.0 + version: 4.4.0 openai: specifier: ^5.22.0 version: 5.22.0(ws@8.18.3) weaviate-client: specifier: ^3.13.0 version: 3.13.0 - xlsx: - specifier: file:vendor/xlsx-0.20.3.tgz - version: file:vendor/xlsx-0.20.3.tgz devDependencies: '@eslint/js': specifier: ^9.39.3 @@ -962,6 +962,12 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@fast-csv/format@4.3.5': + resolution: {integrity: sha512-8iRn6QF3I8Ak78lNAa+Gdl5MJJBM5vRHivFtMRUWINdevNo00K7OXxS2PshawLKTejVwieIlPmK5YlLu6w4u8A==} + + '@fast-csv/parse@4.3.6': + resolution: {integrity: sha512-uRsLYksqpbDmWaSmzvJcuApSEe38+6NQZBUsuAyMZKqHxH0g1wcJgsKUvN3WC8tewaqFjBMMGrkHmC+T7k8LvA==} + '@graphql-typed-document-node/core@3.2.0': resolution: {integrity: sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==} peerDependencies: @@ -2078,6 +2084,9 @@ packages: '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/node@14.18.63': + resolution: {integrity: sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==} + '@types/node@24.10.1': resolution: {integrity: sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==} @@ -2279,6 +2288,18 @@ packages: resolution: {integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==} engines: {node: '>=12'} + archiver-utils@2.1.0: + resolution: {integrity: sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==} + engines: {node: '>= 6'} + + archiver-utils@3.0.4: + resolution: {integrity: sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==} + engines: {node: '>= 10'} + + archiver@5.3.2: + resolution: {integrity: sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==} + engines: {node: '>= 10'} + argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -2297,6 +2318,9 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + async@3.2.6: + resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + atomic-sleep@1.0.0: resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} engines: {node: '>=8.0.0'} @@ -2325,9 +2349,22 @@ packages: bidi-js@1.0.3: resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + big-integer@1.6.52: + resolution: {integrity: sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==} + engines: {node: '>=0.6'} + bignumber.js@9.3.1: resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==} + binary@0.3.0: + resolution: {integrity: sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg==} + + bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + + bluebird@3.4.7: + resolution: {integrity: sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==} + bowser@2.14.1: resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} @@ -2345,9 +2382,23 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} + buffer-crc32@0.2.13: + resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} + buffer-equal-constant-time@1.0.1: resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + buffer-indexof-polyfill@1.0.2: + resolution: {integrity: sha512-I7wzHwA3t1/lwXQh+A5PbNvJxgfo5r3xulgpYDB5zckTu/Z9oUK9biouBKQUjEqzaz3HnAT6TYoovmE+GqSf7A==} + engines: {node: '>=0.10'} + + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + + buffers@0.1.1: + resolution: {integrity: sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ==} + engines: {node: '>=0.2.0'} + c12@3.1.0: resolution: {integrity: sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==} peerDependencies: @@ -2368,6 +2419,9 @@ packages: resolution: {integrity: sha512-mCuXncKXk5iCLhfhwTc0izo0gtEmpz5CtG2y8GiOINBlMVS6v8TMRc5TaLWKS6692m9+dVVfzgeVxR5UxWHTYw==} engines: {node: '>=12'} + chainsaw@0.1.0: + resolution: {integrity: sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ==} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -2434,6 +2488,10 @@ packages: commondir@1.0.1: resolution: {integrity: sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==} + compress-commons@4.1.2: + resolution: {integrity: sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==} + engines: {node: '>= 10'} + concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} @@ -2448,6 +2506,18 @@ packages: resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} engines: {node: '>=18'} + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + + crc-32@1.2.2: + resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==} + engines: {node: '>=0.8'} + hasBin: true + + crc32-stream@4.0.3: + resolution: {integrity: sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==} + engines: {node: '>= 10'} + cross-fetch@3.2.0: resolution: {integrity: sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==} @@ -2488,6 +2558,9 @@ packages: dateformat@4.6.3: resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==} + dayjs@1.11.21: + resolution: {integrity: sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==} + debug@4.4.1: resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==} engines: {node: '>=6.0'} @@ -2562,6 +2635,9 @@ packages: resolution: {integrity: sha512-Sf2LSQP+bOlhKWWyhFsn0UsfdK/kCWRv1iuA2gXAwt3dyNabr6QSj00I2V10pidqz69soatm9ZwZvpQMTIOd5Q==} engines: {node: '>=12'} + duplexer2@0.1.4: + resolution: {integrity: sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==} + eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} @@ -2705,6 +2781,10 @@ packages: eventemitter3@5.0.1: resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} + exceljs@4.4.0: + resolution: {integrity: sha512-XctvKaEMaj1Ii9oDOqbW/6e1gXknSY4g/aLCDicOXqBE4M0nRWkUu0PTp++UPNzoFY12BNHMfs/VadKIS6llvg==} + engines: {node: '>=8.3.0'} + expect-type@1.2.1: resolution: {integrity: sha512-/kP8CAwxzLVEeFrMm4kMmy4CCDlpipyA7MYLVrdJIkV0fYF0UaigQHRsxHiuY/GEea+bh4KSv3TIlgr+2UL6bw==} engines: {node: '>=12.0.0'} @@ -2722,6 +2802,10 @@ packages: fast-copy@3.0.2: resolution: {integrity: sha512-dl0O9Vhju8IrcLndv2eU4ldt1ftXMqqfgN4H1cpmGV7P6jeB9FwpN9a2c8DPGE1Ys88rNUJVYDHq73CGAGOPfQ==} + fast-csv@4.3.6: + resolution: {integrity: sha512-2RNSpuwwsJGP0frGsOmTb9oUF+VkFSM4SyLTDgwf2ciHWTarN0lQTC+F2f/t5J9QjW+c65VFIAAu85GsvMIusw==} + engines: {node: '>=10.0.0'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -2792,11 +2876,22 @@ packages: resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} engines: {node: '>=12.20.0'} + fs-constants@1.0.0: + resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + + fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + fstream@1.0.12: + resolution: {integrity: sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg==} + engines: {node: '>=0.6'} + deprecated: This package is no longer supported. + function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} @@ -2838,6 +2933,10 @@ packages: deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true + glob@7.2.3: + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + globals@14.0.0: resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} engines: {node: '>=18'} @@ -2922,6 +3021,9 @@ packages: resolution: {integrity: sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==} engines: {node: '>=0.10.0'} + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -2934,6 +3036,9 @@ packages: resolution: {integrity: sha512-5i4Cx5vrBpVdvT3gvkSGAzzkUCrg/5Jm54UwWbDUSTMp4AjDI4IxiC6dI4+X1PRJYi6eKqWuE+684NJY2iOn3w==} engines: {node: '>=18.0.0'} + immediate@3.0.6: + resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} + import-fresh@3.3.1: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} @@ -2946,6 +3051,13 @@ packages: resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} engines: {node: '>=8'} + inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + is-arrayish@0.3.2: resolution: {integrity: sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==} @@ -2992,6 +3104,9 @@ packages: is-reference@3.0.3: resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==} + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -3053,6 +3168,9 @@ packages: json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + jszip@3.10.1: + resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} + jwa@2.0.1: resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} @@ -3069,10 +3187,17 @@ packages: known-css-properties@0.37.0: resolution: {integrity: sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ==} + lazystream@1.0.1: + resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==} + engines: {node: '>= 0.6.3'} + levn@0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + lie@3.3.0: + resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} + lightningcss-android-arm64@1.30.2: resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==} engines: {node: '>= 12.0.0'} @@ -3160,6 +3285,9 @@ packages: engines: {node: '>=20.17'} hasBin: true + listenercount@1.0.1: + resolution: {integrity: sha512-3mk/Zag0+IJxeDrxSgaDPy4zZ3w05PRZeJNnlWhzFz5OkX49J4krc+A8X2d2M69vGMBEX0uyl8M+W+8gH+kBqQ==} + listr2@8.3.3: resolution: {integrity: sha512-LWzX2KsqcB1wqQ4AHgYb4RsDXauQiqhjLk+6hjbaeHG4zpjjVAB6wC/gz6X0l+Du1cN3pUB5ZlrvTbhGSNnUQQ==} engines: {node: '>=18.0.0'} @@ -3174,9 +3302,49 @@ packages: lodash.camelcase@4.3.0: resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} + lodash.defaults@4.2.0: + resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} + + lodash.difference@4.5.0: + resolution: {integrity: sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==} + + lodash.escaperegexp@4.1.2: + resolution: {integrity: sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==} + + lodash.flatten@4.4.0: + resolution: {integrity: sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==} + + lodash.groupby@4.6.0: + resolution: {integrity: sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw==} + + lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + + lodash.isequal@4.5.0: + resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==} + deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead. + + lodash.isfunction@3.0.9: + resolution: {integrity: sha512-AirXNj15uRIMMPihnkInB4i3NHeb4iBtNg9WRWuK2o31S+ePwwNmDPaTL3o7dTJ+VXNZim7rFs4rxN4YU1oUJw==} + + lodash.isnil@4.0.0: + resolution: {integrity: sha512-up2Mzq3545mwVnMhTDMdfoG1OurpA/s5t88JmQX809eH3C8491iu2sfKhTfhQtKY78oPNhiaHJUpT/dUDAAtng==} + + lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + + lodash.isundefined@3.0.1: + resolution: {integrity: sha512-MXB1is3s899/cD8jheYYE2V9qTHwKvt+npCwpD+1Sxm3Q3cECXCiYHjeHWXNwr6Q0SOBPrYUDxendrO6goVTEA==} + lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + lodash.union@4.6.0: + resolution: {integrity: sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==} + + lodash.uniq@4.5.0: + resolution: {integrity: sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==} + log-update@6.1.0: resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==} engines: {node: '>=18'} @@ -3246,6 +3414,10 @@ packages: minimatch@3.1.5: resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + minimatch@5.1.9: + resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==} + engines: {node: '>=10'} + minimatch@9.0.9: resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} engines: {node: '>=16 || 14 >=14.17'} @@ -3257,6 +3429,10 @@ packages: resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} engines: {node: '>=16 || 14 >=14.17'} + mkdirp@0.5.6: + resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} + hasBin: true + mri@1.2.0: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} engines: {node: '>=4'} @@ -3326,6 +3502,10 @@ packages: resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + nypm@0.6.5: resolution: {integrity: sha512-K6AJy1GMVyfyMXRVB88700BJqNUkByijGJM8kEHpLdcAt+vSQAVfkWWHYzuRXHSY6xA2sNc5RjTj0p9rE2izVQ==} engines: {node: '>=18'} @@ -3372,6 +3552,9 @@ packages: package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + pako@1.0.11: + resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -3387,6 +3570,10 @@ packages: resolution: {integrity: sha512-s4DQMxIdhj3jLFWd9LxHOplj4p9yQ4ffMGowFf3cpEgrrJjEhN0V5nxw4Ye1EViAGDoL4/1AeO6qHpqYPOzE4Q==} engines: {node: '>=14.0.0'} + path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} @@ -3629,6 +3816,9 @@ packages: typescript: optional: true + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + process-warning@5.0.0: resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} @@ -3667,6 +3857,16 @@ packages: resolution: {integrity: sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==} engines: {node: '>=0.10.0'} + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + + readdir-glob@1.1.3: + resolution: {integrity: sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==} + readdirp@4.1.2: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} @@ -3713,6 +3913,11 @@ packages: rfdc@1.4.1: resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + rimraf@2.7.1: + resolution: {integrity: sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==} + deprecated: Rimraf versions prior to v4 are no longer supported + hasBin: true + rollup@4.59.0: resolution: {integrity: sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -3727,6 +3932,9 @@ packages: resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} engines: {node: '>=6'} + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} @@ -3737,6 +3945,10 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + saxes@5.0.1: + resolution: {integrity: sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw==} + engines: {node: '>=10'} + saxes@6.0.0: resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} engines: {node: '>=v12.22.7'} @@ -3763,6 +3975,9 @@ packages: set-cookie-parser@3.0.1: resolution: {integrity: sha512-n7Z7dXZhJbwuAHhNzkTti6Aw9QDDjZtm3JTpTGATIdNzdQz5GuFs22w90BcvF4INfnrL5xrX3oGsuqO5Dx3A1Q==} + setimmediate@1.0.5: + resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} + sharp@0.34.3: resolution: {integrity: sha512-eX2IQ6nFohW4DbvHIOLRB3MHFpYqaqvXd3Tp5e/T/dSH83fxaNJQRvDMhASmkNTsNTVF2/OOopzRCt7xokgPfg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -3843,6 +4058,12 @@ packages: resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} engines: {node: '>=18'} + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + strip-ansi@6.0.1: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} @@ -3913,6 +4134,10 @@ packages: resolution: {integrity: sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg==} engines: {node: '>=6'} + tar-stream@2.2.0: + resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} + engines: {node: '>=6'} + test-exclude@7.0.1: resolution: {integrity: sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==} engines: {node: '>=18'} @@ -3957,6 +4182,10 @@ packages: resolution: {integrity: sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA==} hasBin: true + tmp@0.2.7: + resolution: {integrity: sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==} + engines: {node: '>=14.14'} + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -3976,6 +4205,9 @@ packages: resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} engines: {node: '>=20'} + traverse@0.3.9: + resolution: {integrity: sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ==} + ts-api-utils@2.4.0: resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==} engines: {node: '>=18.12'} @@ -4012,6 +4244,9 @@ packages: undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + unzipper@0.10.14: + resolution: {integrity: sha512-ti4wZj+0bQTiX2KmKWuwj7lhV+2n//uXEotUmGuQqrbVZSEGFMbI68+c6JCQ8aAmUWYvtHEz2A8K6wXvueR/6g==} + uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} @@ -4022,6 +4257,11 @@ packages: resolution: {integrity: sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==} hasBin: true + uuid@8.3.2: + resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). + hasBin: true + valibot@1.2.0: resolution: {integrity: sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==} peerDependencies: @@ -4231,12 +4471,6 @@ packages: utf-8-validate: optional: true - xlsx@file:vendor/xlsx-0.20.3.tgz: - resolution: {integrity: sha512-oLDq3jw7AcLqKWH2AhCpVTZl8mf6X2YReP+Neh0SJUzV/BdZYjth94tG5toiMB1PPrYtxOCfaoUCkvtuH+3AJA==, tarball: file:vendor/xlsx-0.20.3.tgz} - version: 0.20.3 - engines: {node: '>=0.8'} - hasBin: true - xml-name-validator@5.0.0: resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} engines: {node: '>=18'} @@ -4279,6 +4513,10 @@ packages: zimmerframe@1.1.2: resolution: {integrity: sha512-rAbqEGa8ovJy4pyBxZM70hg4pE6gDgaQ0Sl9M3enG3I0d6H4XSAM3GeNGLKnsBpuijUow064sf7ww1nutC5/3w==} + zip-stream@4.1.1: + resolution: {integrity: sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==} + engines: {node: '>= 10'} + snapshots: '@acemir/cssom@0.9.29': {} @@ -5127,6 +5365,25 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 + '@fast-csv/format@4.3.5': + dependencies: + '@types/node': 14.18.63 + lodash.escaperegexp: 4.1.2 + lodash.isboolean: 3.0.3 + lodash.isequal: 4.5.0 + lodash.isfunction: 3.0.9 + lodash.isnil: 4.0.0 + + '@fast-csv/parse@4.3.6': + dependencies: + '@types/node': 14.18.63 + lodash.escaperegexp: 4.1.2 + lodash.groupby: 4.6.0 + lodash.isfunction: 3.0.9 + lodash.isnil: 4.0.0 + lodash.isundefined: 3.0.1 + lodash.uniq: 4.5.0 + '@graphql-typed-document-node/core@3.2.0(graphql@16.13.2)': dependencies: graphql: 16.13.2 @@ -6187,6 +6444,8 @@ snapshots: '@types/ms@2.1.0': optional: true + '@types/node@14.18.63': {} + '@types/node@24.10.1': dependencies: undici-types: 7.16.0 @@ -6426,6 +6685,42 @@ snapshots: ansi-styles@6.2.1: {} + archiver-utils@2.1.0: + dependencies: + glob: 7.2.3 + graceful-fs: 4.2.11 + lazystream: 1.0.1 + lodash.defaults: 4.2.0 + lodash.difference: 4.5.0 + lodash.flatten: 4.4.0 + lodash.isplainobject: 4.0.6 + lodash.union: 4.6.0 + normalize-path: 3.0.0 + readable-stream: 2.3.8 + + archiver-utils@3.0.4: + dependencies: + glob: 7.2.3 + graceful-fs: 4.2.11 + lazystream: 1.0.1 + lodash.defaults: 4.2.0 + lodash.difference: 4.5.0 + lodash.flatten: 4.4.0 + lodash.isplainobject: 4.0.6 + lodash.union: 4.6.0 + normalize-path: 3.0.0 + readable-stream: 3.6.2 + + archiver@5.3.2: + dependencies: + archiver-utils: 2.1.0 + async: 3.2.6 + buffer-crc32: 0.2.13 + readable-stream: 3.6.2 + readdir-glob: 1.1.3 + tar-stream: 2.2.0 + zip-stream: 4.1.1 + argparse@2.0.1: {} aria-query@5.3.0: @@ -6438,6 +6733,8 @@ snapshots: assertion-error@2.0.1: {} + async@3.2.6: {} + atomic-sleep@1.0.0: {} aws-ssl-profiles@1.1.2: {} @@ -6456,8 +6753,23 @@ snapshots: dependencies: require-from-string: 2.0.2 + big-integer@1.6.52: {} + bignumber.js@9.3.1: {} + binary@0.3.0: + dependencies: + buffers: 0.1.1 + chainsaw: 0.1.0 + + bl@4.1.0: + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 + + bluebird@3.4.7: {} + bowser@2.14.1: {} brace-expansion@1.1.13: @@ -6477,8 +6789,19 @@ snapshots: dependencies: fill-range: 7.1.1 + buffer-crc32@0.2.13: {} + buffer-equal-constant-time@1.0.1: {} + buffer-indexof-polyfill@1.0.2: {} + + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + buffers@0.1.1: {} + c12@3.1.0(magicast@0.3.5): dependencies: chokidar: 4.0.3 @@ -6508,6 +6831,10 @@ snapshots: loupe: 3.1.3 pathval: 2.0.0 + chainsaw@0.1.0: + dependencies: + traverse: 0.3.9 + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -6570,6 +6897,13 @@ snapshots: commondir@1.0.1: {} + compress-commons@4.1.2: + dependencies: + buffer-crc32: 0.2.13 + crc32-stream: 4.0.3 + normalize-path: 3.0.0 + readable-stream: 3.6.2 + concat-map@0.0.1: {} confbox@0.2.4: {} @@ -6578,6 +6912,15 @@ snapshots: cookie@1.1.1: {} + core-util-is@1.0.3: {} + + crc-32@1.2.2: {} + + crc32-stream@4.0.3: + dependencies: + crc-32: 1.2.2 + readable-stream: 3.6.2 + cross-fetch@3.2.0: dependencies: node-fetch: 2.7.0 @@ -6620,6 +6963,8 @@ snapshots: dateformat@4.6.3: {} + dayjs@1.11.21: {} + debug@4.4.1: dependencies: ms: 2.1.3 @@ -6662,6 +7007,10 @@ snapshots: dotenv@17.2.2: {} + duplexer2@0.1.4: + dependencies: + readable-stream: 2.3.8 + eastasianwidth@0.2.0: {} ecdsa-sig-formatter@1.0.11: @@ -6896,6 +7245,18 @@ snapshots: eventemitter3@5.0.1: {} + exceljs@4.4.0: + dependencies: + archiver: 5.3.2 + dayjs: 1.11.21 + fast-csv: 4.3.6 + jszip: 3.10.1 + readable-stream: 3.6.2 + saxes: 5.0.1 + tmp: 0.2.7 + unzipper: 0.10.14 + uuid: 8.3.2 + expect-type@1.2.1: {} exsolve@1.0.8: {} @@ -6908,6 +7269,11 @@ snapshots: fast-copy@3.0.2: {} + fast-csv@4.3.6: + dependencies: + '@fast-csv/format': 4.3.5 + '@fast-csv/parse': 4.3.6 + fast-deep-equal@3.1.3: {} fast-json-stable-stringify@2.1.0: {} @@ -6970,9 +7336,20 @@ snapshots: dependencies: fetch-blob: 3.2.0 + fs-constants@1.0.0: {} + + fs.realpath@1.0.0: {} + fsevents@2.3.3: optional: true + fstream@1.0.12: + dependencies: + graceful-fs: 4.2.11 + inherits: 2.0.4 + mkdirp: 0.5.6 + rimraf: 2.7.1 + function-bind@1.1.2: {} gaxios@7.1.1: @@ -7027,6 +7404,15 @@ snapshots: package-json-from-dist: 1.0.1 path-scurry: 1.11.1 + glob@7.2.3: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.5 + once: 1.4.0 + path-is-absolute: 1.0.1 + globals@14.0.0: {} globals@16.2.0: {} @@ -7110,12 +7496,16 @@ snapshots: dependencies: safer-buffer: 2.1.2 + ieee754@1.2.1: {} + ignore@5.3.2: {} ignore@7.0.5: {} imagetools-core@8.0.0: {} + immediate@3.0.6: {} + import-fresh@3.3.1: dependencies: parent-module: 1.0.1 @@ -7125,6 +7515,13 @@ snapshots: indent-string@4.0.0: {} + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + + inherits@2.0.4: {} + is-arrayish@0.3.2: {} is-core-module@2.16.1: @@ -7161,6 +7558,8 @@ snapshots: dependencies: '@types/estree': 1.0.8 + isarray@1.0.0: {} + isexe@2.0.0: {} istanbul-lib-coverage@3.2.2: {} @@ -7240,6 +7639,13 @@ snapshots: json-stable-stringify-without-jsonify@1.0.1: {} + jszip@3.10.1: + dependencies: + lie: 3.3.0 + pako: 1.0.11 + readable-stream: 2.3.8 + setimmediate: 1.0.5 + jwa@2.0.1: dependencies: buffer-equal-constant-time: 1.0.1 @@ -7259,11 +7665,19 @@ snapshots: known-css-properties@0.37.0: {} + lazystream@1.0.1: + dependencies: + readable-stream: 2.3.8 + levn@0.4.1: dependencies: prelude-ls: 1.2.1 type-check: 0.4.0 + lie@3.3.0: + dependencies: + immediate: 3.0.6 + lightningcss-android-arm64@1.30.2: optional: true @@ -7332,6 +7746,8 @@ snapshots: transitivePeerDependencies: - supports-color + listenercount@1.0.1: {} + listr2@8.3.3: dependencies: cli-truncate: 4.0.0 @@ -7349,8 +7765,34 @@ snapshots: lodash.camelcase@4.3.0: {} + lodash.defaults@4.2.0: {} + + lodash.difference@4.5.0: {} + + lodash.escaperegexp@4.1.2: {} + + lodash.flatten@4.4.0: {} + + lodash.groupby@4.6.0: {} + + lodash.isboolean@3.0.3: {} + + lodash.isequal@4.5.0: {} + + lodash.isfunction@3.0.9: {} + + lodash.isnil@4.0.0: {} + + lodash.isplainobject@4.0.6: {} + + lodash.isundefined@3.0.1: {} + lodash.merge@4.6.2: {} + lodash.union@4.6.0: {} + + lodash.uniq@4.5.0: {} + log-update@6.1.0: dependencies: ansi-escapes: 7.0.0 @@ -7412,6 +7854,10 @@ snapshots: dependencies: brace-expansion: 1.1.13 + minimatch@5.1.9: + dependencies: + brace-expansion: 2.0.3 + minimatch@9.0.9: dependencies: brace-expansion: 2.0.3 @@ -7420,6 +7866,10 @@ snapshots: minipass@7.1.2: {} + mkdirp@0.5.6: + dependencies: + minimist: 1.2.8 + mri@1.2.0: {} mrmime@2.0.1: {} @@ -7483,6 +7933,8 @@ snapshots: fetch-blob: 3.2.0 formdata-polyfill: 4.0.10 + normalize-path@3.0.0: {} + nypm@0.6.5: dependencies: citty: 0.2.2 @@ -7524,6 +7976,8 @@ snapshots: package-json-from-dist@1.0.1: {} + pako@1.0.11: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -7536,6 +7990,8 @@ snapshots: path-expression-matcher@1.4.0: {} + path-is-absolute@1.0.1: {} + path-key@3.1.1: {} path-parse@1.0.7: {} @@ -7727,6 +8183,8 @@ snapshots: - react - react-dom + process-nextick-args@2.0.1: {} + process-warning@5.0.0: {} proper-lockfile@4.1.2: @@ -7775,6 +8233,26 @@ snapshots: react@19.2.0: {} + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + + readdir-glob@1.1.3: + dependencies: + minimatch: 5.1.9 + readdirp@4.1.2: {} real-require@0.2.0: {} @@ -7809,6 +8287,10 @@ snapshots: rfdc@1.4.1: {} + rimraf@2.7.1: + dependencies: + glob: 7.2.3 + rollup@4.59.0: dependencies: '@types/estree': 1.0.8 @@ -7875,12 +8357,18 @@ snapshots: dependencies: mri: 1.2.0 + safe-buffer@5.1.2: {} + safe-buffer@5.2.1: {} safe-stable-stringify@2.5.0: {} safer-buffer@2.1.2: {} + saxes@5.0.1: + dependencies: + xmlchars: 2.2.0 + saxes@6.0.0: dependencies: xmlchars: 2.2.0 @@ -7897,6 +8385,8 @@ snapshots: set-cookie-parser@3.0.1: {} + setimmediate@1.0.5: {} + sharp@0.34.3: dependencies: color: 4.2.3 @@ -7996,6 +8486,14 @@ snapshots: get-east-asian-width: 1.3.0 strip-ansi: 7.1.0 + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + strip-ansi@6.0.1: dependencies: ansi-regex: 5.0.1 @@ -8075,6 +8573,14 @@ snapshots: tapable@2.2.2: {} + tar-stream@2.2.0: + dependencies: + bl: 4.1.0 + end-of-stream: 1.4.5 + fs-constants: 1.0.0 + inherits: 2.0.4 + readable-stream: 3.6.2 + test-exclude@7.0.1: dependencies: '@istanbuljs/schema': 0.1.3 @@ -8113,6 +8619,8 @@ snapshots: dependencies: tldts-core: 7.0.19 + tmp@0.2.7: {} + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 @@ -8129,6 +8637,8 @@ snapshots: dependencies: punycode: 2.3.1 + traverse@0.3.9: {} + ts-api-utils@2.4.0(typescript@5.9.2): dependencies: typescript: 5.9.2 @@ -8163,6 +8673,19 @@ snapshots: undici-types@7.16.0: {} + unzipper@0.10.14: + dependencies: + big-integer: 1.6.52 + binary: 0.3.0 + bluebird: 3.4.7 + buffer-indexof-polyfill: 1.0.2 + duplexer2: 0.1.4 + fstream: 1.0.12 + graceful-fs: 4.2.11 + listenercount: 1.0.1 + readable-stream: 2.3.8 + setimmediate: 1.0.5 + uri-js@4.4.1: dependencies: punycode: 2.3.1 @@ -8171,6 +8694,8 @@ snapshots: uuid@14.0.0: {} + uuid@8.3.2: {} + valibot@1.2.0(typescript@5.9.2): optionalDependencies: typescript: 5.9.2 @@ -8354,8 +8879,6 @@ snapshots: ws@8.18.3: {} - xlsx@file:vendor/xlsx-0.20.3.tgz: {} - xml-name-validator@5.0.0: {} xmlchars@2.2.0: {} @@ -8388,3 +8911,9 @@ snapshots: graphmatch: 1.1.1 zimmerframe@1.1.2: {} + + zip-stream@4.1.1: + dependencies: + archiver-utils: 3.0.4 + compress-commons: 4.1.2 + readable-stream: 3.6.2 diff --git a/src/lib/server/reports/generateReport.test.ts b/src/lib/server/reports/generateReport.test.ts new file mode 100644 index 00000000..272eb564 --- /dev/null +++ b/src/lib/server/reports/generateReport.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, test, vi } from 'vitest'; + +import { generateReport } from './generateReport.js'; + +describe('generateReport', () => { + test('drives fetchBatch until nextCursor is undefined and streams an xlsx body', async () => { + const onError = vi.fn(); + const fetchBatch = vi + .fn() + .mockResolvedValueOnce({ rows: [{ a: 'x' }], nextCursor: 'c1' }) + .mockResolvedValueOnce({ rows: [{ a: 'y' }], nextCursor: undefined }); + + const response = generateReport({ + filename: 'report.xlsx', + sheetName: 'Sheet', + columns: [{ header: 'A', value: (row: { a: string }) => row.a }], + fetchBatch, + onError, + }); + + const reader = response.body!.getReader(); + const chunks: Uint8Array[] = []; + while (true) { + const { value, done } = await reader.read(); + if (done) { + break; + } + chunks.push(value); + } + const bytes = chunks.reduce((acc, c) => acc + c.byteLength, 0); + + expect(fetchBatch).toHaveBeenCalledTimes(2); + expect(fetchBatch).toHaveBeenNthCalledWith(1, undefined); + expect(fetchBatch).toHaveBeenNthCalledWith(2, 'c1'); + expect(response.headers.get('Cache-Control')).toBe('no-store'); + expect(onError).not.toHaveBeenCalled(); + expect(bytes).toBeGreaterThan(0); + expect(chunks[0][0]).toBe(0x50); // 'P' — xlsx is a zip + expect(chunks[0][1]).toBe(0x4b); // 'K' + }); + + test('calls onError and aborts the stream when fetchBatch rejects', async () => { + const onError = vi.fn(); + const fetchBatch = vi.fn().mockRejectedValue(new Error('boom')); + + const response = generateReport({ + filename: 'report.xlsx', + sheetName: 'Sheet', + columns: [{ header: 'A', value: (row: { a: string }) => row.a }], + fetchBatch, + onError, + }); + + const drain = async () => { + const reader = response.body!.getReader(); + while (true) { + const { done } = await reader.read(); + if (done) { + break; + } + } + }; + + await expect(drain()).rejects.toThrow(); + expect(onError).toHaveBeenCalledWith(expect.any(Error)); + }); + + test('aborts the stream without throwing when fetchBatch rejects and no onError is given', async () => { + const fetchBatch = vi.fn().mockRejectedValue(new Error('boom')); + + const response = generateReport({ + filename: 'report.xlsx', + sheetName: 'Sheet', + columns: [{ header: 'A', value: (row: { a: string }) => row.a }], + fetchBatch, + }); + + const drain = async () => { + const reader = response.body!.getReader(); + while (true) { + const { done } = await reader.read(); + if (done) { + break; + } + } + }; + + await expect(drain()).rejects.toThrow(); + }); +}); diff --git a/src/lib/server/reports/generateReport.ts b/src/lib/server/reports/generateReport.ts new file mode 100644 index 00000000..84e40a7b --- /dev/null +++ b/src/lib/server/reports/generateReport.ts @@ -0,0 +1,73 @@ +import { PassThrough, Readable } from 'node:stream'; + +import ExcelJS from 'exceljs'; + +import { sanitizeSpreadsheetCell } from './helpers.js'; + +const SPREADSHEET_CONTENT_TYPE = + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'; + +export interface Column<Row> { + header: string; + value: (row: Row) => string | number | boolean; +} + +export interface GenerateReportOptions<Row, Cursor> { + filename: string; + sheetName: string; + columns: Column<Row>[]; + fetchBatch: ( + cursor: Cursor | undefined, + ) => Promise<{ rows: Row[]; nextCursor: Cursor | undefined }>; + onError?: (err: unknown) => void; +} + +export const generateReport = <Row, Cursor>( + options: GenerateReportOptions<Row, Cursor>, +): Response => { + const { filename, sheetName, columns, fetchBatch, onError } = options; + + const passThrough = new PassThrough(); + const workbook = new ExcelJS.stream.xlsx.WorkbookWriter({ stream: passThrough }); + const sheet = workbook.addWorksheet(sheetName); + + const write = async (): Promise<void> => { + try { + sheet.addRow(columns.map((column) => column.header)).commit(); + + let cursor: Cursor | undefined = undefined; + do { + const { rows, nextCursor } = await fetchBatch(cursor); + for (const row of rows) { + const cells = columns.map((column) => { + const cell = column.value(row); + return typeof cell === 'string' ? sanitizeSpreadsheetCell(cell) : cell; + }); + sheet.addRow(cells).commit(); + } + cursor = nextCursor; + } while (cursor !== undefined); + + sheet.commit(); + await workbook.commit(); + } catch (err) { + if (onError) { + onError(err); + } + passThrough.destroy(err instanceof Error ? err : new Error('generate report failed')); + } + }; + + // Detached on purpose: the Response must return while the workbook is still + // being written, so the stream can flow to the client. The loop owns its own + // error handling above; `void` marks the intentional fire-and-forget. + void write(); + + return new Response(Readable.toWeb(passThrough) as ReadableStream<Uint8Array>, { + headers: { + 'Content-Type': SPREADSHEET_CONTENT_TYPE, + 'Content-Disposition': `attachment; filename="${filename}"`, + 'Cache-Control': 'no-store', + }, + }); +}; diff --git a/src/lib/server/reports/helpers.test.ts b/src/lib/server/reports/helpers.test.ts new file mode 100644 index 00000000..d9319b6a --- /dev/null +++ b/src/lib/server/reports/helpers.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, test } from 'vitest'; + +import { formatTimestamp, sanitizeSpreadsheetCell } from './helpers.js'; + +describe('sanitizeSpreadsheetCell', () => { + test('prefixes a leading equals sign with a quote', () => { + const value = '=SUM(A1:A2)'; + + const result = sanitizeSpreadsheetCell(value); + + expect(result).toBe("'=SUM(A1:A2)"); + }); + + test('prefixes leading +, -, @, tab, and carriage return', () => { + const values = ['+1', '-1', '@cmd', '\tx', '\rx']; + + const results = values.map(sanitizeSpreadsheetCell); + + expect(results).toEqual(["'+1", "'-1", "'@cmd", "'\tx", "'\rx"]); + }); + + test('leaves safe values unchanged', () => { + const values = ['Ann', 'a@x.co', 'a=b', '']; + + const results = values.map(sanitizeSpreadsheetCell); + + expect(results).toEqual(['Ann', 'a@x.co', 'a=b', '']); + }); +}); + +describe('formatTimestamp', () => { + test('formats a date as DDMMYYYYHHmmss', () => { + const date = new Date(2026, 5, 8, 14, 30, 45); + + const result = formatTimestamp(date); + + expect(result).toBe('08062026143045'); + }); + + test('zero-pads single-digit day, month, hour, minute, and second', () => { + const date = new Date(2026, 0, 3, 4, 5, 6); + + const result = formatTimestamp(date); + + expect(result).toBe('03012026040506'); + }); +}); diff --git a/src/lib/server/reports/helpers.ts b/src/lib/server/reports/helpers.ts new file mode 100644 index 00000000..1ebe76af --- /dev/null +++ b/src/lib/server/reports/helpers.ts @@ -0,0 +1,16 @@ +export const sanitizeSpreadsheetCell = (value: string): string => { + if (/^[=+\-@\t\r]/.test(value)) { + return `'${value}`; + } + return value; +}; + +export const formatTimestamp = (date: Date): string => { + const dd = String(date.getDate()).padStart(2, '0'); + const mm = String(date.getMonth() + 1).padStart(2, '0'); + const yyyy = date.getFullYear(); + const hh = String(date.getHours()).padStart(2, '0'); + const min = String(date.getMinutes()).padStart(2, '0'); + const ss = String(date.getSeconds()).padStart(2, '0'); + return `${dd}${mm}${yyyy}${hh}${min}${ss}`; +}; diff --git a/src/lib/server/reports/index.ts b/src/lib/server/reports/index.ts new file mode 100644 index 00000000..0d0614b5 --- /dev/null +++ b/src/lib/server/reports/index.ts @@ -0,0 +1,2 @@ +export * from './generateReport.js'; +export * from './helpers.js'; diff --git a/src/routes/admin/(protected)/reports/+page.svelte b/src/routes/admin/(protected)/reports/+page.svelte index ed9cdd4f..2db78f49 100644 --- a/src/routes/admin/(protected)/reports/+page.svelte +++ b/src/routes/admin/(protected)/reports/+page.svelte @@ -13,7 +13,9 @@ let selectedId = $derived(data.quizId); - const downloadHref = $derived(`/admin/api/download?quizId=${encodeURIComponent(selectedId)}`); + const downloadHref = $derived( + `/admin/api/download/quiz?quizId=${encodeURIComponent(selectedId)}`, + ); const handleFilterChange = async () => { const url = new URL(page.url); diff --git a/src/routes/admin/api/download/+server.ts b/src/routes/admin/api/download/+server.ts deleted file mode 100644 index 6a165ee9..00000000 --- a/src/routes/admin/api/download/+server.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { json } from '@sveltejs/kit'; -import * as XLSX from 'xlsx'; - -import { db, type LearningJourneyFindManyArgs } from '$lib/server/db.js'; - -import type { RequestHandler } from './$types'; - -export const GET: RequestHandler = async (event) => { - const logger = event.locals.logger.child({ handler: 'api_download_quiz_report' }); - - const { user } = event.locals.session; - if (!user) { - logger.warn('User not authenticated'); - return json(null, { status: 401 }); - } - - const quizId = event.url.searchParams.get('quizId')?.trim() || undefined; - - const reportArgs = { - select: { - isCompleted: true, - numberOfAttempts: true, - user: { - select: { - name: true, - email: true, - }, - }, - learningUnit: { - select: { - title: true, - }, - }, - }, - where: { - learningUnit: { - questionAnswers: { some: {} }, - ...(quizId && { id: quizId }), - }, - }, - orderBy: { - user: { name: 'asc' }, - }, - } satisfies LearningJourneyFindManyArgs; - - try { - const [records, quiz] = await Promise.all([ - db.learningJourney.findMany(reportArgs), - quizId - ? db.learningUnit.findUnique({ where: { id: quizId }, select: { title: true } }) - : null, - ]); - - const rows = records.map((r) => ({ - Name: r.user.name, - Email: r.user.email, - 'Quiz Title': r.learningUnit.title, - 'Is Completed': r.isCompleted ? 'Yes' : 'No', - 'Number of Attempts': r.numberOfAttempts, - })); - - const workbook = XLSX.utils.book_new(); - const worksheet = XLSX.utils.json_to_sheet(rows); - XLSX.utils.book_append_sheet(workbook, worksheet, 'Quiz Report'); - - const buffer: Uint8Array = XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' }); - - const now = new Date(); - const dd = String(now.getDate()).padStart(2, '0'); - const mm = String(now.getMonth() + 1).padStart(2, '0'); - const yyyy = now.getFullYear(); - const hh = String(now.getHours()).padStart(2, '0'); - const min = String(now.getMinutes()).padStart(2, '0'); - const ss = String(now.getSeconds()).padStart(2, '0'); - const quizTitle = (quiz?.title ?? 'quiz').replace(/[^a-zA-Z0-9]/g, '_'); - const filename = `${dd}${mm}${yyyy}${hh}${min}${ss}_${quizTitle}_user_report.xlsx`; - - return new Response(buffer.buffer as ArrayBuffer, { - headers: { - 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - 'Content-Disposition': `attachment; filename="${filename}"`, - }, - }); - } catch (err) { - logger.error({ err }, 'Failed to fetch quiz report data'); - return new Response('Failed to generate report', { status: 500 }); - } -}; diff --git a/src/routes/admin/api/download/quiz/+server.ts b/src/routes/admin/api/download/quiz/+server.ts new file mode 100644 index 00000000..570e95a1 --- /dev/null +++ b/src/routes/admin/api/download/quiz/+server.ts @@ -0,0 +1,90 @@ +import { json } from '@sveltejs/kit'; + +import { + db, + type LearningJourneyFindManyArgs, + type LearningJourneyGetPayload, + type LearningUnitFindUniqueArgs, + type LearningUnitGetPayload, +} from '$lib/server/db.js'; +import { formatTimestamp, generateReport } from '$lib/server/reports'; + +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async (event) => { + const logger = event.locals.logger.child({ handler: 'api_download_quiz_report' }); + + const { user } = event.locals.session; + if (!user) { + logger.warn('User not authenticated'); + return json(null, { status: 401 }); + } + + const quizId = event.url.searchParams.get('quizId')?.trim() || undefined; + + const batchSize = 100; + + const recordArgs = { + select: { + id: true, + isCompleted: true, + numberOfAttempts: true, + user: { select: { name: true, email: true } }, + learningUnit: { select: { title: true } }, + }, + where: { + learningUnit: { + questionAnswers: { some: {} }, + ...(quizId && { id: quizId }), + }, + }, + orderBy: { id: 'asc' }, + take: batchSize, + } satisfies LearningJourneyFindManyArgs; + + type QuizRow = LearningJourneyGetPayload<typeof recordArgs>; + + let quiz: LearningUnitGetPayload<{ select: { title: true } }> | null = null; + if (quizId) { + const quizArgs = { + select: { title: true }, + where: { id: quizId }, + } satisfies LearningUnitFindUniqueArgs; + + try { + quiz = await db.learningUnit.findUnique(quizArgs); + } catch (err) { + logger.error({ err }, 'Failed to look up quiz title'); + return json(null, { status: 500 }); + } + + if (!quiz) { + logger.warn({ quizId }, 'Quiz not found'); + return json(null, { status: 404 }); + } + } + + const quizTitle = (quiz?.title ?? 'quiz').replace(/[^a-zA-Z0-9]/g, '_'); + const filename = `${formatTimestamp(new Date())}_${quizTitle}_user_report.xlsx`; + + return generateReport<QuizRow, string>({ + filename, + sheetName: 'Quiz Report', + columns: [ + { header: 'Name', value: (row) => row.user.name }, + { header: 'Email', value: (row) => row.user.email }, + { header: 'Quiz Title', value: (row) => row.learningUnit.title }, + { header: 'Is Completed', value: (row) => (row.isCompleted ? 'Yes' : 'No') }, + { header: 'Number of Attempts', value: (row) => row.numberOfAttempts }, + ], + fetchBatch: async (cursor) => { + const rows = await db.learningJourney.findMany({ + ...recordArgs, + ...(cursor && { skip: 1, cursor: { id: cursor } }), + }); + const nextCursor = rows.length === batchSize ? rows[rows.length - 1].id : undefined; + return { rows, nextCursor }; + }, + onError: (err) => logger.error({ err }, 'Failed while streaming quiz report'), + }); +}; diff --git a/src/routes/admin/api/download/quiz/server.test.ts b/src/routes/admin/api/download/quiz/server.test.ts new file mode 100644 index 00000000..df655cf7 --- /dev/null +++ b/src/routes/admin/api/download/quiz/server.test.ts @@ -0,0 +1,158 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +import type { GenerateReportOptions } from '$lib/server/reports'; + +import { GET } from './+server.js'; + +interface QuizReportRow { + id: string; + isCompleted: boolean; + numberOfAttempts: number; + user: { name: string; email: string }; + learningUnit: { title: string }; +} + +const { mockGenerateReport, mockFindMany, mockFindUnique } = vi.hoisted(() => ({ + mockGenerateReport: vi.fn<(options: GenerateReportOptions<QuizReportRow, string>) => Response>(), + mockFindMany: vi.fn(), + mockFindUnique: vi.fn(), +})); + +vi.mock('$lib/server/reports', async (importActual) => { + const actual = await importActual<typeof import('$lib/server/reports')>(); + return { ...actual, generateReport: mockGenerateReport }; +}); + +vi.mock('$lib/server/db.js', () => ({ + db: { + learningJourney: { findMany: mockFindMany }, + learningUnit: { findUnique: mockFindUnique }, + }, +})); + +const silentLogger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + child: vi.fn(), +}; +silentLogger.child.mockReturnValue(silentLogger); + +const buildEvent = (url: string, user: { id: string } | null) => + ({ + locals: { logger: silentLogger, session: { user } }, + url: new URL(url), + }) as unknown as Parameters<typeof GET>[0]; + +beforeEach(() => { + vi.clearAllMocks(); + silentLogger.child.mockReturnValue(silentLogger); + mockGenerateReport.mockReturnValue(new Response('ok')); +}); + +describe('GET /admin/api/download/quiz', () => { + test('returns 401 and does not stream when unauthenticated', async () => { + const event = buildEvent('http://localhost/admin/api/download/quiz', null); + + const response = await GET(event); + + expect(response.status).toBe(401); + expect(mockGenerateReport).not.toHaveBeenCalled(); + }); + + test('declares the report columns, sheet name, and filename', async () => { + mockFindUnique.mockResolvedValue({ title: 'My Quiz' }); + const event = buildEvent('http://localhost/admin/api/download/quiz?quizId=quiz-1', { + id: 'admin-1', + }); + + await GET(event); + + const options = mockGenerateReport.mock.calls[0][0]; + expect(options.columns.map((c) => c.header)).toEqual([ + 'Name', + 'Email', + 'Quiz Title', + 'Is Completed', + 'Number of Attempts', + ]); + expect(options.sheetName).toBe('Quiz Report'); + expect(options.filename).toMatch(/^\d{14}_My_Quiz_user_report\.xlsx$/); + }); + + test('maps a record to row values', async () => { + mockFindUnique.mockResolvedValue({ title: 'My Quiz' }); + const event = buildEvent('http://localhost/admin/api/download/quiz?quizId=quiz-1', { + id: 'admin-1', + }); + const record = { + id: '1', + isCompleted: true, + numberOfAttempts: 3, + user: { name: 'Ann', email: 'a@x.co' }, + learningUnit: { title: 'Quiz X' }, + }; + + await GET(event); + + const options = mockGenerateReport.mock.calls[0][0]; + expect(options.columns.map((c) => c.value(record))).toEqual([ + 'Ann', + 'a@x.co', + 'Quiz X', + 'Yes', + 3, + ]); + }); + + test('fetchBatch advances the keyset cursor and honors the quizId filter', async () => { + mockFindUnique.mockResolvedValue({ title: 'My Quiz' }); + const event = buildEvent('http://localhost/admin/api/download/quiz?quizId=quiz-1', { + id: 'admin-1', + }); + const fullBatch = Array.from({ length: 100 }, (_, i) => ({ id: `id-${i}` })); + mockFindMany.mockResolvedValueOnce(fullBatch).mockResolvedValueOnce([{ id: 'id-100' }]); + + await GET(event); + const { fetchBatch } = mockGenerateReport.mock.calls[0][0]; + const first = await fetchBatch(undefined); + const second = await fetchBatch('id-99'); + + expect(mockFindMany.mock.calls[0][0]).toMatchObject({ + where: { learningUnit: { questionAnswers: { some: {} }, id: 'quiz-1' } }, + orderBy: { id: 'asc' }, + take: 100, + }); + expect(mockFindMany.mock.calls[0][0]).not.toHaveProperty('cursor'); + expect(first.nextCursor).toBe('id-99'); + expect(mockFindMany.mock.calls[1][0]).toMatchObject({ skip: 1, cursor: { id: 'id-99' } }); + expect(second.nextCursor).toBeUndefined(); + }); + + test('returns 404 and does not stream when a given quizId resolves to no quiz', async () => { + mockFindUnique.mockResolvedValue(null); + const event = buildEvent('http://localhost/admin/api/download/quiz?quizId=missing', { + id: 'admin-1', + }); + + const response = await GET(event); + + expect(response.status).toBe(404); + expect(mockGenerateReport).not.toHaveBeenCalled(); + }); + + test('omits the id filter and the title lookup when no quizId is given', async () => { + const event = buildEvent('http://localhost/admin/api/download/quiz', { id: 'admin-1' }); + mockFindMany.mockResolvedValue([]); + + await GET(event); + const { fetchBatch } = mockGenerateReport.mock.calls[0][0]; + await fetchBatch(undefined); + + expect(mockFindUnique).not.toHaveBeenCalled(); + expect(mockFindMany.mock.calls[0][0].where).toEqual({ + learningUnit: { questionAnswers: { some: {} } }, + }); + }); +}); diff --git a/vendor/xlsx-0.20.3.tgz b/vendor/xlsx-0.20.3.tgz deleted file mode 100644 index b9df84a5..00000000 Binary files a/vendor/xlsx-0.20.3.tgz and /dev/null differ