diff --git a/.gitignore b/.gitignore index e427ff3..5e7b191 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,12 @@ node_modules/ */node_modules/ */build/ +.claude +.DS_Store +*.tsbuildinfo +.env +.env.* +*.env +*.env.* +!.env.example +!*.env.example diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..0f47513 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,30 @@ +# Repository Guidelines + +**MAIN RULE:** DON'T MADE UP ANYTHING!!! IF YOU NOT SURE - DOUBLECHECK IT VIA PROJECT DOCUMENTATION, TOOL DOCUMENTATION, APPROPRIATE MCP, WEB SEARCH OR JUST ASK FOR ME TO CLARIFY. + +## Working Principles + +1. **Check docs first**: Read relevant documents files before starting tasks (links provided below) +2. **Minimal changes**: Update only necessary files, prefer simple robust solutions +3. **Use SKILLS**: For best results in specific areas, use the appropriate **SKILLS** in `.codex/skills` (56 skills available) +4. **Agents orchestration**: Use `.codex/skills/ai-agent-orchestrator/SKILL.md` skill to improve your efficiency. +5. **Use MCP servers**: Available via `mcp____`: GitHub (`github`), Chrome DevTools (`chrome-devtools`) +6. **TypeScript strict mode**: Avoid `any` types, **NEVER** disable linter or TypeScript, **NEVER** use types casting +7. **Concise comments**: Explain "why" not "what", code should be self-documenting +8. **No over-engineering**: Build for a small SaaS used by owner-operators. Choose the simplest robust solution that is fast to implement, easy to understand, and easy to support. Avoid enterprise-style architecture and unnecessary complexity: no extra fallbacks, premature abstractions, microservices, Kubernetes, complex orchestration, heavy caching, or distributed-system patterns unless the task explicitly requires them. User-facing copy must use plain language and avoid accounting jargon. +9. **Native errors for external services**: Pass through errors from Gemini/OpenAI as-is +10. **Centralized exceptions only**: Always use centralized exceptions instead of inlined logs or exceptions +11. **Use tools and agents**: Use MCP servers, web search, and agents when needed +12. **Avoid hardcoded constants**: Add to `backend/src/constants/` or `frontend/src/shared/constants/` +13. **Avoid silent failures**: Provide proper observability for all failure modes +14. **Documentation matters**: After **EACH** task update appropriate documentation files AND for **EACH** new module or feature create documentation file in appropriate directory: `backend/docs/`, `frontend/docs/` or common `docs/` +15. **Aliases for imports**: For **ALL** imports use aliases `@` instead of absolute or relative paths +16. **Tests matters**: After **EACH** task update appropriate unit and e2e tests AND for **EACH** new functionality create new tests (unit or e2e depending on the feature’s complexity and importance) + +## Documentation Entry Points + +- Frontend documentation index: `frontend/docs/index.md` +- Frontend architecture contract: `frontend/docs/frontend-architecture.md` +- Backend and cross-project integration plan: `docs/full-integration-refactor-plan.md` + +For frontend work, read `frontend/docs/frontend-architecture.md` before implementation and follow its three-layer approach: view components, business logic, and API/data access. diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..abf8eb0 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,50 @@ +NODE_ENV=development +PORT=8080 + +# Required secret for signing JWTs. Use a long random value locally and in production. +SECRET_KEY=replace_with_a_long_random_secret + +# Local PostgreSQL connection. Sequelize development config still defaults to localhost values when these are empty. +DB_HOST=localhost +DB_PORT=5432 +DB_USER=postgres +DB_PASS= +DB_NAME=db_school_chain_manager + +# Frontend and Swagger public URLs used for redirects and generated API docs. +UI_HOST=http://localhost +UI_PORT=3000 +SWAGGER_HOST=http://localhost +SWAGGER_PORT=8080 + +# Browser auth cookie and credentialed CORS. These are non-secret deployment values. +ALLOWED_ORIGINS=http://localhost:3000,http://localhost:8080 +AUTH_ACCESS_COOKIE_NAME=school_chain_session +AUTH_REFRESH_COOKIE_NAME=school_chain_refresh +AUTH_COOKIE_SAME_SITE=lax +AUTH_COOKIE_SECURE=false +AUTH_COOKIE_MAX_AGE_MS=900000 +AUTH_REFRESH_TOKEN_MAX_AGE_MS=1209600000 +AUTH_COOKIE_DOMAIN= + +# Seed-only local credentials. Do not use production passwords here. +SEED_ADMIN_EMAIL=admin@example.com +SEED_ADMIN_PASSWORD=replace_with_local_seed_password +SEED_USER_PASSWORD=replace_with_local_seed_password + +# Optional external integrations. +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= +MS_CLIENT_ID= +MS_CLIENT_SECRET= +EMAIL_FROM=School Chain Manager +EMAIL_HOST= +EMAIL_PORT=587 +EMAIL_USER= +EMAIL_PASS= +GCLOUD_BUCKET= +GCLOUD_HASH= +PEXELS_KEY= +PEXELS_QUERY=Lighthouse guiding ships at dawn +GPT_KEY= +FLATLOGIC_PROJECT_UUID= diff --git a/backend/.eslintignore b/backend/.eslintignore deleted file mode 100644 index 3fabb75..0000000 --- a/backend/.eslintignore +++ /dev/null @@ -1,4 +0,0 @@ -# Ignore generated and runtime files -node_modules/ -tmp/ -logs/ diff --git a/backend/.eslintrc.cjs b/backend/.eslintrc.cjs deleted file mode 100644 index f312476..0000000 --- a/backend/.eslintrc.cjs +++ /dev/null @@ -1,15 +0,0 @@ -module.exports = { - env: { - node: true, - es2021: true - }, - extends: [ - 'eslint:recommended' - ], - plugins: [ - 'import' - ], - rules: { - 'import/no-unresolved': 'error' - } -}; diff --git a/backend/docs/auth-profile.md b/backend/docs/auth-profile.md new file mode 100644 index 0000000..5ccb910 --- /dev/null +++ b/backend/docs/auth-profile.md @@ -0,0 +1,51 @@ +# Auth Profile Contract + +## Purpose + +`GET /api/auth/me` is the backend-owned current user profile contract for the product frontend. + +The endpoint must not expose passwords, verification tokens, reset tokens, or raw Sequelize model objects. + +The auth transport hardening is documented in `backend/docs/cookie-auth.md`. + +## Response Shape + +The endpoint returns: + +- `id` +- `email` +- `firstName` +- `lastName` +- `phoneNumber` +- `organizationsId` +- `organizations` +- `app_role` +- `productRole` +- `staffProfile` +- `campus` +- `campusId` +- `permissions` + +`productRole` is derived server-side from generated backend roles first, then staff type, then the default teacher role. + +## Constants + +Role names and mappings live in `backend/src/constants/roles.js`. + +Do not duplicate generated-role to product-role mapping in frontend code. + +## Security Rules + +- JWT validation remains handled by Passport. +- The target browser auth transport is a backend-owned HttpOnly cookie. +- Auth tokens must not be returned to the product frontend in response bodies or redirect URLs. +- Missing or invalid current users return the centralized forbidden error. +- The response is formatted by `AuthService.currentUserProfile`. +- Secrets are read from `backend/.env` or process environment only. +- `backend/.env` is ignored by git; repository access should still be treated carefully because local deployment values can exist in the working copy. + +## Known Gaps + +- Product roles still need a persistent backend migration or a documented server-owned mapping decision. +- Staff profile creation and update flows are not complete. +- Tenant and campus isolation tests still need to be added. diff --git a/backend/docs/campus-attendance.md b/backend/docs/campus-attendance.md new file mode 100644 index 0000000..16f098f --- /dev/null +++ b/backend/docs/campus-attendance.md @@ -0,0 +1,65 @@ +# Campus Attendance Backend + +## Purpose + +The campus attendance API stores campus attendance system links and manually entered daily campus attendance summaries. + +The current CampusAttendance UI uses daily aggregate totals, not student-level attendance sessions. Student-level attendance remains in the existing generated `attendance_sessions` and `attendance_records` models. + +## Data Model + +The module uses: + +- `campus_attendance_config` +- `campus_attendance_summaries` + +Both tables include: + +- `organizationId` for tenant ownership. +- `campus_key` for the approved UI campus keys: `tigers`, `gators`, `hawks`, `owls`, `wildcats`, `grizzlies`. +- nullable `campusId` for future linkage to persisted campus rows. +- audit fields and soft delete timestamps. + +## API + +All routes require JWT authentication. + +- `GET /api/campus_attendance/configs` +- `GET /api/campus_attendance/configs?campusKey=` +- `PUT /api/campus_attendance/configs/:campusKey` +- `GET /api/campus_attendance/summaries` +- `GET /api/campus_attendance/summaries?campusKey=&startDate=&endDate=` +- `PUT /api/campus_attendance/summaries/:campusKey/:date` + +## Access Rules + +- Tenant-wide leadership roles can read all campus config and summary records. +- Campus-scoped users can read their own campus when their backend campus name/code maps to an approved `campus_key`. +- Attendance manager roles can update links and daily summaries. +- The frontend does not send organization, campus UUID, creator, updater, or recorded-by labels. The backend derives them from the authenticated user. + +## Data Contract + +Config mutation fields: + +- `attendance_link` + +Summary mutation fields: + +- `total_enrolled` +- `total_present` +- `total_absent` +- `total_tardy` +- `notes` + +The backend calculates `attendance_percentage` from `total_present / total_enrolled`. The frontend displays the backend-calculated value. + +## Files + +- `backend/src/constants/campus-attendance.js` +- `backend/src/db/models/campus_attendance_config.js` +- `backend/src/db/models/campus_attendance_summaries.js` +- `backend/src/db/migrations/20260608006000-create-campus-attendance-config.js` +- `backend/src/db/migrations/20260608007000-create-campus-attendance-summaries.js` +- `backend/src/services/campus_attendance.js` +- `backend/src/routes/campus_attendance.js` diff --git a/backend/docs/campus-catalog.md b/backend/docs/campus-catalog.md new file mode 100644 index 0000000..6841b1d --- /dev/null +++ b/backend/docs/campus-catalog.md @@ -0,0 +1,46 @@ +# Campus Catalog + +## Purpose + +The database is the source of truth for campus records. Runtime frontend code must not ship campus rows as constants. + +## Backend Contract + +Public read-only campus catalog: + +- `GET /api/public/campuses` + +Response shape: + +```json +{ + "rows": [ + { + "id": "campus uuid", + "name": "Tigers Campus", + "code": "tigers", + "mascot": "Tigers", + "color": "bg-orange-500", + "bgGradient": "from-orange-500 to-amber-500", + "borderColor": "border-orange-500/30", + "textColor": "text-orange-400", + "bgLight": "bg-orange-500/10", + "description": "Strength, courage & determination", + "isOnline": false + } + ], + "count": 1 +} +``` + +Only active campuses are returned. The endpoint is intentionally read-only and does not replace the authenticated `/api/campuses` CRUD workflow. + +Campus identity, names, codes, mascot labels, online flag, descriptions, and branding tokens are campus data and belong in the `campuses` table. + +## Seed Data + +Initial product campuses are seeded by: + +- `backend/src/db/seeders/20260608100000-product-campuses.js` + +The deleted generated sample-data seeder must not be reintroduced. Development or test-only rows belong in backend seeders or backend test fixtures, not frontend runtime constants. diff --git a/backend/docs/communications.md b/backend/docs/communications.md new file mode 100644 index 0000000..c4250ba --- /dev/null +++ b/backend/docs/communications.md @@ -0,0 +1,49 @@ +# Communications Backend + +## Purpose + +The communications API provides product-focused endpoints for parent messages and internal alerts without exposing the generated CRUD routes directly to the frontend workflow. + +Parent messages reuse existing backend tables: + +- `messages` +- `message_recipients` + +Internal alerts use: + +- `communication_events` + +## API + +All routes require JWT authentication. + +- `GET /api/communications/parent-messages`: returns parent messages created by the current user. +- `GET /api/communications/parent-messages?category=`: filters the current user's parent messages by category. +- `POST /api/communications/parent-messages`: creates one sent parent message and recipient log. +- `GET /api/communications/events`: returns internal alert events visible to the current user's organization and campus scope. +- `GET /api/communications/events?type=`: filters events by type. +- `POST /api/communications/events`: creates one internal alert event. + +## Access Rules + +- Authenticated tenant users can create and list their own parent-message logs. +- Communication manager roles can create internal alert events. +- Tenant-wide roles can list all organization events. +- Campus-scoped users list events for their campus when a campus is available on their profile. + +## Data Contract + +Parent message create fields: + +- `recipientName` +- `messageText` +- `category` + +Event create fields: + +- `title` +- `date` +- `type` +- `roles` + +The frontend does not send organization, campus, creator, or updater fields. The backend fills them from the authenticated user. diff --git a/backend/docs/content-catalog.md b/backend/docs/content-catalog.md new file mode 100644 index 0000000..b2255b2 --- /dev/null +++ b/backend/docs/content-catalog.md @@ -0,0 +1,94 @@ +# Content Catalog + +## Purpose + +`content_catalog` stores backend-owned seeded product content that the frontend renders through backend APIs. + +This keeps the database and backend seeds as the source of truth for domain/content records instead of duplicating those records in frontend runtime constants. + +## Files + +- Migration: `backend/src/db/migrations/20260608102000-create-content-catalog.js` +- Model: `backend/src/db/models/content_catalog.js` +- Service: `backend/src/services/content_catalog.js` +- Route: `backend/src/routes/public_content_catalog.js` +- Management route: `backend/src/routes/content_catalog.js` +- Seed payloads: `backend/src/db/seeders/content-catalog-data/content-catalog-seed-payloads.js` +- Seeder: `backend/src/db/seeders/20260608103000-content-catalog.js` + +## API + +Public read endpoint: + +- `GET /api/public/content-catalog/:contentType` + +The response returns one active content payload for the requested `contentType`. + +Authenticated management endpoints: + +- `GET /api/content-catalog` +- `POST /api/content-catalog` +- `GET /api/content-catalog/:contentType` +- `PUT /api/content-catalog/:contentType` +- `DELETE /api/content-catalog/:contentType` + +Management endpoints are restricted to global access users and content managers. They allow runtime configuration for catalog-backed modules such as QBS quiz content, while the frontend runtime continues to consume active records through the public read endpoint. + +## Seeded Content Types + +- `classroom-strategies` +- `safety-qbs-quiz` +- `sign-language-items` +- `sign-language-page-content` +- `regulation-zones` +- `zones-of-regulation-page-content` +- `dashboard-teacher-images` +- `dashboard-encouraging-quotes` +- `dashboard-compliance-items` +- `dashboard-sign-of-week` +- `parent-message-templates` +- `community-organizations` +- `vocational-opportunities` +- `emotional-intelligence-assessment-questions` +- `emotional-intelligence-weekly-topics` +- `emotional-intelligence-growth-tips` +- `emotional-intelligence-team-wellness-metrics` +- `emotional-intelligence-weekly-focus` +- `personality-quiz-questions` +- `personality-types` +- `personality-workplace-content` +- `esa-funding-content` +- `safety-protocols` +- `classroom-timer-backgrounds` +- `classroom-timer-sounds` +- `classroom-timer-presets` +- `classroom-timer-tips` +- `personality-quiz-features` + +## Rules + +- Add production content records to backend seed payloads, not frontend constants. +- Keep frontend constants limited to UI config, labels, query keys, timing values, and presentation tokens. +- If a catalog needs complex workflow state, approvals, or per-campus variants, replace the generic catalog entry with typed backend tables and tenant-scoped CRUD APIs. +- Missing content types fail explicitly through the service instead of returning silent empty payloads. +- Do not duplicate catalog payload copy in frontend constants. Frontend may define TypeScript contracts and UI-only labels, but editable content belongs to the backend. + +## Classroom Strategies + +`classroom-strategies` stores strategy records rendered by the classroom support page. Titles, descriptions, images, categories, age groups, zones, and implementation tips are backend-owned content fields and should be edited through catalog management APIs or backend seeds. + +## Sign Language + +`sign-language-items` stores sign records rendered by the sign language page. Teaching tips, video URLs, GIF URLs, and step instructions are backend-owned content fields. + +`sign-language-page-content` stores page-level teaching reminders for the sign language page. This keeps editable instructional copy out of frontend runtime constants. + +## Zones Of Regulation + +`regulation-zones` stores zone records rendered by the zones of regulation page. Zone descriptions, behaviors, strategies, matching signs, and zone presentation classes are backend-owned content fields. + +`zones-of-regulation-page-content` stores page-level safety connection copy and quick de-escalation flow content for the zones page. This keeps editable instructional copy out of frontend runtime constants. + +## ESA Funding + +`esa-funding-content` stores editable ESA funding content rendered by the ESA funding page. Approved uses, key points, state checklist items, school impact items, staff role guidance, parent conversation script, and resource records are backend-owned content fields. Static ESA intro copy and FAQs live in frontend constants because they are stable training copy rather than editable runtime records. diff --git a/backend/docs/cookie-auth.md b/backend/docs/cookie-auth.md new file mode 100644 index 0000000..a5caaf9 --- /dev/null +++ b/backend/docs/cookie-auth.md @@ -0,0 +1,56 @@ +# Cookie Auth + +## Purpose + +Browser authentication uses a backend-owned HttpOnly cookie. The product frontend does not store, read, or send auth tokens manually. + +## Runtime Contract + +- `POST /api/auth/signin/local` validates credentials, sets access and refresh HttpOnly cookies, and returns the current user profile. +- `GET /api/auth/me` authenticates from the access cookie and returns the current user profile. +- `POST /api/auth/refresh` authenticates from the refresh cookie, rotates it, sets fresh cookies, and returns the current user profile. +- `POST /api/auth/signout` revokes the active refresh token, clears both cookies, and returns `204 No Content`. +- Social auth callbacks set access and refresh cookies and redirect to the frontend without token query parameters. + +## Refresh Tokens + +Auth uses access/refresh cookie rotation: + +- short-lived access cookie for normal Passport-protected API requests +- long-lived opaque refresh cookie used only by `POST /api/auth/refresh` +- hashed refresh token storage in the backend database +- refresh-token rotation on every successful refresh +- refresh-token family revocation when revoked-token reuse is detected +- both cookies cleared on sign-out + +The frontend must not read or receive access or refresh tokens. It should only send credentialed requests and perform one controlled refresh-and-retry when an access cookie expires. If both access and refresh credentials are expired or invalid, the frontend must redirect to `/login` instead of showing a raw session error. + +## Configuration + +Cookie and CORS values are configured through `backend/src/config.js` from non-secret environment values: + +- `ALLOWED_ORIGINS` +- `AUTH_COOKIE_NAME` +- `AUTH_COOKIE_SAME_SITE` +- `AUTH_COOKIE_SECURE` +- `AUTH_COOKIE_MAX_AGE_MS` +- `AUTH_COOKIE_DOMAIN` + +Access and refresh cookie names and lifetimes are part of this config contract. + +Secrets remain in environment variables only, especially `SECRET_KEY` and OAuth/email credentials. + +## Code Ownership + +- Cookie helpers live in `backend/src/auth/cookies.js`. +- Passport JWT extraction reads the cookie in `backend/src/auth/auth.js`. +- CSRF origin checks live in `backend/src/middlewares/csrf-origin.js`. +- Auth routes own setting and clearing cookies through helper functions. + +## Security Rules + +- Auth tokens must not be returned in response bodies. +- Auth tokens must not be placed in redirect URLs. +- Protected browser API routes must authenticate through the HttpOnly cookie. +- Credentialed CORS must allow only configured frontend origins. +- Unsafe methods are protected by Origin/Referer validation. diff --git a/backend/docs/frame-entries.md b/backend/docs/frame-entries.md new file mode 100644 index 0000000..79b2385 --- /dev/null +++ b/backend/docs/frame-entries.md @@ -0,0 +1,40 @@ +# FRAME Entries Backend + +## Purpose + +`frame_entries` stores weekly F.R.A.M.E. focus entries per organization. The backend is the source of truth for persisted FRAME data. + +## API + +All routes require JWT authentication. + +- `GET /api/frame_entries`: returns `{ rows, count }` for the current user's organization. +- `POST /api/frame_entries`: creates one entry and returns the created DTO. +- `PUT /api/frame_entries/:id`: updates one entry inside the current user's organization and returns the updated DTO. + +## Access Rules + +- All authenticated users in the organization can read FRAME entries. +- Editing is restricted to generated roles mapped to director or superintendent capabilities: + - `Super Administrator` + - `Administrator` + - `Platform Owner` + - `Tenant Director` + - `Campus Manager` + +The frontend may hide editing controls, but backend role checks remain authoritative. + +## Data Contract + +Required request fields: + +- `week_of` +- `posted_date` +- `formal` +- `recognition` +- `application` +- `management` +- `emotional` +- `author` + +Tenant scope is assigned from the current authenticated user. `campusId` is optional and defaults to the current staff profile campus when available. diff --git a/backend/docs/personality-quiz-results.md b/backend/docs/personality-quiz-results.md new file mode 100644 index 0000000..5f6f912 --- /dev/null +++ b/backend/docs/personality-quiz-results.md @@ -0,0 +1,44 @@ +# Personality Quiz Results Backend + +## Purpose + +The personality quiz results API stores each authenticated staff user's current EI/personality quiz result and exposes aggregate distribution data for leadership reporting. + +The workflow uses a dedicated tenant-owned table: + +- `personality_quiz_results` + +It does not update staff profile records. Staff profile extension should be handled as a separate schema decision if the product later needs personality type on the staff profile itself. + +## API + +All routes require JWT authentication. + +- `GET /api/personality_quiz_results/me`: returns the current user's saved result or `null`. +- `PUT /api/personality_quiz_results/me`: creates or updates the current user's saved result. +- `GET /api/personality_quiz_results/distribution`: returns aggregate counts by personality type for authorized report roles. +- `GET /api/personality_quiz_results/distribution?campusId=`: filters aggregate counts by campus when provided. + +## Access Rules + +- Authenticated tenant users can read and update only their own result. +- Director, superintendent, and mapped backend leadership roles can read aggregate distributions. +- Distribution endpoints return counts only. They do not expose individual staff names or individual answers. +- Organization, campus, user, creator, and updater fields are derived from the authenticated backend user. + +## Data Contract + +Result mutation fields: + +- `personalityType` +- `quizAnswers` + +The backend validates that `personalityType` is present and that `quizAnswers` is an object. The frontend sends answer maps through the typed API layer; the backend stores them as JSON. + +## Files + +- `backend/src/constants/personality.js` +- `backend/src/db/models/personality_quiz_results.js` +- `backend/src/db/migrations/20260608005000-create-personality-quiz-results.js` +- `backend/src/services/personality_quiz_results.js` +- `backend/src/routes/personality_quiz_results.js` diff --git a/backend/docs/safety-quiz-results.md b/backend/docs/safety-quiz-results.md new file mode 100644 index 0000000..b571f6f --- /dev/null +++ b/backend/docs/safety-quiz-results.md @@ -0,0 +1,32 @@ +# Safety Quiz Results Backend + +## Purpose + +`safety_quiz_results` stores weekly de-escalation/QBS quiz submissions per authenticated staff user. The backend owns tenant scope, user ownership, user display name, and role snapshot. + +## API + +All routes require JWT authentication. + +- `GET /api/safety_quiz_results`: returns quiz results visible to the current user. +- `GET /api/safety_quiz_results?week_of=`: filters visible results by week. +- `POST /api/safety_quiz_results`: saves one quiz result for the current user. + +## Access Rules + +- Staff users can create results for themselves. +- Staff users can read their own results. +- Director/superintendent-capable generated roles can read organization-level results for compliance views. + +## Data Contract + +Required mutation fields: + +- `quiz_id` +- `quiz_title` +- `week_of` +- `score` +- `total_questions` +- `answers` + +The frontend does not send user names or roles for ownership. The backend fills `user_name`, `user_role`, `organizationId`, `campusId`, and `userId` from the authenticated user. diff --git a/backend/docs/staff-attendance.md b/backend/docs/staff-attendance.md new file mode 100644 index 0000000..67fe424 --- /dev/null +++ b/backend/docs/staff-attendance.md @@ -0,0 +1,56 @@ +# Staff Attendance Backend + +## Purpose + +The staff attendance API provides staff-level attendance records and summary counts for the attendance snapshot and director dashboard. + +This workflow is separate from: + +- student-level generated attendance models: `attendance_sessions`, `attendance_records` +- campus daily aggregate summaries: `campus_attendance_summaries` + +## Data Model + +The module uses: + +- `staff_attendance_records` + +Records include: + +- `attendance_date` +- `status`: `present`, `late`, or `absent` +- `note` +- `user_name` +- `user_role` +- `organizationId` +- `campusId` +- `userId` +- audit fields and soft delete timestamps + +## API + +All routes require JWT authentication. + +- `GET /api/staff_attendance/records` +- `GET /api/staff_attendance/records?startDate=&endDate=&limit=` +- `GET /api/staff_attendance/summary` +- `GET /api/staff_attendance/summary?startDate=&endDate=&limit=` + +## Access Rules + +- Regular staff can read only their own staff attendance records. +- Report roles can read campus-scoped staff attendance records. +- Tenant-wide leadership roles can read organization-wide records. +- Summary includes active staff count from `staff` plus attendance status counts from `staff_attendance_records`. + +## Remaining Related Work + +The module currently exposes read/report endpoints only. Add write or import endpoints when the staff attendance source system is defined. + +## Files + +- `backend/src/constants/staff-attendance.js` +- `backend/src/db/models/staff_attendance_records.js` +- `backend/src/db/migrations/20260608008000-create-staff-attendance-records.js` +- `backend/src/services/staff_attendance.js` +- `backend/src/routes/staff_attendance.js` diff --git a/backend/docs/typescript-esm-migration-plan.md b/backend/docs/typescript-esm-migration-plan.md new file mode 100644 index 0000000..3c02a0b --- /dev/null +++ b/backend/docs/typescript-esm-migration-plan.md @@ -0,0 +1,427 @@ +# План миграции бэкэнда на TypeScript + ESM + +Статус: черновик плана (реализация ещё не начата). Дата: 2026-06-09. + +Этот документ описывает полную, поэтапную миграцию `backend/` с CommonJS/JavaScript на +TypeScript со строгим режимом и нативные ESM-модули (`import`/`export`). План составлен +так, чтобы приложение оставалось рабочим на каждом шаге и соответствовало правилам +`CLAUDE.md` (без `any`, без отключения линтера/TS, без приведения типов, импорты через +алиас `@`, документация и тесты на каждый модуль, минимальные изменения, без +оверинжиниринга). + +--- + +## 0. Принятые решения (2026-06-09) + +Зафиксированы по итогам обсуждения и проверочных spike: + +1. **Дата/время:** `moment` → **`dayjs`** (минимальный diff, близкий API). +2. **sequelize-cli в ESM (spike проведён):** подтверждено — CLI загружает `.cjs`-конфиг и `.sequelizerc` в пакете с `"type": "module"`. Но т.к. выбраны TS-миграции (см. п.4), sequelize-cli из миграционного флоу убирается. +3. **Инструменты:** dev — раннер **`tsx`** (исполняет `.ts`+ESM напрямую, это инструмент, не React-разметка), прод — сборка **`tsc` + `tsc-alias`**. Бандлер не вводим. +4. **Миграции — на TypeScript.** Используем **Umzug 3 + `tsx`** (spike подтвердил выполнение `.ts`-миграций end-to-end). Хранилище — `SequelizeStorage` поверх существующей таблицы `SequelizeMeta`, поэтому история уже применённых миграций сохраняется. Лучшие практики — см. раздел 11. +5. **Версия Node:** **24 (Active LTS)**. Удовлетворяет самым строгим ограничениям зависимостей (`vitest`, `eslint` требуют `>=24`). Node 26 — ещё «Current», не LTS, в прод не берём. +6. **Тесты:** минимальный смоук-набор на этапе миграции (+ unit на чистые модули). +7. **OAuth-стратегии passport:** обновление — **отдельной задачей** после миграции; задача внесена в `docs/full-integration-refactor-plan.md`. + +ORM остаётся **Sequelize 6.37** (решение зафиксировано отдельно: Prisma — слишком большой рефакторинг, v7 — alpha). + +--- + +## 1. Текущее состояние (факты, на которых строится план) + +Замеры по `backend/src` (без `node_modules`): + +- Всего файлов `.js`: **206**, суммарно ~**55 651** строк. +- Распределение по слоям: + - `db/` — 90 файлов (модели 39, db/api 28, миграции 15, сидеры 5), ~33 400 строк — самый объёмный слой. + - `routes/` — 45 файлов, ~12 700 строк. + - `services/` — 48 файлов, ~7 600 строк. + - `constants/` — 13 файлов, `middlewares/` — 3, `auth/` — 2, `config/` — 1 + `config.js`, `ai/` — 1. +- Модульная система: **100% CommonJS**. `require(` используется в 173 файлах, `module.exports` — в 201; ESM-импортов (`import …`) — **0**. +- Экспорт-паттерны: `module.exports = class` — 74 файла (services, db/api, middlewares, helpers); `module.exports = function(sequelize, DataTypes)` — 38 (фабрики моделей Sequelize). +- ORM: **Sequelize 6.37**, миграции/сидеры через **sequelize-cli 6.6.5** (CommonJS-инструмент, читает `.sequelizerc` → `src/db/db.config.js`). +- Express **5.2**, аутентификация на **passport** (JWT + Google + Microsoft), документация через **swagger-jsdoc** (парсит `./src/routes/*.js`). +- Dev-запуск: `watcher.js` (chokidar + nodemon), он же триггерит `db:migrate` / `db:seed` при появлении новых файлов. +- ESLint flat-config с `sourceType: 'commonjs'` и правилом `import-x/no-unresolved`. +- Тестов в бэкэнде сейчас **нет** (0 файлов `*.test.js` / `*.spec.js`); проверка — только `node -c` (синтаксис) согласно `docs/full-integration-refactor-plan.md`. +- Алиас `@` в бэкэнде пока **не используется** (хотя `CLAUDE.md` его требует); все импорты относительные. +- Окружения: `engines.node >= 18`, Dockerfile на `node:20.15.1-alpine`, локально доступен Node 22. + +Важный ориентир: **фронтенд уже на TypeScript + ESM + Vite** (strict, `paths: { "@/*": ["./src/*"] }`, +`vitest`, `playwright`). Бэкэнд логично привести к тем же конвенциям (strict TS, алиас `@`, vitest). + +### ESM-блокеры, выявленные в коде + +1. **Динамическая загрузка моделей** в `db/models/index.js`: `fs.readdirSync(__dirname)` + `require(path.join(...))` в цикле. В нативном ESM `require` недоступен; нужен либо явный список `import`, либо динамический `import()`. +2. **`__dirname` / `__filename`** в 7 файлах: `config/load-env.js`, `index.js`, `ai/LocalAIApi.js`, `db/models/index.js`, `services/email/list/{passwordReset,addressVerification,invitation}.js`. В ESM их нет — замена на `fileURLToPath(import.meta.url)`. +3. **sequelize-cli** загружает `.sequelizerc`, `db.config.js`, миграции и сидеры как CommonJS через `require`. При `"type": "module"` файлы `.js` станут ESM и CLI сломается. +4. **swagger-jsdoc** указывает на `./src/routes/*.js` — после сборки путь к исходникам/выходу изменится. +5. **`watcher.js` + nodemon** заточены под запуск `.js`-файла напрямую. +6. В ESM (NodeNext) **относительные импорты требуют явного расширения** (`./foo.js`), а алиасы tsconfig **не резолвятся Node в рантайме** без дополнительного инструмента. + +--- + +## 2. Целевое состояние + +- Весь рантайм-код бэкэнда — `.ts` со строгим TypeScript (`strict: true`, `noImplicitAny`, `strictNullChecks`), без `any` и без приведения типов. +- Нативный ESM: `"type": "module"` в `package.json`, `import`/`export` во всём рантайм-коде. +- Импорты через алиас `@` (как на фронтенде), работающие и в dev, и в прод-сборке. +- Сборка `tsc` → `dist/`, запуск прод из `dist/`; dev — через `tsx watch` (TS + ESM + алиасы без отдельной сборки). +- Миграции и сидеры остаются на sequelize-cli и сохраняют CommonJS (расширение `.cjs`) — как исторические артефакты, переписывать их на TS нецелесообразно. +- Введён минимальный каркас типизированных тестов (`vitest`) и `typecheck` как гейт качества. +- Обновлены Dockerfile, скрипты `package.json`, ESLint и документация. + +--- + +## 3. Ключевые проектные решения (с обоснованием) + +### 3.1. Две фазы вместо «большого взрыва» + +Задача совмещает две независимые трансформации: смену **языка** (JS → TS) и смену +**модульной системы** (CJS → ESM). Делать обе сразу на 206 файлах рискованно. Рекомендуется +разделить: + +- **Фаза A — типизация (остаёмся на CommonJS).** TypeScript компилируется в `module: "CommonJS"`. Включаем `allowJs: true`, чтобы `.ts` и `.js` сосуществовали, и мигрируем файлы снизу вверх по дереву зависимостей. Приложение всё это время запускается как раньше. Риск минимальный. +- **Фаза B — перевод на ESM.** Когда весь код уже на TS, механически переводим модульную систему: `"type": "module"`, `module: "NodeNext"`, `require`→`import`, `module.exports`→`export`, `__dirname`→`import.meta.url`, переписываем динамический загрузчик моделей, чиним sequelize-cli и swagger-пути. + +Такое разделение изолирует «языковые» ошибки от «модульных» и даёт чёткие точки отката. + +> Альтернатива (не рекомендуется): одновременный переход на `tsx` + `"type": "module"` и +> правка всех импортов сразу. Быстрее по числу шагов, но даёт большой нестабильный +> diff и трудный откат. Противоречит принципу «минимальные изменения». + +### 3.2. Инструмент сборки и запуска + +- **Сборка:** `tsc` (официальный компилятор) в `dist/`. Просто, предсказуемо, без бандлера — соответствует «без оверинжиниринга». +- **Алиасы `@` в выходной сборке:** `tsc` не переписывает `@/*` в относительные пути. Добавляем `tsc-alias` как пост-шаг сборки. (Альтернатива — нативные `imports` в `package.json` с префиксом `#`, но `CLAUDE.md` требует именно `@`.) +- **Dev-запуск:** `tsx watch src/index.ts` — исполняет TS+ESM напрямую и резолвит `paths` из tsconfig, заменяя `nodemon`. Авто-миграции/сидинг из `watcher.js` выносим в отдельный `predev`-шаг или оставляем лёгкий watcher только для `db/migrations`+`db/seeders` (см. 4.7). + +### 3.3. Миграции — переход на Umzug + TypeScript (решение принято) + +sequelize-cli — CommonJS-инструмент и не поддерживает `.ts`-миграции. Поскольку выбраны +TS-миграции (решение 0.4), **отказываемся от sequelize-cli** в пользу **Umzug 3** — это та же +библиотека, что лежит под капотом sequelize-cli, поэтому переход совместим по хранилищу +истории. + +Подход (детали и пример каркаса — в разделе 11): + +- Собственный лёгкий раннер `db/migrate.ts` на Umzug, запускаемый через `tsx`. +- Хранилище истории — `SequelizeStorage` поверх существующей таблицы **`SequelizeMeta`**. Уже применённые миграции остаются записанными, повторно не выполняются. +- **Новые** миграции пишутся как `.ts` (ESM, типизированный `QueryInterface`). +- **Существующие 15 миграций** не переписываем: оставляем как есть в формате `.cjs` (исторические артефакты), Umzug-glob включает и `.cjs`, и `.ts`, `tsx` грузит оба. Это исключает риск нарушить уже применённую историю БД. +- Сидеры — вторым экземпляром Umzug (отдельная meta-таблица) либо оставляем минимальный текущий механизм; решается в разделе 11. + +> Spike подтвердил: Umzug 3.8.3 + `tsx` выполняют `.ts`-миграции end-to-end. Отдельно +> подтверждено, что `.cjs`-артефакты корректно грузятся в ESM-пакете, что нужно для +> сосуществования старых `.cjs` и новых `.ts` миграций. + +Модели переводим на TS/ESM (их импортирует рантайм). Команды миграций моделей не требуют. + +### 3.4. Загрузчик моделей и типобезопасный объект `db` + +Заменяем `fs.readdirSync + require` на **явные `import` всех 39 моделей** в `db/models/index.ts` +и сборку строго типизированного объекта `db`. Это убирает динамику (несовместимую с ESM-статикой), +даёт автодополнение и исключает `any`. Минус — вербозный index с 39 импортами, но это +разовый предсказуемый код. **Динамический `import()` здесь не используем** — только статические. + +### 3.4.1. Политика импортов: статика по умолчанию + +Решение: **избегаем динамических импортов (`import()`)**, кроме случаев, где это действительно +оправдано. На текущем коде таких случаев нет: + +- Единственный реальный динамический `require` — загрузчик моделей (`db/models/index.js:25`) — переводится на статические импорты (3.4). +- Найденные `import("…")` — это JSDoc-аннотации типов, а не рантайм; в TS становятся обычными `import type`. +- `__dirname`/`__filename` в 7 файлах используются для `fs`-чтения файлов (`.env`, HTML-шаблоны писем), **не для импортов**. Замена на `path.dirname(fileURLToPath(import.meta.url))` сохраняет их статическими — это не динамический импорт. + +Если в будущем потребуется ленивая загрузка (тяжёлый опциональный модуль, разрыв цикла +зависимостей), динамический `import()` допускается **точечно и с обоснованием в коде**, а не как +паттерн. Циклы зависимостей предпочтительно разрывать рефакторингом, а не `import()`. + +### 3.5. Стратегия типизации моделей Sequelize + +Модели сейчас — фабрики `sequelize.define('name', {...})`. Чтобы не переписывать 39 моделей в +class-based стиль (большой рискованный diff), рекомендуется **остаться на `define()`**, но +добавить типобезопасность: + +- На каждую модель — интерфейс атрибутов (`XAttributes`) и creation-атрибутов (`XCreationAttributes`). +- Фабрика возвращает `ModelStatic>`. +- `associate` типизируется через общий тип реестра `Db`. + +Это самый объёмный по трудозатратам пункт миграции (39 моделей + связи), поэтому он вынесен в +отдельную подфазу и может идти параллельно остальному после готовности каркаса. + +### 3.6. Типизация Express и слоёв + +- `@types/express`, `Request`/`Response`/`NextFunction`, расширение `Request` полем `currentUser` через declaration merging (`src/types/express.d.ts`). +- `helpers.wrapAsync`, `commonErrorHandler`, middlewares — строго типизированные сигнатуры. +- db/api и services — публичные методы получают типы входных DTO и возвращаемых моделей. + +--- + +## 4. Поэтапный план работ + +### Фаза 0 — Подготовка инструментов (без изменения рантайм-поведения) + +1. Установить dev-зависимости: `typescript`, `tsx`, `tsc-alias`, `@types/node`, и типы для библиотек без собственных деклараций: `@types/express`, `@types/cors`, `@types/passport`, `@types/passport-jwt`, `@types/jsonwebtoken`, `@types/nodemailer`, `@types/multer`, `@types/swagger-jsdoc`, `@types/swagger-ui-express`, `@types/bcrypt`, `@types/validator`. (axios, sequelize, pg, `dayjs`, `@json2csv/plainjs`, `umzug` поставляют типы сами; `@types/lodash` не нужен — `lodash` удаляется.) Добавить рантайм-зависимость `umzug` (для TS-миграций, см. раздел 11). +2. Создать `backend/tsconfig.json` (Фаза A — CommonJS): + - `strict: true`, `noImplicitAny`, `strictNullChecks`, `noUnusedLocals`, `noUnusedParameters`, `noFallthroughCasesInSwitch` (зеркало фронтенда). + - `target: ES2022`, `module: CommonJS`, `moduleResolution: Node`, `esModuleInterop: true`. + - `allowJs: true`, `checkJs: false` — для сосуществования `.js` и `.ts`. + - `outDir: dist`, `rootDir: src`, `baseUrl: src`, `paths: { "@/*": ["*"] }`. +3. Обновить ESLint: добавить `typescript-eslint`, парсер для `.ts`, сохранить `import-x/no-unresolved` с TS-резолвером. На Фазе A не ломать существующие `.js`-правила. +4. Добавить скрипт `typecheck: tsc --noEmit` и завести его как обязательный гейт. + +Критерий готовности фазы: `npm run typecheck` проходит на смешанной кодовой базе, приложение запускается как раньше. + +### Фаза 0.5 — Аудит и обновление зависимостей (совмещается с Фазой A) + +Полный разбор — в разделе 10. Кратко по порядку: + +1. **Удалить неиспользуемые** драйверы и пакеты: `mysql2`, `tedious`, `sqlite` (диалект везде `postgres`, 0 использований), `lodash` (один импорт `lodash/get`, заменяется на optional chaining). +2. **Заменить deprecated** `json2csv@5.0.7` → `@json2csv/plainjs@7` (26 файлов). +3. **Заменить устаревшую** `moment` → **`dayjs`** (26 файлов). +4. **Консолидировать загрузку файлов**: убрать `formidable` (1 файл), оставить `multer`; убрать `body-parser` → `express.json()` (Express 5). +5. **Убрать `sequelize-cli`** (миграции переходят на Umzug + tsx, см. раздел 11). OAuth-стратегии — отдельной задачей (см. `full-integration-refactor-plan.md`); `sequelize-json-schema` пересмотреть при типизации `routes/openai.ts`. +6. Минорно поднять актуальные пакеты (`@google-cloud/storage` 7.19→7.21 и т.п.). + +Эти изменения лучше делать вместе с типизацией соответствующих модулей, чтобы новые типы сразу ложились на новые API. Каждое — отдельным PR со смоук-проверкой. + +### Фаза A — Перевод на TypeScript (язык), модульная система = CommonJS + +Мигрируем снизу вверх по зависимостям, по одному связному блоку за раз; после каждого блока — `typecheck` + ручной/смоук-прогон. + +1. `constants/` (13 файлов) — листовые, без зависимостей. Перевести в `.ts`, экспортировать типизированные константы (`as const`). +2. `config/load-env.ts`, `config.ts` — типизировать чтение env (хелперы `requiredEnv`/`readBooleanEnv`/`readNumberEnv`/`readListEnv` уже есть, добавить типы и тип `Config`). +3. `helpers.ts`, `db/utils.ts` — общие утилиты. +4. `db/models/*` — типизация моделей (см. 3.5) + типобезопасный `db/models/index.ts` (см. 3.4). Самый крупный блок; можно дробить по доменам. +5. `db/api/*` (28 файлов) — типизировать публичные статические методы, опираясь на типы моделей. +6. `services/*` (48 файлов), включая `services/notifications/errors/*`, `services/email/list/*`. +7. `auth/*` и `middlewares/*` — типизация passport-стратегий, cookies, проверки прав; declaration merging для `Request.currentUser`. +8. `ai/LocalAIApi.ts`. +9. `routes/*` (45 файлов) — типизированные роутеры; swagger-JSDoc-комментарии переносятся как есть (swagger-jsdoc парсит и `.ts`). +10. `index.ts` — точка входа. +11. Существующие миграции/сидеры/`db.config`/`.sequelizerc` — на Фазе A **не трогаем** (остаются `.js`, CommonJS-пакет их корректно исполняет). Перевод на Umzug — в Фазе B. + +Критерий готовности: 0 `.js` в рантайм-коде (кроме исторических миграций/сидеров), `typecheck` зелёный, приложение работает. + +### Фаза B — Перевод на ESM (модульная система) + +1. Переключить tsconfig на ESM: `module: NodeNext`, `moduleResolution: NodeNext`. Отключить `allowJs` (язык уже весь TS). +2. Поставить `"type": "module"` в `package.json`. +3. Конвертировать синтаксис во всех `.ts`: `require`→`import`, `module.exports`→`export` / `export default`. (Можно ускорить кодмодом `cjstoesm` — но результат вычитывать вручную; правило: не доверять слепо.) +4. `__dirname`/`__filename` → `path.dirname(fileURLToPath(import.meta.url))` в 7 файлах. +5. Переписать `db/models/index.ts` на статические `import` всех моделей. +6. Поправить относительные импорты под NodeNext (явные расширения в выходе обеспечивает `tsc-alias`; алиас `@` — предпочтительный путь). +7. **Миграции на Umzug + tsx (раздел 11):** убрать `sequelize-cli` и `.sequelizerc`; перевести `db.config.js`→`db/config.ts`; добавить раннер `db/migrate.ts` (Umzug + `SequelizeStorage` на `SequelizeMeta`); существующие 15 миграций оставить как `.cjs`, новые писать на `.ts`. Проверить `migrate up` на чистой БД и на БД с уже применённой историей. +8. **swagger-jsdoc:** сделать путь `apis` env-зависимым — dev: `./src/routes/*.ts`, прод: `./dist/routes/*.js`. +9. **dev-запуск:** заменить `nodemon`/`watcher.js` на `tsx watch src/index.ts` (hot-reload + стриминг логов в реальном времени). Авто-применение миграций — `predev`-шаг (`tsx src/db/migrate.ts up`); если нужно сохранить применение «на лету» при добавлении файла, оставить лёгкий chokidar-вотчер на `db/migrations`/`db/seeders`. Паритет с текущим поведением проверяется в Фазе D, п.4. +10. `db/reset.js` (использует `execSync('sequelize ...')`) — переписать на вызов Umzug-раннера (`migrate down/up` или `sequelize.sync` для dev), перевести в `.ts`/ESM. + +Критерий готовности: `npm run build` (tsc + tsc-alias) собирает `dist/`, `node dist/index.js` стартует, миграции/сидеры проходят, все маршруты отвечают. + +### Фаза C — Скрипты, сборка, контейнеризация + +1. `package.json` скрипты: + - `dev: tsx watch src/index.ts` + - `build: tsc && tsc-alias` + - `start: node dist/index.js` (прод; миграции — отдельной командой перед стартом) + - `db:migrate: tsx src/db/migrate.ts up`, `db:rollback: tsx src/db/migrate.ts down`, `db:seed: tsx src/db/seed.ts` (раздел 11) + - `typecheck`, `lint`, `test`. + - Указать `engines.node: ">=24"` (Active LTS, решение 0.5). +2. Обновить корневой `package.json` (`build:production`, `start:production`) под новый build-флоу бэкэнда. +3. **Копирование ассетов в `dist/`.** `tsc` собирает только `.ts`. Не-TS ассеты, которые читаются через `fs` по пути от `import.meta.url`, нужно копировать в `dist/` отдельным build-шагом: HTML-шаблоны писем `src/services/email/htmlTemplates/**` (используются в `services/email/list/*`). Добавить в `build` (например, `cpy`/`copyfiles`/`rsync`) и проверить, что пути от `import.meta.url` резолвятся в `dist/`. +4. Dockerfile: базовый образ **`node:24-alpine`** (текущая Active LTS); добавить шаг `npm run build`, в прод-образ копировать `dist/` (включая скопированные шаблоны) + `node_modules` (multi-stage build). Заменить `yarn install` на `npm ci` (консистентно с корневым `build:production`). +5. `.dockerignore`/`.gitignore`: добавить `dist/`. + +### Фаза D — Тесты и верификация + +1. Добавить `vitest` (консистентно с фронтендом) и базовый каркас тестов. +2. Покрыть unit-тестами критичные чистые модули: `config` (парсинг env), `helpers`, `db/utils`, auth-хелперы, проверки прав. +3. Смоук-проверка рантайма: старт сервера, `/api-docs`, healthcheck, 1–2 защищённых маршрута. +4. **Проверка паритета dev-workflow (nodemon/watcher).** Убедиться, что после перехода на `tsx watch` опыт разработки не хуже текущего `watcher.js` + nodemon: + - **Hot-reload:** правка любого `.ts` в `src/**` вызывает автоматический перезапуск сервера; в консоли видны сообщения о рестарте (аналог `nodemon restarted due changes`). + - **Логи в реальном времени:** `console.*`/логи приложения стримятся в stdout сразу, без буферизации; проверить, что вывод не теряется при рестарте (`tsx watch` не глушит stdout дочернего процесса). + - **Миграции в dev:** новые миграции применяются ожидаемым образом. Текущий `watcher.js` авто-применял новые файлы в `db/migrations`/`db/seeders` «на лету» через chokidar; план заменяет это на `predev`-шаг `db:migrate`. Подтвердить, что выбранный сценарий (ручной `npm run db:migrate` при добавлении миграции **или** сохранённый отдельный chokidar-вотчер) задокументирован и работает. Если авто-применение «на лету» нужно — оставить лёгкий watcher, запускающий `tsx src/db/migrate.ts up` на событие `add`. + - **Завершение процесса:** `Ctrl+C` корректно останавливает `tsx watch` и дочерний сервер (нет «зависших» процессов на порту). +5. Обновить `docs/full-integration-refactor-plan.md` (раздел Backend baseline): добавить `typecheck`, `build`, тесты. +6. Финальный гейт: `typecheck` + `lint` + `vitest` + `build` + ручной прогон миграций на чистой БД + подтверждённый паритет dev-workflow (п.4). + +--- + +## 5. Типизация: где основной объём + +| Слой | Файлов | Сложность типизации | +|---|---|---| +| `db/models` | 39 | Высокая — атрибуты, creation-атрибуты, связи | +| `db/api` | 28 | Средняя — DTO входа/выхода, опции транзакций | +| `routes` | 45 | Средняя — Request/Response, currentUser, обёртки | +| `services` | 48 | Средняя — бизнес-DTO, ошибки | +| `constants` | 13 | Низкая | +| прочее (auth, middlewares, config, ai, helpers) | ~10 | Низкая–средняя | + +Основной риск и трудозатраты сосредоточены в `db/` (модели + api). Рекомендуется выделить +типизацию моделей в отдельный поток работ и при необходимости распараллелить по доменам +(люди/учёба/посещаемость/финансы/контент). + +--- + +## 6. Риски и меры + +- **Переход миграций на Umzug** — главный риск. Меры: использовать `SequelizeStorage` с той же таблицей `SequelizeMeta`, чтобы история уже применённых миграций сохранилась; обязательно прогнать раннер и на чистой БД, и на БД с существующей историей; новые миграции — `.ts`, старые — оставить `.cjs` без переписывания. (Базовый механизм подтверждён spike: Umzug 3.8.3 + tsx выполняют `.ts`-миграции.) +- **Разрастание `any` под давлением сроков** — запрещено `CLAUDE.md`. Мера: `typecheck` как блокирующий гейт; временно сложные места изолировать узкими типами, а не `any`. +- **Расхождение dev (`tsx`) и прод (`dist`)** — алиасы/расширения резолвятся по-разному. Мера: всегда проверять и `npm run dev`, и `node dist/index.js` в CI. +- **Связи Sequelize теряют типы** при `define()`-подходе. Мера: общий тип реестра `Db` + строго типизированный `associate`. +- **swagger пути** ломаются после сборки. Мера: env-зависимый `apis`-glob, проверка `/api-docs` в смоуке. +- **Большой diff** усложняет ревью. Мера: PR на каждый связный блок (по разделам Фазы A), а не одним коммитом. + +## 7. Стратегия отката + +- Фаза A полностью обратима: TS компилируется в тот же CommonJS; при проблеме можно остановиться на любом блоке — смешанная кодовая база рабочая. +- Фаза B — отдельная ветка/серия PR; точка возврата — последний зелёный коммит Фазы A. +- Содержимое уже применённых миграций не меняется; Umzug читает ту же `SequelizeMeta`, поэтому история БД не затрагивается. Откат миграционного слоя = вернуть прежний `db/reset.js`/CLI-вызовы. + +## 8. Порядок исполнения (сводка) + +Фаза 0 (инструменты) → Фаза A (TS, снизу вверх: constants → config → helpers/utils → models → +db/api → services → auth/middlewares → ai → routes → index) → Фаза B (ESM: tsconfig NodeNext, +`type:module`, синтаксис import/export, import.meta.url, загрузчик моделей, Umzug-миграции, +swagger, tsx) → Фаза C (скрипты/Docker/Node 24) → Фаза D (тесты/верификация/доки). + +> Оценка объёма намеренно не дана в часах: основной труд — типизация `db/` (~61 файл) и `routes` +> (45 файлов). После готовности каркаса (Фаза 0 + загрузчик моделей) остальное — предсказуемая +> механическая работа поблочно. + +--- + +## 9. Открытые вопросы + +Все вопросы по состоянию на 2026-06-09 закрыты — см. раздел 0 «Принятые решения». +Остаётся один пункт к проверке в коде по ходу работ: подтвердить замену `sequelize-json-schema` +при типизации `routes/openai.ts` (раздел 10.3). + +--- + +## 10. Аудит зависимостей: обновление и замена + +Версии проверены по npm-реестру на 2026-06-09 (не из памяти модели). Источник истины — +реестр; несколько пакетов в `package.json` (например, `lodash`, `nodemailer`, `cors`, `eslint`) +уже соответствуют последним стабильным релизам в текущем реестре, менять их не нужно. + +### 10.1. Удалить (не используются в коде) + +| Пакет | Версия | Причина | +|---|---|---| +| `mysql2` | 3.22.5 | Диалект БД везде `postgres`, 0 использований | +| `tedious` | 19.2.1 | MSSQL-драйвер, 0 использований | +| `sqlite` | 5.1.1 | 0 использований | +| `lodash` | 4.18.1 | Единственный импорт `lodash/get` в `services/notifications/helpers.js` — заменяется на optional chaining (`?.`) | + +Эффект: меньше поверхности атаки, легче и быстрее установка/сборка. Оставляем только драйвер `pg` + `pg-hstore`. + +### 10.2. Заменить — deprecated / устаревшие + +| Текущий | Статус | Рекомендуемая замена | Масштаб | +|---|---|---|---| +| `json2csv` 5.0.7 | **deprecated** («Package no longer supported») | `@json2csv/plainjs` 7.0.6 (официальный преемник, scoped-пакеты) | 26 файлов | +| `moment` 2.30.1 | Maintenance mode, проект сам рекомендует альтернативы | **`dayjs` 1.11.21** (решено: минимальный diff, близкий API) | 26 файлов | +| `body-parser` (в `index.js`) | Избыточен в Express 5 | Встроенный `express.json()` | 1 файл | +| `formidable` 3.5.4 | Дублирует `multer` | Консолидировать на `multer` 2.1.1 (популярнее, активнее), переписать `services/file.js` | 1 файл | +| `sequelize-cli` 6.6.5 (devDep) | Не поддерживает `.ts`-миграции | **Umzug 3.8.3 + tsx** (раздел 11) | весь миграционный флоу | + +Выбор `dayjs` зафиксирован (решение 0.1). + +### 10.3. Заменить — опционально (низкая поддержка) + +| Текущий | Последняя публикация | Вариант | Комментарий | +|---|---|---|---| +| `passport-google-oauth2` 0.2.0 | 2022 | `passport-google-oauth20` 2.0.0 или `openid-client` 6.8.4 | Низкая активность; `passport-google-oauth20` — почти drop-in, `openid-client` — стратегически современнее, но больше работы | +| `passport-microsoft` 2.1.0 | 2024 | оставить либо `openid-client` 6.8.4 | Поддержка приемлема; единый `openid-client` под оба провайдера — отдельная задача | +| `sequelize-json-schema` 2.1.1 | 2022 | `zod-to-json-schema` 3.25.2 или ручная схема | Используется в 1 файле (`routes/openai.js`); пересмотреть при типизации этого роута | + +**Решено (0.7):** OAuth-замена выносится в **отдельную задачу** после основной миграции (внесена +в `docs/full-integration-refactor-plan.md`), чтобы не смешивать риски (изменение потока +аутентификации ≠ смена языка/модулей). + +### 10.4. Обновить версии (минор/патч, без замены) + +`@google-cloud/storage` 7.19 → 7.21. Остальные ключевые рантайм-пакеты уже на актуальных стабильных: +`express` 5.2.1, `helmet` 8.2.0, `axios` 1.17.0, `bcrypt` 6.0.0, `jsonwebtoken` 9.0.3, +`pg` 8.21.0, `pg-hstore` 2.3.4, `cors` 2.8.6, `passport` 0.7.0, `passport-jwt` 4.0.1, +`csv-parser` 3.2.1, `swagger-jsdoc` 6.3.0, `swagger-ui-express` 5.0.1, `chokidar` 5.0.0, +`nodemailer` 8.0.10, `multer` 2.1.1. + +Перед общим обновлением выполнить `npm outdated` и `npm audit`, фиксировать точные версии в lock-файле. + +### 10.5. Sequelize — остаёмся на 6 (важно) + +`sequelize` стабильный — **6.37.8**; версия 7 существует только как **alpha** (`7.0.0-alpha.9`). +Пользователь просил **стабильные** версии, поэтому переход на Sequelize 7 (TS-native) сейчас +**не делаем**. ORM как таковой не меняем: переписывание моделей под другой ORM (Drizzle/Prisma) +противоречит принципу «минимальные изменения» и выходит за рамки миграции на TS/ESM. Это +возможная отдельная инициатива в будущем, но не часть данного плана. + +### 10.6. Новые зависимости для TS/ESM (актуальные версии) + +- Рантайм: `umzug` 3.8.3 (миграции, раздел 11). +- Dev: `typescript` 6.0.3, `tsx` 4.22.4, `tsc-alias` 1.8.17, `vitest` 4.1.8, `typescript-eslint` 8.61.0, `@types/node` 25.9.2, `@types/express` 5.0.6, `@types/multer` 2.1.0, и типы для остальных библиотек без собственных деклараций (см. Фаза 0, п. 1). +- **Не добавлять:** `@types/lodash` (`lodash` удаляется); `@types/passport-google-oauth20` (OAuth-замена отложена). + +Целевая среда исполнения — **Node 24 (Active LTS)**: удовлетворяет самым строгим `engines.node` +зависимостей (`vitest` и `eslint` требуют `>=24`). + +### 10.7. Порядок применения + +1. Сначала **удаления** (10.1) — самый безопасный шаг, не меняет поведение. +2. Затем **замены** (10.2) — модуль за модулем, вместе с типизацией соответствующих файлов в Фазе A. +3. **Опциональные** замены (10.3) — после стабилизации основной миграции, отдельными задачами. +4. **Минорные апдейты** (10.4) — единым PR с `npm audit` перед финальной верификацией (Фаза D). + +--- + +## 11. Лучшие практики: TS-миграции на Umzug + +Umzug — библиотека-движок, на которой построен и сам sequelize-cli, поэтому переход совместим +по хранилищу истории. Подход (подтверждён spike: Umzug 3.8.3 + tsx исполняют `.ts`-миграции): + +1. **Единое хранилище истории.** Использовать `SequelizeStorage` с той же таблицей `SequelizeMeta`, что вёл sequelize-cli. Уже применённые миграции остаются зарегистрированными и повторно не запускаются — история БД не теряется. +2. **Сосуществование старого и нового.** Glob включает и `.cjs` (15 существующих миграций — не переписываем), и `.ts` (все новые). `tsx` грузит оба формата. +3. **Типизированные миграции.** Каждая новая миграция — `.ts` с типизированным контекстом `QueryInterface`: + + ```ts + // src/db/migrations/2026XXXX-create-foo.ts + import type { QueryInterface } from 'sequelize'; + import { DataTypes } from 'sequelize'; + + export async function up({ context: qi }: { context: QueryInterface }) { + await qi.createTable('foo', { + id: { type: DataTypes.UUID, primaryKey: true, defaultValue: DataTypes.UUIDV4 }, + }); + } + export async function down({ context: qi }: { context: QueryInterface }) { + await qi.dropTable('foo'); + } + ``` + +4. **Раннер с CLI.** Один файл `src/db/migrate.ts` создаёт `Umzug` и пробрасывает CLI через `umzug.runAsCLI()` (даёт команды `up`/`down`/`pending`/`executed`): + + ```ts + // src/db/migrate.ts + import { Umzug, SequelizeStorage } from 'umzug'; + import { sequelize } from '@/db/models'; // существующий инстанс Sequelize + + export const migrator = new Umzug({ + migrations: { glob: 'src/db/migrations/*.{cjs,ts}' }, + context: sequelize.getQueryInterface(), + storage: new SequelizeStorage({ sequelize }), // таблица SequelizeMeta + logger: console, + }); + + if (import.meta.url === `file://${process.argv[1]}`) { + migrator.runAsCLI(); + } + ``` + +5. **Скрипты:** `db:migrate` = `tsx src/db/migrate.ts up`, `db:rollback` = `... down`, `db:status` = `... pending`. В Dockerfile/прод миграции запускать перед стартом (`tsx` ставить в рантайм-зависимости либо запускать из собранного `dist`). +6. **Сидеры.** Второй экземпляр Umzug со своей meta-таблицей (`SequelizeMeta_seeders`) и аналогичным раннером `src/db/seed.ts`; существующие 5 сидеров перенести как `.cjs` (не переписывать) или по необходимости — на `.ts`. +7. **Замена `watcher.js`.** Авто-миграции в dev — через `predev`-шаг (`tsx src/db/migrate.ts up`), а не chokidar-вотчер; перезапуск сервера — `tsx watch`. +8. **Проверка перед мерджем.** Прогнать раннер дважды: на чистой БД (все миграции применяются по порядку) и на БД с уже заполненной `SequelizeMeta` (применяются только новые). diff --git a/backend/docs/user-progress.md b/backend/docs/user-progress.md new file mode 100644 index 0000000..a246f9e --- /dev/null +++ b/backend/docs/user-progress.md @@ -0,0 +1,34 @@ +# User Progress Backend + +## Purpose + +`user_progress` stores current-user progress for narrow staff workflows such as learned sign language items and zone check-ins. The backend owns tenant scope, user ownership, and persistence. + +## API + +All routes require JWT authentication. + +- `GET /api/user_progress?progress_type=`: returns current user's progress rows for the requested type. +- `GET /api/user_progress?progress_type=&item_id=`: returns current user's progress for one item. +- `POST /api/user_progress`: creates or updates one progress item and returns the saved DTO. +- `DELETE /api/user_progress/by-item?progress_type=&item_id=`: deletes current user's progress for one item. + +## Supported Initial Types + +- `sign_learned` +- `zone_checkin` + +## Data Contract + +Required mutation fields: + +- `progress_type` +- `item_id` + +Optional mutation fields: + +- `value` +- `score` +- `metadata` + +The backend assigns `organizationId`, `campusId`, and `userId` from the authenticated user. Frontend-provided user names or roles are not trusted for ownership. diff --git a/backend/docs/walkthrough-checkins.md b/backend/docs/walkthrough-checkins.md new file mode 100644 index 0000000..b7f9945 --- /dev/null +++ b/backend/docs/walkthrough-checkins.md @@ -0,0 +1,45 @@ +# Walk-Through Check-Ins Backend + +## Purpose + +`walkthrough_checkins` stores structured classroom observation records for director-level users. The backend owns tenant scope, campus scope, creator ownership, validation, and role-gated access. + +## API + +All routes require JWT authentication. + +- `GET /api/walkthrough_checkins`: returns check-ins visible to the current manager. +- `GET /api/walkthrough_checkins?teacher_name=`: filters visible check-ins by teacher name. +- `POST /api/walkthrough_checkins`: creates one check-in for the current user's organization and campus. +- `DELETE /api/walkthrough_checkins/:id`: deletes one visible check-in. + +## Access Rules + +- Director/superintendent-capable generated roles can create, list, and delete check-ins. +- Records are scoped to the current user's organization. +- Campus-scoped users write records to their current campus. +- The frontend does not send organization, campus, creator, or updater fields. + +## Data Contract + +Required mutation fields: + +- `teacher_name` +- `classroom` +- `director_name` +- `check_in_date` +- `check_in_time` +- `attitude_rating` +- `classroom_management_rating` +- `cleanliness_rating` +- `vibes_rating` +- `team_dynamics_rating` +- `emergency_exit_rating` +- `lesson_plan_rating` + +Optional mutation fields: + +- category comments +- `overall_notes` + +The backend returns normalized DTO rows with tenant and audit fields. diff --git a/backend/eslint.config.js b/backend/eslint.config.js new file mode 100644 index 0000000..c166cdb --- /dev/null +++ b/backend/eslint.config.js @@ -0,0 +1,33 @@ +const eslint = require('@eslint/js'); +const importPlugin = require('eslint-plugin-import-x'); + +module.exports = [ + { + ignores: ['node_modules/**', 'tmp/**', 'logs/**'], + }, + eslint.configs.recommended, + { + files: ['**/*.js'], + plugins: { + 'import-x': importPlugin, + }, + languageOptions: { + ecmaVersion: 'latest', + sourceType: 'commonjs', + globals: { + Buffer: 'readonly', + __dirname: 'readonly', + console: 'readonly', + module: 'readonly', + process: 'readonly', + require: 'readonly', + setInterval: 'readonly', + setTimeout: 'readonly', + URL: 'readonly', + }, + }, + rules: { + 'import-x/no-unresolved': 'error', + }, + }, +]; diff --git a/backend/package-lock.json b/backend/package-lock.json new file mode 100644 index 0000000..ade2991 --- /dev/null +++ b/backend/package-lock.json @@ -0,0 +1,6124 @@ +{ + "name": "schoolchainmanager", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "schoolchainmanager", + "dependencies": { + "@google-cloud/storage": "^7.19.0", + "axios": "^1.17.0", + "bcrypt": "6.0.0", + "chokidar": "^5.0.0", + "cors": "2.8.6", + "csv-parser": "^3.2.1", + "express": "5.2.1", + "formidable": "3.5.4", + "helmet": "8.2.0", + "json2csv": "^5.0.7", + "jsonwebtoken": "9.0.3", + "lodash": "4.18.1", + "moment": "2.30.1", + "multer": "^2.1.1", + "mysql2": "3.22.5", + "nodemailer": "8.0.10", + "passport": "^0.7.0", + "passport-google-oauth2": "^0.2.0", + "passport-jwt": "^4.0.1", + "passport-microsoft": "^2.1.0", + "pg": "8.21.0", + "pg-hstore": "2.3.4", + "sequelize": "6.37.8", + "sequelize-json-schema": "^2.1.1", + "sqlite": "5.1.1", + "swagger-jsdoc": "^6.3.0", + "swagger-ui-express": "^5.0.1", + "tedious": "^19.2.1" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "cross-env": "10.1.0", + "eslint": "^10.4.1", + "eslint-plugin-import-x": "^4.16.2", + "nodemon": "3.1.14", + "sequelize-cli": "6.6.5" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@apidevtools/json-schema-ref-parser": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-14.0.1.tgz", + "integrity": "sha512-Oc96zvmxx1fqoSEdUmfmvvb59/KDOnUoJ7s2t7bISyAn0XEz57LCCw8k2Y4Pf3mwKaZLMciESALORLgfe2frCw==", + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.15", + "js-yaml": "^4.1.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/philsturgeon" + } + }, + "node_modules/@apidevtools/openapi-schemas": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@apidevtools/openapi-schemas/-/openapi-schemas-2.1.0.tgz", + "integrity": "sha512-Zc1AlqrJlX3SlpupFGpiLi2EbteyP7fXmUOGup6/DnkRgjP9bgMM/ag+n91rsv0U1Gpz0H3VILA/o3bW7Ua6BQ==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/@apidevtools/swagger-methods": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@apidevtools/swagger-methods/-/swagger-methods-3.0.2.tgz", + "integrity": "sha512-QAkD5kK2b1WfjDS/UQn/qQkbwF31uqRjPTrsCs5ZG9BQGAkjwvqGFjjPqAuzac/IYzpPtRzjCP1WrTuAIjMrXg==", + "license": "MIT" + }, + "node_modules/@apidevtools/swagger-parser": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/@apidevtools/swagger-parser/-/swagger-parser-12.1.0.tgz", + "integrity": "sha512-e5mJoswsnAX0jG+J09xHFYQXb/bUc5S3pLpMxUuRUA2H8T2kni3yEoyz2R3Dltw5f4A6j6rPNMpWTK+iVDFlng==", + "license": "MIT", + "dependencies": { + "@apidevtools/json-schema-ref-parser": "14.0.1", + "@apidevtools/openapi-schemas": "^2.1.0", + "@apidevtools/swagger-methods": "^3.0.2", + "ajv": "^8.17.1", + "ajv-draft-04": "^1.0.0", + "call-me-maybe": "^1.0.2" + }, + "peerDependencies": { + "openapi-types": ">=7" + } + }, + "node_modules/@azure/abort-controller": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.1.2.tgz", + "integrity": "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@azure/core-auth": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@azure/core-auth/-/core-auth-1.10.1.tgz", + "integrity": "sha512-ykRMW8PjVAn+RS6ww5cmK9U2CyH9p4Q88YJwvUslfuMmN98w/2rdGRLPqJYObapBCdzBVeDgYWdJnFPFb7qzpg==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-util": "^1.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-client": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@azure/core-client/-/core-client-1.9.2.tgz", + "integrity": "sha512-kRdry/rav3fUKHl/aDLd/pDLcB+4pOFwPPTVEExuMyaI5r+JBbMWqRbCY1pn5BniDaU3lRxO9eaQ1AmSMehl/w==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.0.0", + "@azure/core-auth": "^1.4.0", + "@azure/core-rest-pipeline": "^1.9.1", + "@azure/core-tracing": "^1.0.0", + "@azure/core-util": "^1.6.1", + "@azure/logger": "^1.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@azure/core-http-compat": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@azure/core-http-compat/-/core-http-compat-2.1.2.tgz", + "integrity": "sha512-5MnV1yqzZwgNLLjlizsU3QqOeQChkIXw781Fwh1xdAqJR5AA32IUaq6xv1BICJvfbHoa+JYcaij2HFkhLbNTJQ==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.0.0", + "@azure/core-client": "^1.3.0", + "@azure/core-rest-pipeline": "^1.3.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@azure/core-lro": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/@azure/core-lro/-/core-lro-2.7.2.tgz", + "integrity": "sha512-0YIpccoX8m/k00O7mDDMdJpbr6mf1yWo2dfmxt5A8XVZVVMz2SSKaEbMCeJRvgQ0IaSlqhjT47p4hVIRRy90xw==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.0.0", + "@azure/core-util": "^1.2.0", + "@azure/logger": "^1.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@azure/core-paging": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/@azure/core-paging/-/core-paging-1.6.2.tgz", + "integrity": "sha512-YKWi9YuCU04B55h25cnOYZHxXYtEvQEbKST5vqRga7hWY9ydd3FZHdeQF8pyh+acWZvppw13M/LMGx0LABUVMA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@azure/core-rest-pipeline": { + "version": "1.24.0", + "resolved": "https://registry.npmjs.org/@azure/core-rest-pipeline/-/core-rest-pipeline-1.24.0.tgz", + "integrity": "sha512-PpLsoDQ3AMmKZ0VU+0GrmqMxgp/sExjlVm4R+nLWngeoEGAzOIPVifaxKGU5gMv+nWELUoHfvrolWD+ZS/nFJg==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.10.0", + "@azure/core-tracing": "^1.3.0", + "@azure/core-util": "^1.13.0", + "@azure/logger": "^1.3.0", + "@typespec/ts-http-runtime": "^0.3.4", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-tracing": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@azure/core-tracing/-/core-tracing-1.3.1.tgz", + "integrity": "sha512-9MWKevR7Hz8kNzzPLfX4EAtGM2b8mr50HPDBvio96bURP/9C+HjdH3sBlLSNNrvRAr5/k/svoH457gB5IKpmwQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-util": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/@azure/core-util/-/core-util-1.13.1.tgz", + "integrity": "sha512-XPArKLzsvl0Hf0CaGyKHUyVgF7oDnhKoP85Xv6M4StF/1AhfORhZudHtOyf2s+FcbuQ9dPRAjB8J2KvRRMUK2A==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@typespec/ts-http-runtime": "^0.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/identity": { + "version": "4.13.1", + "resolved": "https://registry.npmjs.org/@azure/identity/-/identity-4.13.1.tgz", + "integrity": "sha512-5C/2WD5Vb1lHnZS16dNQRPMjN6oV/Upba+C9nBIs15PmOi6A3ZGs4Lr2u60zw4S04gi+u3cEXiqTVP7M4Pz3kw==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.0.0", + "@azure/core-auth": "^1.9.0", + "@azure/core-client": "^1.9.2", + "@azure/core-rest-pipeline": "^1.17.0", + "@azure/core-tracing": "^1.0.0", + "@azure/core-util": "^1.11.0", + "@azure/logger": "^1.0.0", + "@azure/msal-browser": "^5.5.0", + "@azure/msal-node": "^5.1.0", + "open": "^10.1.0", + "tslib": "^2.2.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/keyvault-keys": { + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/@azure/keyvault-keys/-/keyvault-keys-4.8.0.tgz", + "integrity": "sha512-jkuYxgkw0aaRfk40OQhFqDIupqblIOIlYESWB6DKCVDxQet1pyv86Tfk9M+5uFM0+mCs6+MUHU+Hxh3joiUn4Q==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^1.0.0", + "@azure/core-auth": "^1.3.0", + "@azure/core-client": "^1.5.0", + "@azure/core-http-compat": "^2.0.1", + "@azure/core-lro": "^2.2.0", + "@azure/core-paging": "^1.1.1", + "@azure/core-rest-pipeline": "^1.8.1", + "@azure/core-tracing": "^1.0.0", + "@azure/core-util": "^1.0.0", + "@azure/logger": "^1.0.0", + "tslib": "^2.2.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@azure/keyvault-keys/node_modules/@azure/abort-controller": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-1.1.0.tgz", + "integrity": "sha512-TrRLIoSQVzfAJX9H1JeFjzAoDGcoK1IYX1UImfceTZpsyYfWr09Ss1aHW1y5TrrR3iq6RZLBwJ3E24uwPhwahw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.2.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/@azure/logger": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@azure/logger/-/logger-1.3.0.tgz", + "integrity": "sha512-fCqPIfOcLE+CGqGPd66c8bZpwAji98tZ4JI9i/mlTNTlsIWslCfpg48s/ypyLxZTump5sypjrKn2/kY7q8oAbA==", + "license": "MIT", + "dependencies": { + "@typespec/ts-http-runtime": "^0.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/msal-browser": { + "version": "5.12.0", + "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-5.12.0.tgz", + "integrity": "sha512-eNf2aqx1C6I0yT1GEu5ukblFrmaBXGfe1bivpmlfqvK7giPZvoXLa404C8EfeHVsy6EIryfQuPRzuW1fPxWlHg==", + "license": "MIT", + "dependencies": { + "@azure/msal-common": "16.7.0" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-common": { + "version": "16.7.0", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-16.7.0.tgz", + "integrity": "sha512-Jb8Y7pX6KM42SIT7KWP6YbY3+vLbwB5b5m+tpiiOzMU1QeyelQzs9lO8jv1e7/Uj9r7tg7VjPvW4T0KB1jF3UQ==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-node": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-5.2.3.tgz", + "integrity": "sha512-YYX4TchEVddVBiybKvKhV9QO/q22jgewP+BVxKG7Uh115voPcviGlypbKERDsqQdAiSTJrwi80gcWFjYKdo8+Q==", + "license": "MIT", + "dependencies": { + "@azure/msal-common": "16.7.0", + "jsonwebtoken": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@epic-web/invariant": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz", + "integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", + "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@google-cloud/paginator": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@google-cloud/paginator/-/paginator-5.0.2.tgz", + "integrity": "sha512-DJS3s0OVH4zFDB1PzjxAsHqJT6sKVbRwwML0ZBP9PbU7Yebtu/7SWMRzvO2J3nUi9pRNITCfu4LJeooM2w4pjg==", + "license": "Apache-2.0", + "dependencies": { + "arrify": "^2.0.0", + "extend": "^3.0.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@google-cloud/projectify": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@google-cloud/projectify/-/projectify-4.0.0.tgz", + "integrity": "sha512-MmaX6HeSvyPbWGwFq7mXdo0uQZLGBYCwziiLIGq5JVX+/bdI3SAq6bP98trV5eTWfLuvsMcIC1YJOF2vfteLFA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@google-cloud/promisify": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@google-cloud/promisify/-/promisify-2.0.4.tgz", + "integrity": "sha512-j8yRSSqswWi1QqUGKVEKOG03Q7qOoZP6/h2zN2YO+F5h2+DHU0bSrHCK9Y7lo2DI9fBd8qGAw795sf+3Jva4yA==", + "license": "Apache-2.0", + "engines": { + "node": ">=10" + } + }, + "node_modules/@google-cloud/storage": { + "version": "7.19.0", + "resolved": "https://registry.npmjs.org/@google-cloud/storage/-/storage-7.19.0.tgz", + "integrity": "sha512-n2FjE7NAOYyshogdc7KQOl/VZb4sneqPjWouSyia9CMDdMhRX5+RIbqalNmC7LOLzuLAN89VlF2HvG8na9G+zQ==", + "license": "Apache-2.0", + "dependencies": { + "@google-cloud/paginator": "^5.0.0", + "@google-cloud/projectify": "^4.0.0", + "@google-cloud/promisify": "<4.1.0", + "abort-controller": "^3.0.0", + "async-retry": "^1.3.3", + "duplexify": "^4.1.3", + "fast-xml-parser": "^5.3.4", + "gaxios": "^6.0.2", + "google-auth-library": "^9.6.3", + "html-entities": "^2.5.2", + "mime": "^3.0.0", + "p-limit": "^3.0.1", + "retry-request": "^7.0.0", + "teeny-request": "^9.0.0", + "uuid": "^8.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@google-cloud/storage/node_modules/mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@js-joda/core": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/@js-joda/core/-/core-5.7.0.tgz", + "integrity": "sha512-WBu4ULVVxySLLzK1Ppq+OdfP+adRS4ntmDQT915rzDJ++i95gc2jZkM5B6LWEAwN3lGXpfie3yPABozdD3K3Vg==", + "license": "BSD-3-Clause" + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@nodable/entities": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.1.tgz", + "integrity": "sha512-Pig3HxDIoMgjdEH8OCf/dkcTmLFjJRjWuq8jSnklu284/TKOPibSRERmOykiwmyXTtv61mP+44f3GMx0tLAyjg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" + }, + "node_modules/@one-ini/wasm": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.1.1.tgz", + "integrity": "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@package-json/types": { + "version": "0.0.12", + "resolved": "https://registry.npmjs.org/@package-json/types/-/types-0.0.12.tgz", + "integrity": "sha512-uu43FGU34B5VM9mCNjXCwLaGHYjXdNincqKLaraaCW+7S2+SmiBg1Nv8bPnmschrIfZmfKNY9f3fC376MRrObw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", + "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@tootallnate/once": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.1.tgz", + "integrity": "sha512-HqmEUIGRJ5fSXchkVgR5F7qn48bDBzv0kWj/Kfu5e6uci4UlEeng4331LnBkWffb++Ei3FOVLxo8JJWMFBDMeQ==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/caseless": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/@types/caseless/-/caseless-0.12.5.tgz", + "integrity": "sha512-hWtVTC2q7hc7xZ/RLbxapMvDMgUnDvKvMOpKal4DrMyfGBUfB1oKaZlIRr6mJL+If3bAP6sV/QneGzF6tJjZDg==", + "license": "MIT" + }, + "node_modules/@types/debug": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", + "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "license": "MIT" + }, + "node_modules/@types/ms": { + "version": "0.7.34", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.34.tgz", + "integrity": "sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.14.11", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.11.tgz", + "integrity": "sha512-kprQpL8MMeszbz6ojB5/tU8PLN4kesnN8Gjzw349rDlNgsSzg90lAVj3llK99Dh7JON+t9AuscPPFW6mPbTnSA==", + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@types/readable-stream": { + "version": "4.0.23", + "resolved": "https://registry.npmjs.org/@types/readable-stream/-/readable-stream-4.0.23.tgz", + "integrity": "sha512-wwXrtQvbMHxCbBgjHaMGEmImFTQxxpfMOR/ZoQnXxB1woqkUbdLGFDgauo00Py9IudiaqSeiBiulSV9i6XIPig==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/request": { + "version": "2.48.13", + "resolved": "https://registry.npmjs.org/@types/request/-/request-2.48.13.tgz", + "integrity": "sha512-FGJ6udDNUCjd19pp0Q3iTiDkwhYup7J8hpMW9c4k53NrccQFFWKRho6hvtPPEhnXWKvukfwAlB6DbDz4yhH5Gg==", + "license": "MIT", + "dependencies": { + "@types/caseless": "*", + "@types/node": "*", + "@types/tough-cookie": "*", + "form-data": "^2.5.5" + } + }, + "node_modules/@types/request/node_modules/form-data": { + "version": "2.5.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.5.tgz", + "integrity": "sha512-jqdObeR2rxZZbPSGL+3VckHMYtu+f9//KXBsVny6JSX/pa38Fy+bGjuG8eW/H6USNQWhLi8Num++cU2yOCNz4A==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.35", + "safe-buffer": "^5.2.1" + }, + "engines": { + "node": ">= 0.12" + } + }, + "node_modules/@types/tough-cookie": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", + "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", + "license": "MIT" + }, + "node_modules/@types/validator": { + "version": "13.12.0", + "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.12.0.tgz", + "integrity": "sha512-nH45Lk7oPIJ1RVOF6JgFI6Dy0QpHEzq4QecZhvguxYPDwT8c93prCMqAtiIttm39voZ+DDR+qkNnMpJmMBRqag==", + "license": "MIT" + }, + "node_modules/@typescript-eslint/types": { + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.60.1.tgz", + "integrity": "sha512-4h0tY8ppCkdCzcrl2YM5M3my0xsE1Tf8om3owEu5oPWmXwkKRmk0j0LGDzYBGUcAlesEbxBhazqu/K4cu3Ug7w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typespec/ts-http-runtime": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.6.tgz", + "integrity": "sha512-jIXhD0eWQ1JA6ln/5Dltyx22UxWNrw0hZmhy2rlv6m6KgF7kplHx3g0fzi09lNmTJQRR91OlemYp3xFnvDK9og==", + "license": "MIT", + "dependencies": { + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@typespec/ts-http-runtime/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/@typespec/ts-http-runtime/node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@typespec/ts-http-runtime/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.12.2.tgz", + "integrity": "sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.12.2.tgz", + "integrity": "sha512-YGCRZv/9GLhwmz6mYDeTsm/92BAyR28l6c2ReweVW5pWgfsitWLY8upvfRlGdoyD8HjeTHSYJWyZGD4KJA/nFQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.12.2.tgz", + "integrity": "sha512-u9DiNT1auQMO20A9SyTuG3wUgQWB9Z7KjAg0uFuCDR1FsAY8A0CG2S6JpHS1xwm/w1G08bjXZDcyOCjv1WAm2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.12.2.tgz", + "integrity": "sha512-f7rPLi/T1HVKZu/u6t87lroib16n8vrSzcyxI7lg4BGO9UF26KhQL44sd9eOUgrTYhvRXtWOIZT5PejdPyJfUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.12.2.tgz", + "integrity": "sha512-BpcOjWCJub6nRZUS2zA20pmLvjtqAtGejETaIyRLiZiQf++cbrjltLA5NN/xaXfqeOBOSlMFbemIl5/S5tljmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.12.2.tgz", + "integrity": "sha512-vZTDvdSISZjJx66OzJqtsOhzifbqRjbmI1Mnu49fQDwog5GtDI4QidRiEAYbZCRj9C8YZEW+3ZjqsyS9GR4k2A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.12.2.tgz", + "integrity": "sha512-BiPI+IrIlwcW4nLLMM21+B1dFPzd55yAVgVGrdgDjNef+ch03GdxrcyaIz8X9SsQirh/kCQ7mviyWlMxdh2D7g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.12.2.tgz", + "integrity": "sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.12.2.tgz", + "integrity": "sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-loong64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-gnu/-/resolver-binding-linux-loong64-gnu-1.12.2.tgz", + "integrity": "sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-loong64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-musl/-/resolver-binding-linux-loong64-musl-1.12.2.tgz", + "integrity": "sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.12.2.tgz", + "integrity": "sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.12.2.tgz", + "integrity": "sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.12.2.tgz", + "integrity": "sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.12.2.tgz", + "integrity": "sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.12.2.tgz", + "integrity": "sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.12.2.tgz", + "integrity": "sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-openharmony-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-openharmony-arm64/-/resolver-binding-openharmony-arm64-1.12.2.tgz", + "integrity": "sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.12.2.tgz", + "integrity": "sha512-tYFDIkMxSflfEc/h92ZWNsZlHSwgimbNHSO3PL2JWQHfCuC2q316jMyYU9TIWZsFK2bQwyK5VAdYgn8ygPj69A==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.12.2.tgz", + "integrity": "sha512-qzNyg3xL0VPQmCaUh+N5jSitce6k+uCBfMDesWRnlULOZaqUkaJ0ybdT+UqlAWJoQjuqfIU/0Ptx9bteN4D82g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.12.2.tgz", + "integrity": "sha512-WD9sY00OfpHVGfsnHZoA8jVT+esS/Bg8z8jzxp5BnDCjjwsuKsPQrzswwpFy4J1AUJbXPRfkpcX0mXrzeXW79g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.12.2.tgz", + "integrity": "sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/abbrev": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz", + "integrity": "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-draft-04": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ajv-draft-04/-/ajv-draft-04-1.0.0.tgz", + "integrity": "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==", + "license": "MIT", + "peerDependencies": { + "ajv": "^8.5.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/arrify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/arrify/-/arrify-2.0.1.tgz", + "integrity": "sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "license": "MIT" + }, + "node_modules/async-retry": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/async-retry/-/async-retry-1.3.3.tgz", + "integrity": "sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==", + "license": "MIT", + "dependencies": { + "retry": "0.13.1" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/at-least-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/aws-ssl-profiles": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", + "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/axios": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.17.0.tgz", + "integrity": "sha512-J8SwNxprqqpbfenehxWYXE7CW+wM1BB4w3+N+g+/Wx40xM4rsLrfPmHHxSWIxJLYDgSY/HqlFPIYb2/S3rxafw==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/base64url": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/base64url/-/base64url-3.0.1.tgz", + "integrity": "sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bcrypt": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz", + "integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.3.0", + "node-gyp-build": "^4.8.4" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/bl": { + "version": "6.1.6", + "resolved": "https://registry.npmjs.org/bl/-/bl-6.1.6.tgz", + "integrity": "sha512-jLsPgN/YSvPUg9UX0Kd73CXpm2Psg9FxMeCSXnk3WBO3CMT10JMwijubhGfHCnFu6TPn1ei3b975dxv7K2pWVg==", + "license": "MIT", + "dependencies": { + "@types/readable-stream": "^4.0.0", + "buffer": "^6.0.3", + "inherits": "^2.0.4", + "readable-stream": "^4.2.0" + } + }, + "node_modules/bl/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/body-parser/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/body-parser/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/bundle-name": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", + "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", + "license": "MIT", + "dependencies": { + "run-applescript": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-me-maybe": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.2.tgz", + "integrity": "sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==", + "license": "MIT" + }, + "node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/cliui": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", + "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.0.tgz", + "integrity": "sha512-zP4jEKbe8SHzKJYQmq8Y9gYjtO/POJLgIdKgV7B9qNmABVFVc+ctqSX6iXh4mCpJfRBOabiZ2YKPg8ciDw6C+Q==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/comment-parser": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/comment-parser/-/comment-parser-1.4.7.tgz", + "integrity": "sha512-0h+uSNtQGW3D98eQt3jJ8L06Fves8hncB4V/PKdw/Qb8Hnk19VaKuTr55UNRYiSoVa7WwrFls+rh3ux9agmkeQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/config-chain": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", + "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ini": "^1.3.4", + "proto-list": "~1.2.1" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-env": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz", + "integrity": "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@epic-web/invariant": "^1.0.0", + "cross-spawn": "^7.0.6" + }, + "bin": { + "cross-env": "dist/bin/cross-env.js", + "cross-env-shell": "dist/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csv-parser": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/csv-parser/-/csv-parser-3.2.1.tgz", + "integrity": "sha512-v8RPMSglouR9od735SnwSxLBbCJqEPSbgm1R5qfr8yIiMUCEFjox56kRZid0SvgHJEkxeIEu3+a9QS3YRh7CuA==", + "license": "MIT", + "bin": { + "csv-parser": "bin/csv-parser" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/default-browser": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", + "integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==", + "license": "MIT", + "dependencies": { + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser-id": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", + "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-lazy-prop": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/dottie": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/dottie/-/dottie-2.0.7.tgz", + "integrity": "sha512-7lAK2A0b3zZr3UC5aE69CPdCFR4RHW1o2Dr74TqFykxkUCBXSRJum/yPc7g8zRHJqWKomPLHwFLLoUnn8PXXRg==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "license": "MIT" + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/duplexify": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.3.tgz", + "integrity": "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.4.1", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1", + "stream-shift": "^1.0.2" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/editorconfig": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.7.tgz", + "integrity": "sha512-e0GOtq/aTQhVdNyDU9e02+wz9oDDM+SIOQxWME2QRjzRX5yyLAuHDE+0aE8vHb9XRC8XD37eO2u57+F09JqFhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@one-ini/wasm": "0.1.1", + "commander": "^10.0.0", + "minimatch": "^9.0.1", + "semver": "^7.5.3" + }, + "bin": { + "editorconfig": "bin/editorconfig" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/editorconfig/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/editorconfig/node_modules/brace-expansion": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/editorconfig/node_modules/commander": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", + "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/editorconfig/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.2.tgz", + "integrity": "sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.4.1.tgz", + "integrity": "sha512-AyIKhnOBuOAdueD7RB3xB+YeAWScb9jHsJBgH2Hcde8InP5JYhqrRR6iTMHyTEwgENK54Cp44e4v8BwNhsuHuw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.6.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.2", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-import-context": { + "version": "0.1.9", + "resolved": "https://registry.npmjs.org/eslint-import-context/-/eslint-import-context-0.1.9.tgz", + "integrity": "sha512-K9Hb+yRaGAGUbwjhFNHvSmmkZs9+zbuoe3kFQ4V1wYjrepUFYM2dZAfNtjbbj3qsPfUfsA68Bx/ICWQMi+C8Eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-tsconfig": "^4.10.1", + "stable-hash-x": "^0.2.0" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-import-context" + }, + "peerDependencies": { + "unrs-resolver": "^1.0.0" + }, + "peerDependenciesMeta": { + "unrs-resolver": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-import-x": { + "version": "4.16.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-import-x/-/eslint-plugin-import-x-4.16.2.tgz", + "integrity": "sha512-rM9K8UBHcWKpzQzStn1YRN2T5NvdeIfSVoKu/lKF41znQXHAUcBbYXe5wd6GNjZjTrP7viQ49n1D83x/2gYgIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@package-json/types": "^0.0.12", + "@typescript-eslint/types": "^8.56.0", + "comment-parser": "^1.4.1", + "debug": "^4.4.1", + "eslint-import-context": "^0.1.9", + "is-glob": "^4.0.3", + "minimatch": "^9.0.3 || ^10.1.2", + "semver": "^7.7.2", + "stable-hash-x": "^0.2.0", + "unrs-resolver": "^1.9.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-plugin-import-x" + }, + "peerDependencies": { + "@typescript-eslint/utils": "^8.56.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "eslint-import-resolver-node": "*" + }, + "peerDependenciesMeta": { + "@typescript-eslint/utils": { + "optional": true + }, + "eslint-import-resolver-node": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express/node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/express/node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/express/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express/node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fast-xml-builder": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz", + "integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.5.0", + "xml-naming": "^0.1.0" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.8.0.tgz", + "integrity": "sha512-6bIM7fsJxeo3uXv7OncQYsBAMPJ7V16Slahl/6M98C/i2q+vB1+4a0MtrvYwDFEUrwDSbAmeLDRXsOBwrL7yAg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "@nodable/entities": "^2.1.0", + "fast-xml-builder": "^1.2.0", + "path-expression-matcher": "^1.5.0", + "strnum": "^2.3.0", + "xml-naming": "^0.1.0" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/formidable": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gaxios": { + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.7.1.tgz", + "integrity": "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "is-stream": "^2.0.0", + "node-fetch": "^2.6.9", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/gaxios/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/gaxios/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/gcp-metadata": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz", + "integrity": "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^6.1.1", + "google-logging-utils": "^0.0.2", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/generate-function": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", + "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", + "license": "MIT", + "dependencies": { + "is-property": "^1.0.2" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-tsconfig": { + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", + "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/google-auth-library": { + "version": "9.15.1", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.15.1.tgz", + "integrity": "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^6.1.1", + "gcp-metadata": "^6.1.0", + "gtoken": "^7.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/google-logging-utils": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-0.0.2.tgz", + "integrity": "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/gtoken": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-7.1.0.tgz", + "integrity": "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==", + "license": "MIT", + "dependencies": { + "gaxios": "^6.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/helmet": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.2.0.tgz", + "integrity": "sha512-DRgTIUgnWcJ62KyarxxziuqYxKGnR6Rgg19BlbucN/dpmJbl1XOit6qvoOX0ZT+HhWe5OUVhU/a1zpGyc1xA0Q==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/EvanHahn" + } + }, + "node_modules/html-entities": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz", + "integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/mdevils" + }, + { + "type": "patreon", + "url": "https://patreon.com/mdevils" + } + ], + "license": "MIT" + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/http-proxy-agent": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", + "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", + "license": "MIT", + "dependencies": { + "@tootallnate/once": "2", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/ignore-by-default": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", + "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", + "dev": true, + "license": "ISC" + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflection": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/inflection/-/inflection-1.13.4.tgz", + "integrity": "sha512-6I/HUDeYFfuNCVS3td055BaXBwKYuzw7K3ExVMStBowKo9oOAMJIXIHvdyR3iboTCp1b+1i5DSkIZTcwIktuDw==", + "engines": [ + "node >= 0.4.0" + ], + "license": "MIT" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/is-property": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", + "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", + "license": "MIT" + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-wsl": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", + "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/js-beautify": { + "version": "1.15.4", + "resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.15.4.tgz", + "integrity": "sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "config-chain": "^1.1.13", + "editorconfig": "^1.0.4", + "glob": "^10.4.2", + "js-cookie": "^3.0.5", + "nopt": "^7.2.1" + }, + "bin": { + "css-beautify": "js/bin/css-beautify.js", + "html-beautify": "js/bin/html-beautify.js", + "js-beautify": "js/bin/js-beautify.js" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/js-cookie": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.8.tgz", + "integrity": "sha512-yeJd4aNAdYZQjaon2bpD/Gb0B/omw7HQOsynXXcOiWVCacbBcPlgn8S/d1X6blFSaHao7ozqtW7NZW19xpCtIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-md4": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/js-md4/-/js-md4-0.3.2.tgz", + "integrity": "sha512-/GDnfQYsltsjRswQhN9fhv3EMw2sCpUdrdxyWDOUK7eyD++r3gRhzgiQgc/x4MAv2i1iuQ4lxO5mvqM3vj4bwA==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json2csv": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/json2csv/-/json2csv-5.0.7.tgz", + "integrity": "sha512-YRZbUnyaJZLZUJSRi2G/MqahCyRv9n/ds+4oIetjDF3jWQA7AG7iSeKTiZiCNqtMZM7HDyt0e/W6lEnoGEmMGA==", + "license": "MIT", + "dependencies": { + "commander": "^6.1.0", + "jsonparse": "^1.3.1", + "lodash.get": "^4.4.2" + }, + "bin": { + "json2csv": "bin/json2csv.js" + }, + "engines": { + "node": ">= 10", + "npm": ">= 6.13.0" + } + }, + "node_modules/json2csv/node_modules/commander": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.1.tgz", + "integrity": "sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/jsonfile": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", + "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsonparse": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz", + "integrity": "sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==", + "engines": [ + "node >= 0.2.0" + ], + "license": "MIT" + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", + "license": "MIT" + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.mergewith": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz", + "integrity": "sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/lru.min": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz", + "integrity": "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==", + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=1.30.0", + "node": ">=8.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wellwelwel" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/moment": { + "version": "2.30.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", + "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/moment-timezone": { + "version": "0.5.45", + "resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.45.tgz", + "integrity": "sha512-HIWmqA86KcmCAhnMAN0wuDOARV/525R2+lOLotuGFzn4HO+FH+/645z2wx0Dt3iDv6/p61SIvKnDstISainhLQ==", + "license": "MIT", + "dependencies": { + "moment": "^2.29.4" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/multer": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz", + "integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "type-is": "^1.6.18" + }, + "engines": { + "node": ">= 10.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/mysql2": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.22.5.tgz", + "integrity": "sha512-95uZ2TrPWAZdwpB3vvvDbmEMcNG8yIeNCyu6GUcr/QnWEE/wXm7+mhOCsdQfWQDTV7qYT/PDUZ4U4UPP4AsXqQ==", + "license": "MIT", + "dependencies": { + "aws-ssl-profiles": "^1.1.2", + "denque": "^2.1.0", + "generate-function": "^2.3.1", + "iconv-lite": "^0.7.2", + "long": "^5.3.2", + "lru.min": "^1.1.4", + "named-placeholders": "^1.1.6", + "sql-escaper": "^1.3.3" + }, + "engines": { + "node": ">= 8.0" + }, + "peerDependencies": { + "@types/node": ">= 8" + } + }, + "node_modules/named-placeholders": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz", + "integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==", + "license": "MIT", + "dependencies": { + "lru.min": "^1.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/napi-postinstall": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", + "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", + "dev": true, + "license": "MIT", + "bin": { + "napi-postinstall": "lib/cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/napi-postinstall" + } + }, + "node_modules/native-duplexpair": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/native-duplexpair/-/native-duplexpair-1.0.0.tgz", + "integrity": "sha512-E7QQoM+3jvNtlmyfqRZ0/U75VFgCls+fSkbml2MpgWkWyz3ox8Y58gNhfuziuQYGNNQAbFZJQck55LHCnCK6CA==", + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-addon-api": { + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.8.0.tgz", + "integrity": "sha512-c5Ko1fZJIJmzhFIkhRN76WTq+fC6tWnGy9CXA0fA+XygsWZmEwG8vmbkNqxMyoaa0Tin4djul49NzdVcJJcjeA==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, + "node_modules/nodemailer": { + "version": "8.0.10", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.10.tgz", + "integrity": "sha512-BLFuSth7QtHOkBzyqTehWWyub0NTRDuK2Q2SQfnGLsrJnzyU+Yeh4WpV1eZGuARFj1xQJHIdnTuJZLP+b9R1GQ==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/nodemon": { + "version": "3.1.14", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz", + "integrity": "sha512-jakjZi93UtB3jHMWsXL68FXSAosbLfY0In5gtKq3niLSkrWznrVBzXFNOEMJUfc9+Ke7SHWoAZsiMkNP3vq6Jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^4", + "ignore-by-default": "^1.0.1", + "minimatch": "^10.2.1", + "pstree.remy": "^1.1.8", + "semver": "^7.5.3", + "simple-update-notifier": "^2.0.0", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/nodemon/node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/nodemon/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/nodemon/node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/nopt": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-7.2.1.tgz", + "integrity": "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^2.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/oauth": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.10.0.tgz", + "integrity": "sha512-1orQ9MT1vHFGQxhuy7E/0gECD3fd2fCC+PIX+/jgmU/gI3EpRocXtmtvxCO5x3WZ443FLTLFWNDjl5MPJf9u+Q==", + "license": "MIT" + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/open": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", + "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==", + "license": "MIT", + "dependencies": { + "default-browser": "^5.2.1", + "define-lazy-prop": "^3.0.0", + "is-inside-container": "^1.0.0", + "wsl-utils": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/openapi-types": { + "version": "12.1.3", + "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz", + "integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==", + "license": "MIT", + "peer": true + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "license": "BlueOak-1.0.0" + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/passport": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz", + "integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==", + "license": "MIT", + "dependencies": { + "passport-strategy": "1.x.x", + "pause": "0.0.1", + "utils-merge": "^1.0.1" + }, + "engines": { + "node": ">= 0.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jaredhanson" + } + }, + "node_modules/passport-google-oauth2": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/passport-google-oauth2/-/passport-google-oauth2-0.2.0.tgz", + "integrity": "sha512-62EdPtbfVdc55nIXi0p1WOa/fFMM8v/M8uQGnbcXA4OexZWCnfsEi3wo2buag+Is5oqpuHzOtI64JpHk0Xi5RQ==", + "license": "MIT", + "dependencies": { + "passport-oauth2": "^1.1.2" + } + }, + "node_modules/passport-jwt": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/passport-jwt/-/passport-jwt-4.0.1.tgz", + "integrity": "sha512-UCKMDYhNuGOBE9/9Ycuoyh7vP6jpeTp/+sfMJl7nLff/t6dps+iaeE0hhNkKN8/HZHcJ7lCdOyDxHdDoxoSvdQ==", + "license": "MIT", + "dependencies": { + "jsonwebtoken": "^9.0.0", + "passport-strategy": "^1.0.0" + } + }, + "node_modules/passport-microsoft": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/passport-microsoft/-/passport-microsoft-2.1.0.tgz", + "integrity": "sha512-7bOcjEmZCHg5qD55iHaMD/mgBxPtXLbqAwmKox5IsqOSEU50WJk5nQKK4lxKdBHLZ0hf+gzrFgDsTybJP18/JA==", + "dependencies": { + "passport-oauth2": "1.8.0" + }, + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/passport-oauth2": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.8.0.tgz", + "integrity": "sha512-cjsQbOrXIDE4P8nNb3FQRCCmJJ/utnFKEz2NX209f7KOHPoX18gF7gBzBbLLsj2/je4KrgiwLLGjf0lm9rtTBA==", + "license": "MIT", + "dependencies": { + "base64url": "3.x.x", + "oauth": "0.10.x", + "passport-strategy": "1.x.x", + "uid2": "0.0.x", + "utils-merge": "1.x.x" + }, + "engines": { + "node": ">= 0.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jaredhanson" + } + }, + "node_modules/passport-strategy": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", + "integrity": "sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-expression-matcher": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", + "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pause": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", + "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==" + }, + "node_modules/pg": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.21.0.tgz", + "integrity": "sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.13.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.14.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.13.0.tgz", + "integrity": "sha512-EMnU9E2fSULdsbErBbMaXJvFeD9B4+nPcM3f+4lsiCR0BHLPrLVjv3DbyM2hgQQviKJaTWIRRTjKjWlHg3p2ig==", + "license": "MIT" + }, + "node_modules/pg-hstore": { + "version": "2.3.4", + "resolved": "https://registry.npmjs.org/pg-hstore/-/pg-hstore-2.3.4.tgz", + "integrity": "sha512-N3SGs/Rf+xA1M2/n0JBiXFDVMzdekwLZLAO0g7mpDY9ouX+fDI7jS6kTq3JujmYbtNSJ53TJ0q4G98KVZSM4EA==", + "license": "MIT", + "dependencies": { + "underscore": "^1.13.1" + }, + "engines": { + "node": ">= 0.8.x" + } + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.14.0.tgz", + "integrity": "sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz", + "integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/proto-list": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", + "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==", + "dev": true, + "license": "ISC" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/pstree.remy": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", + "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", + "dev": true, + "license": "MIT" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdirp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.8", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", + "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/retry-as-promised": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/retry-as-promised/-/retry-as-promised-7.0.4.tgz", + "integrity": "sha512-XgmCoxKWkDofwH8WddD0w85ZfqYz+ZHlr5yo+3YUCfycWawU56T5ckWXsScsj5B8tqUcIG67DxXByo3VUgiAdA==", + "license": "MIT" + }, + "node_modules/retry-request": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/retry-request/-/retry-request-7.0.2.tgz", + "integrity": "sha512-dUOvLMJ0/JJYEn8NrpOaGNE7X3vpI5XlZS/u0ANjqtcZVKnIxP7IgCFwrKTxENw29emmwug53awKtaMm4i9g5w==", + "license": "MIT", + "dependencies": { + "@types/request": "^2.48.8", + "extend": "^3.0.2", + "teeny-request": "^9.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/run-applescript": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", + "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.3.tgz", + "integrity": "sha512-wnilbGyMxzbY7dNOl7jpKbLSjcfeweJWU5j4+u5qW+6/wuGD9KzIGOyZnQVSBM9E7DtWaaH3CyHkppYrKYoxwg==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/send/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/send/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/sequelize": { + "version": "6.37.8", + "resolved": "https://registry.npmjs.org/sequelize/-/sequelize-6.37.8.tgz", + "integrity": "sha512-HJ0IQFqcTsTiqbEgiuioYFMSD00TP6Cz7zoTti+zVVBwVe9fEhev9cH6WnM3XU31+ABS356durAb99ZuOthnKw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/sequelize" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.1.8", + "@types/validator": "^13.7.17", + "debug": "^4.3.4", + "dottie": "^2.0.6", + "inflection": "^1.13.4", + "lodash": "^4.17.21", + "moment": "^2.29.4", + "moment-timezone": "^0.5.43", + "pg-connection-string": "^2.6.1", + "retry-as-promised": "^7.0.4", + "semver": "^7.5.4", + "sequelize-pool": "^7.1.0", + "toposort-class": "^1.0.1", + "uuid": "^8.3.2", + "validator": "^13.9.0", + "wkx": "^0.5.0" + }, + "engines": { + "node": ">=10.0.0" + }, + "peerDependenciesMeta": { + "ibm_db": { + "optional": true + }, + "mariadb": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "oracledb": { + "optional": true + }, + "pg": { + "optional": true + }, + "pg-hstore": { + "optional": true + }, + "snowflake-sdk": { + "optional": true + }, + "sqlite3": { + "optional": true + }, + "tedious": { + "optional": true + } + } + }, + "node_modules/sequelize-cli": { + "version": "6.6.5", + "resolved": "https://registry.npmjs.org/sequelize-cli/-/sequelize-cli-6.6.5.tgz", + "integrity": "sha512-DqyISCULOaEbTM+rRQH4YvcUWeOC1XDiSKcjsC6TfAnT7W837mNkChJhtB/Z4FdCFHRCojmiP7zsrA4pARmacA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fs-extra": "^9.1.0", + "js-beautify": "1.15.4", + "lodash": "^4.17.21", + "picocolors": "^1.1.1", + "resolve": "^1.22.1", + "umzug": "^2.3.0", + "yargs": "^16.2.0" + }, + "bin": { + "sequelize": "lib/sequelize", + "sequelize-cli": "lib/sequelize" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/sequelize-json-schema": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/sequelize-json-schema/-/sequelize-json-schema-2.1.1.tgz", + "integrity": "sha512-yCGaHnmQQeL6MQ/fOxhkR5C2aOGZyTD6OrgjP4yw1rbuujuIUVdzWN3AsC6r6AvlGZ3EUBBbCJHKl8OIFFES4Q==", + "license": "MIT", + "engines": { + "node": ">= 8" + }, + "peerDependencies": { + "sequelize": ">= 4" + } + }, + "node_modules/sequelize-pool": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/sequelize-pool/-/sequelize-pool-7.1.0.tgz", + "integrity": "sha512-G9c0qlIWQSK29pR/5U2JF5dDQeqqHRragoyahj/Nx4KOOQ3CPPfzxnfqFPCSB7x5UgjOgnZ61nSxz+fjDpRlJg==", + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "license": "BSD-3-Clause" + }, + "node_modules/sql-escaper": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.3.3.tgz", + "integrity": "sha512-BsTCV265VpTp8tm1wyIm1xqQCS+Q9NHx2Sr+WcnUrgLrQ6yiDIvHYJV5gHxsj1lMBy2zm5twLaZao8Jd+S8JJw==", + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=2.0.0", + "node": ">=12.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/mysqljs/sql-escaper?sponsor=1" + } + }, + "node_modules/sqlite": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/sqlite/-/sqlite-5.1.1.tgz", + "integrity": "sha512-oBkezXa2hnkfuJwUo44Hl9hS3er+YFtueifoajrgidvqsJRQFpc5fKoAkAor1O5ZnLoa28GBScfHXs8j0K358Q==", + "license": "MIT" + }, + "node_modules/stable-hash-x": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/stable-hash-x/-/stable-hash-x-0.2.0.tgz", + "integrity": "sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/stream-events": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/stream-events/-/stream-events-1.0.5.tgz", + "integrity": "sha512-E1GUzBSgvct8Jsb3v2X15pjzN1tYebtbLaMg+eBOUOAxgbLoSbT2NS91ckc5lJD1KfLjId+jXJRgo0qnV5Nerg==", + "license": "MIT", + "dependencies": { + "stubs": "^3.0.0" + } + }, + "node_modules/stream-shift": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz", + "integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==", + "license": "MIT" + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strnum": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.3.0.tgz", + "integrity": "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/stubs": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/stubs/-/stubs-3.0.0.tgz", + "integrity": "sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw==", + "license": "MIT" + }, + "node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/supports-color/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/swagger-jsdoc": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/swagger-jsdoc/-/swagger-jsdoc-6.3.0.tgz", + "integrity": "sha512-I+iQjVGV3t28pOkQUJv2MncthvOtkEactOn8R76SvSYhxgtIn7FoqfDHwQaN+GBnQdXQLrhgDXseKitmJcHMsA==", + "license": "MIT", + "dependencies": { + "@apidevtools/swagger-parser": "^12.1.0", + "commander": "6.2.0", + "doctrine": "3.0.0", + "glob": "11.1.0", + "lodash.mergewith": "^4.6.2", + "yaml": "2.0.0-1" + }, + "bin": { + "swagger-jsdoc": "bin/swagger-jsdoc.js" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/swagger-jsdoc/node_modules/@isaacs/cliui": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz", + "integrity": "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/swagger-jsdoc/node_modules/glob": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz", + "integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "foreground-child": "^3.3.1", + "jackspeak": "^4.1.1", + "minimatch": "^10.1.1", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^2.0.0" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/swagger-jsdoc/node_modules/jackspeak": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.2.3.tgz", + "integrity": "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^9.0.0" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/swagger-jsdoc/node_modules/lru-cache": { + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/swagger-jsdoc/node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/swagger-ui-dist": { + "version": "5.17.14", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.17.14.tgz", + "integrity": "sha512-CVbSfaLpstV65OnSjbXfVd6Sta3q3F7Cj/yYuvHMp1P90LztOLs6PfUnKEVAeiIVQt9u2SaPwv0LiH/OyMjHRw==", + "license": "Apache-2.0" + }, + "node_modules/swagger-ui-express": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/swagger-ui-express/-/swagger-ui-express-5.0.1.tgz", + "integrity": "sha512-SrNU3RiBGTLLmFU8GIJdOdanJTl4TOmT27tt3bWWHppqYmAZ6IDuEuBvMU6nZq0zLEe6b/1rACXCgLZqO6ZfrA==", + "license": "MIT", + "dependencies": { + "swagger-ui-dist": ">=5.0.0" + }, + "engines": { + "node": ">= v0.10.32" + }, + "peerDependencies": { + "express": ">=4.0.0 || >=5.0.0-beta" + } + }, + "node_modules/tedious": { + "version": "19.2.1", + "resolved": "https://registry.npmjs.org/tedious/-/tedious-19.2.1.tgz", + "integrity": "sha512-pk1Q16Yl62iocuQB+RWbg6rFUFkIyzqOFQ6NfysCltRvQqKwfurgj8v/f2X+CKvDhSL4IJ0cCOfCHDg9PWEEYA==", + "license": "MIT", + "dependencies": { + "@azure/core-auth": "^1.7.2", + "@azure/identity": "^4.2.1", + "@azure/keyvault-keys": "^4.4.0", + "@js-joda/core": "^5.6.5", + "@types/node": ">=18", + "bl": "^6.1.4", + "iconv-lite": "^0.7.0", + "js-md4": "^0.3.2", + "native-duplexpair": "^1.0.0", + "sprintf-js": "^1.1.3" + }, + "engines": { + "node": ">=18.17" + } + }, + "node_modules/teeny-request": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/teeny-request/-/teeny-request-9.0.0.tgz", + "integrity": "sha512-resvxdc6Mgb7YEThw6G6bExlXKkv6+YbuzGg9xuXxSgxJF7Ozs+o8Y9+2R3sArdWdW8nOokoQb1yrpFB0pQK2g==", + "license": "Apache-2.0", + "dependencies": { + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.0", + "node-fetch": "^2.6.9", + "stream-events": "^1.0.5", + "uuid": "^9.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/toposort-class": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toposort-class/-/toposort-class-1.0.1.tgz", + "integrity": "sha512-OsLcGGbYF3rMjPUf8oKktyvCiUxSbqMMS39m33MAjLTC1DVIH6x3WSt63/M77ihI09+Sdfk1AXvfhCEeUmC7mg==", + "license": "MIT" + }, + "node_modules/touch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", + "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", + "dev": true, + "license": "ISC", + "bin": { + "nodetouch": "bin/nodetouch.js" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", + "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==", + "license": "0BSD" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, + "node_modules/uid2": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/uid2/-/uid2-0.0.4.tgz", + "integrity": "sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA==", + "license": "MIT" + }, + "node_modules/umzug": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/umzug/-/umzug-2.3.0.tgz", + "integrity": "sha512-Z274K+e8goZK8QJxmbRPhl89HPO1K+ORFtm6rySPhFKfKc5GHhqdzD0SGhSWHkzoXasqJuItdhorSvY7/Cgflw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bluebird": "^3.7.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/undefsafe": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", + "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", + "dev": true, + "license": "MIT" + }, + "node_modules/underscore": { + "version": "1.13.8", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz", + "integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==", + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "license": "MIT" + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/unrs-resolver": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.12.2.tgz", + "integrity": "sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "napi-postinstall": "^0.3.4" + }, + "funding": { + "url": "https://opencollective.com/unrs-resolver" + }, + "optionalDependencies": { + "@unrs/resolver-binding-android-arm-eabi": "1.12.2", + "@unrs/resolver-binding-android-arm64": "1.12.2", + "@unrs/resolver-binding-darwin-arm64": "1.12.2", + "@unrs/resolver-binding-darwin-x64": "1.12.2", + "@unrs/resolver-binding-freebsd-x64": "1.12.2", + "@unrs/resolver-binding-linux-arm-gnueabihf": "1.12.2", + "@unrs/resolver-binding-linux-arm-musleabihf": "1.12.2", + "@unrs/resolver-binding-linux-arm64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-arm64-musl": "1.12.2", + "@unrs/resolver-binding-linux-loong64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-loong64-musl": "1.12.2", + "@unrs/resolver-binding-linux-ppc64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-riscv64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-riscv64-musl": "1.12.2", + "@unrs/resolver-binding-linux-s390x-gnu": "1.12.2", + "@unrs/resolver-binding-linux-x64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-x64-musl": "1.12.2", + "@unrs/resolver-binding-openharmony-arm64": "1.12.2", + "@unrs/resolver-binding-wasm32-wasi": "1.12.2", + "@unrs/resolver-binding-win32-arm64-msvc": "1.12.2", + "@unrs/resolver-binding-win32-ia32-msvc": "1.12.2", + "@unrs/resolver-binding-win32-x64-msvc": "1.12.2" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz", + "integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, + "node_modules/validator": { + "version": "13.15.35", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.35.tgz", + "integrity": "sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wkx": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/wkx/-/wkx-0.5.0.tgz", + "integrity": "sha512-Xng/d4Ichh8uN4l0FToV/258EjMGU9MGcA0HV2d9B/ZpZB3lqQm7nkOdZdm5GhKtLLhAE7PiVQwN4eN+2YJJUg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/wsl-utils": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", + "integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==", + "license": "MIT", + "dependencies": { + "is-wsl": "^3.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/xml-naming": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", + "integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yaml": { + "version": "2.0.0-1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.0.0-1.tgz", + "integrity": "sha512-W7h5dEhywMKenDJh2iX/LABkbFnBxasD27oyXWDS/feDsxiw0dD5ncXdYXgkvAsXIY2MpW/ZKkr9IU30DBdMNQ==", + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, + "node_modules/yargs": { + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", + "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^7.0.2", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^20.2.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs/node_modules/yargs-parser": { + "version": "20.2.9", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", + "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/backend/package.json b/backend/package.json index e82fb8c..342c75f 100644 --- a/backend/package.json +++ b/backend/package.json @@ -3,7 +3,7 @@ "description": "School Chain Manager - template backend", "scripts": { "start": "npm run db:migrate && npm run db:seed && npm run watch", - "lint": "eslint . --ext .js", + "lint": "eslint .", "db:migrate": "sequelize-cli db:migrate", "db:seed": "sequelize-cli db:seed:all", "db:drop": "sequelize-cli db:drop", @@ -11,46 +11,48 @@ "watch": "node watcher.js" }, "dependencies": { - "@google-cloud/storage": "^5.18.2", - "axios": "^1.6.7", - "bcrypt": "5.1.1", - "chokidar": "^4.0.3", - "cors": "2.8.5", - "csv-parser": "^3.0.0", - "express": "4.18.2", - "formidable": "1.2.2", - "helmet": "4.1.1", + "@google-cloud/storage": "^7.19.0", + "axios": "^1.17.0", + "bcrypt": "6.0.0", + "chokidar": "^5.0.0", + "cors": "2.8.6", + "csv-parser": "^3.2.1", + "express": "5.2.1", + "formidable": "3.5.4", + "helmet": "8.2.0", "json2csv": "^5.0.7", - "jsonwebtoken": "8.5.1", - "lodash": "4.17.21", + "jsonwebtoken": "9.0.3", + "lodash": "4.18.1", "moment": "2.30.1", - "multer": "^1.4.4", - "mysql2": "2.2.5", - "nodemailer": "6.9.9", + "multer": "^2.1.1", + "mysql2": "3.22.5", + "nodemailer": "8.0.10", "passport": "^0.7.0", "passport-google-oauth2": "^0.2.0", "passport-jwt": "^4.0.1", - "passport-microsoft": "^0.1.0", - "pg": "8.4.1", + "passport-microsoft": "^2.1.0", + "pg": "8.21.0", "pg-hstore": "2.3.4", - "sequelize": "6.35.2", + "sequelize": "6.37.8", "sequelize-json-schema": "^2.1.1", - "sqlite": "4.0.15", - "swagger-jsdoc": "^6.2.8", - "swagger-ui-express": "^5.0.0", - "tedious": "^18.2.4" + "sqlite": "5.1.1", + "swagger-jsdoc": "^6.3.0", + "swagger-ui-express": "^5.0.1", + "tedious": "^19.2.1" }, "engines": { "node": ">=18" }, + "overrides": { + "uuid": "^11.1.1" + }, "private": true, "devDependencies": { - "cross-env": "7.0.3", - "eslint": "^8.23.1", - "eslint-plugin-import": "^2.29.1", - "mocha": "8.1.3", - "node-mocks-http": "1.9.0", - "nodemon": "2.0.5", - "sequelize-cli": "6.6.2" + "@eslint/js": "^10.0.1", + "cross-env": "10.1.0", + "eslint": "^10.4.1", + "eslint-plugin-import-x": "^4.16.2", + "nodemon": "3.1.14", + "sequelize-cli": "6.6.5" } } diff --git a/backend/src/auth/auth.js b/backend/src/auth/auth.js index 251c149..6c66b06 100644 --- a/backend/src/auth/auth.js +++ b/backend/src/auth/auth.js @@ -1,11 +1,10 @@ const config = require('../config'); const providers = config.providers; -const helpers = require('../helpers'); const db = require('../db/models'); +const { extractAccessCookie } = require('./cookies'); const passport = require('passport'); const JWTstrategy = require('passport-jwt').Strategy; -const ExtractJWT = require('passport-jwt').ExtractJwt; const GoogleStrategy = require('passport-google-oauth2').Strategy; const MicrosoftStrategy = require('passport-microsoft').Strategy; const UsersDBApi = require('../db/api/users'); @@ -14,7 +13,7 @@ const UsersDBApi = require('../db/api/users'); passport.use(new JWTstrategy({ passReqToCallback: true, secretOrKey: config.secret_key, - jwtFromRequest: ExtractJWT.fromAuthHeaderAsBearerToken() + jwtFromRequest: extractAccessCookie }, async (req, token, done) => { try { const user = await UsersDBApi.findBy( {email: token.user.email}); @@ -56,13 +55,7 @@ passport.use(new MicrosoftStrategy({ )); function socialStrategy(email, profile, provider, done) { - db.users.findOrCreate({where: {email, provider}}).then(([user, created]) => { - const body = { - id: user.id, - email: user.email, - name: profile.displayName, - }; - const token = helpers.jwtSign({user: body}); - return done(null, {token}); + db.users.findOrCreate({where: {email, provider}}).then(([user]) => { + return done(null, {user}); }); } diff --git a/backend/src/auth/cookies.js b/backend/src/auth/cookies.js new file mode 100644 index 0000000..42ba6ce --- /dev/null +++ b/backend/src/auth/cookies.js @@ -0,0 +1,130 @@ +const config = require('../config'); + +function encodeCookieValue(value) { + return encodeURIComponent(value); +} + +function decodeCookieValue(value) { + try { + return decodeURIComponent(value); + } catch { + return value; + } +} + +function getBaseCookieOptions(maxAge) { + const options = { + httpOnly: true, + maxAge, + path: config.auth.cookiePath, + sameSite: config.auth.cookieSameSite, + secure: config.auth.cookieSecure, + }; + + if (config.auth.cookieDomain) { + options.domain = config.auth.cookieDomain; + } + + return options; +} + +function getClearCookieOptions() { + const options = { + path: config.auth.cookiePath, + }; + + if (config.auth.cookieDomain) { + options.domain = config.auth.cookieDomain; + } + + return options; +} + +function getAccessCookieOptions() { + return getBaseCookieOptions(config.auth.accessTokenMaxAgeMs); +} + +function getRefreshCookieOptions() { + return getBaseCookieOptions(config.auth.refreshTokenMaxAgeMs); +} + +function setAccessCookie(res, token) { + res.cookie(config.auth.accessCookieName, token, getAccessCookieOptions()); +} + +function setRefreshCookie(res, token) { + res.cookie(config.auth.refreshCookieName, token, getRefreshCookieOptions()); +} + +function setSessionCookies(res, session) { + setAccessCookie(res, session.accessToken); + setRefreshCookie(res, session.refreshToken); +} + +function clearAccessCookie(res) { + res.clearCookie(config.auth.accessCookieName, getClearCookieOptions()); +} + +function clearRefreshCookie(res) { + res.clearCookie(config.auth.refreshCookieName, getClearCookieOptions()); +} + +function clearSessionCookies(res) { + clearAccessCookie(res); + clearRefreshCookie(res); +} + +function extractCookie(req, cookieName) { + const cookieHeader = req.headers.cookie; + + if (!cookieHeader) { + return null; + } + + const cookies = cookieHeader.split(';'); + + for (const cookie of cookies) { + const separatorIndex = cookie.indexOf('='); + + if (separatorIndex === -1) { + continue; + } + + const name = cookie.slice(0, separatorIndex).trim(); + + if (name !== cookieName) { + continue; + } + + const value = cookie.slice(separatorIndex + 1).trim(); + return decodeCookieValue(value); + } + + return null; +} + +function extractAccessCookie(req) { + return extractCookie(req, config.auth.accessCookieName); +} + +function extractRefreshCookie(req) { + return extractCookie(req, config.auth.refreshCookieName); +} + +function serializeAccessCookie(token) { + return `${config.auth.accessCookieName}=${encodeCookieValue(token)}`; +} + +module.exports = { + clearAccessCookie, + clearRefreshCookie, + clearSessionCookies, + extractAccessCookie, + extractRefreshCookie, + getAccessCookieOptions, + getRefreshCookieOptions, + serializeAccessCookie, + setAccessCookie, + setRefreshCookie, + setSessionCookies, +}; diff --git a/backend/src/config.js b/backend/src/config.js index df5b3a1..a817106 100644 --- a/backend/src/config.js +++ b/backend/src/config.js @@ -1,34 +1,161 @@ - - const os = require('os'); +require('./config/load-env'); + +const { + AUTH_COOKIE_NAME, + AUTH_COOKIE_PATH, + AUTH_REFRESH_COOKIE_NAME, + AUTH_COOKIE_SAME_SITE_VALUES, + AUTH_PROVIDERS, + BCRYPT_SALT_ROUNDS, + DEFAULT_AUTH_COOKIE_SAME_SITE, + JWT_EXPIRES_IN_MS, + REFRESH_TOKEN_BYTES, + REFRESH_TOKEN_EXPIRES_IN_MS, + REFRESH_TOKEN_HASH_ALGORITHM, +} = require('./constants/auth'); +const { + DEFAULT_DEV_API_PORT, + DEFAULT_DEV_UI_PORT, + DEFAULT_DEV_HOST, + DEFAULT_EMAIL_FROM, + DEFAULT_EMAIL_HOST, + DEFAULT_EMAIL_PORT, + DEFAULT_FLATLOGIC_HOST, + PRODUCTION_FLATLOGIC_HOST, + DEFAULT_PEXELS_QUERY, +} = require('./constants/app'); +const { GENERATED_ROLE_NAMES } = require('./constants/roles'); + +function requiredEnv(name) { + const value = process.env[name]; + + if (!value) { + throw new Error(`Missing required environment variable: ${name}`); + } + + return value; +} + +function readBooleanEnv(name, defaultValue) { + const value = process.env[name]; + + if (value === undefined || value === '') { + return defaultValue; + } + + if (value === 'true') { + return true; + } + + if (value === 'false') { + return false; + } + + throw new Error(`Invalid boolean environment variable: ${name}`); +} + +function readNumberEnv(name, defaultValue) { + const value = process.env[name]; + + if (value === undefined || value === '') { + return defaultValue; + } + + const parsedValue = Number(value); + + if (!Number.isFinite(parsedValue) || parsedValue <= 0) { + throw new Error(`Invalid positive number environment variable: ${name}`); + } + + return parsedValue; +} + +function readListEnv(name, defaultValue) { + const value = process.env[name]; + + if (!value) { + return defaultValue; + } + + return value + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean); +} + +function normalizeSameSite(value) { + const normalizedValue = value.toLowerCase(); + + if (!AUTH_COOKIE_SAME_SITE_VALUES.includes(normalizedValue)) { + throw new Error(`Invalid AUTH_COOKIE_SAME_SITE value: ${value}`); + } + + return normalizedValue; +} + +const isProductionLike = process.env.NODE_ENV === 'production' || process.env.NODE_ENV === 'dev_stage'; +const isProduction = process.env.NODE_ENV === 'production'; +const authCookieSameSite = normalizeSameSite(process.env.AUTH_COOKIE_SAME_SITE || DEFAULT_AUTH_COOKIE_SAME_SITE); +const authCookieSecure = readBooleanEnv('AUTH_COOKIE_SECURE', isProductionLike); +const authCookieMaxAgeMs = readNumberEnv('AUTH_COOKIE_MAX_AGE_MS', JWT_EXPIRES_IN_MS); +const defaultApiOrigin = `${DEFAULT_DEV_HOST}:${process.env.PORT || DEFAULT_DEV_API_PORT}`; +const defaultUiOrigin = `${process.env.UI_HOST || DEFAULT_DEV_HOST}${process.env.UI_PORT || DEFAULT_DEV_UI_PORT ? `:${process.env.UI_PORT || DEFAULT_DEV_UI_PORT}` : ''}`; +const defaultAllowedOrigins = [ + defaultUiOrigin, + defaultApiOrigin, +]; +const allowedOrigins = [...new Set(readListEnv('ALLOWED_ORIGINS', defaultAllowedOrigins))]; + +if (authCookieSameSite === 'none' && !authCookieSecure) { + throw new Error('AUTH_COOKIE_SECURE must be true when AUTH_COOKIE_SAME_SITE is none'); +} + +if (isProductionLike && !authCookieSecure) { + throw new Error('AUTH_COOKIE_SECURE must be true in production-like environments'); +} + +if (isProductionLike && !process.env.ALLOWED_ORIGINS) { + throw new Error('ALLOWED_ORIGINS must be configured in production-like environments'); +} const config = { gcloud: { - bucket: "fldemo-files", - hash: "afeefb9d49f5b7977577876b99532ac7" + bucket: process.env.GCLOUD_BUCKET || '', + hash: process.env.GCLOUD_HASH || '' }, bcrypt: { - saltRounds: 12 + saltRounds: BCRYPT_SALT_ROUNDS }, - admin_pass: "c7730661", - user_pass: "dfdc88c68843", - admin_email: "admin@flatlogic.com", - providers: { - LOCAL: 'local', - GOOGLE: 'google', - MICROSOFT: 'microsoft' + admin_pass: process.env.SEED_ADMIN_PASSWORD, + user_pass: process.env.SEED_USER_PASSWORD, + admin_email: process.env.SEED_ADMIN_EMAIL, + providers: AUTH_PROVIDERS, + auth: { + allowedOrigins, + accessCookieName: process.env.AUTH_ACCESS_COOKIE_NAME || AUTH_COOKIE_NAME, + accessTokenMaxAgeMs: authCookieMaxAgeMs, + cookieDomain: process.env.AUTH_COOKIE_DOMAIN || undefined, + cookiePath: AUTH_COOKIE_PATH, + cookieSameSite: authCookieSameSite, + cookieSecure: authCookieSecure, + refreshCookieName: process.env.AUTH_REFRESH_COOKIE_NAME || AUTH_REFRESH_COOKIE_NAME, + refreshTokenBytes: REFRESH_TOKEN_BYTES, + refreshTokenHashAlgorithm: REFRESH_TOKEN_HASH_ALGORITHM, + refreshTokenMaxAgeMs: readNumberEnv('AUTH_REFRESH_TOKEN_MAX_AGE_MS', REFRESH_TOKEN_EXPIRES_IN_MS), }, - secret_key: process.env.SECRET_KEY || 'c7730661-4acb-45d3-86c6-dfdc88c68843', + secret_key: requiredEnv('SECRET_KEY'), remote: '', - port: process.env.NODE_ENV === "production" ? "" : "8080", - hostUI: process.env.NODE_ENV === "production" ? "" : "http://localhost", - portUI: process.env.NODE_ENV === "production" ? "" : "3000", + port: isProduction ? "" : (process.env.PORT || DEFAULT_DEV_API_PORT), + serverPort: process.env.NODE_ENV === 'dev_stage' ? DEFAULT_DEV_UI_PORT : (process.env.PORT || DEFAULT_DEV_API_PORT), + hostUI: isProduction ? "" : (process.env.UI_HOST || DEFAULT_DEV_HOST), + portUI: isProduction ? "" : (process.env.UI_PORT || DEFAULT_DEV_UI_PORT), - portUIProd: process.env.NODE_ENV === "production" ? "" : ":3000", + portUIProd: isProduction ? "" : `:${process.env.UI_PORT || DEFAULT_DEV_UI_PORT}`, - swaggerUI: process.env.NODE_ENV === "production" ? "" : "http://localhost", - swaggerPort: process.env.NODE_ENV === "production" ? "" : ":8080", + swaggerUI: isProduction ? "" : (process.env.SWAGGER_HOST || DEFAULT_DEV_HOST), + swaggerPort: isProduction ? "" : `:${process.env.SWAGGER_PORT || DEFAULT_DEV_API_PORT}`, google: { clientId: process.env.GOOGLE_CLIENT_ID || '', clientSecret: process.env.GOOGLE_CLIENT_SECRET || '', @@ -39,9 +166,9 @@ const config = { }, uploadDir: os.tmpdir(), email: { - from: 'School Chain Manager ', - host: 'email-smtp.us-east-1.amazonaws.com', - port: 587, + from: process.env.EMAIL_FROM || DEFAULT_EMAIL_FROM, + host: process.env.EMAIL_HOST || DEFAULT_EMAIL_HOST, + port: Number(process.env.EMAIL_PORT || DEFAULT_EMAIL_PORT), auth: { user: process.env.EMAIL_USER || '', pass: process.env.EMAIL_PASS, @@ -52,18 +179,18 @@ const config = { }, roles: { - super_admin: 'Super Administrator', + super_admin: GENERATED_ROLE_NAMES.SUPER_ADMIN, - admin: 'Administrator', + admin: GENERATED_ROLE_NAMES.ADMIN, - user: 'Finance Officer', + user: GENERATED_ROLE_NAMES.FINANCE_OFFICER, }, - project_uuid: 'c7730661-4acb-45d3-86c6-dfdc88c68843', - flHost: process.env.NODE_ENV === 'production' || process.env.NODE_ENV === 'dev_stage' ? 'https://flatlogic.com/projects' : 'http://localhost:3000/projects', + project_uuid: process.env.FLATLOGIC_PROJECT_UUID || '', + flHost: isProductionLike ? PRODUCTION_FLATLOGIC_HOST : DEFAULT_FLATLOGIC_HOST, gpt_key: process.env.GPT_KEY || '', @@ -71,8 +198,8 @@ const config = { config.pexelsKey = process.env.PEXELS_KEY || ''; -config.pexelsQuery = 'Lighthouse guiding ships at dawn'; -config.host = process.env.NODE_ENV === "production" ? config.remote : "http://localhost"; +config.pexelsQuery = process.env.PEXELS_QUERY || DEFAULT_PEXELS_QUERY; +config.host = isProduction ? config.remote : DEFAULT_DEV_HOST; config.apiUrl = `${config.host}${config.port ? `:${config.port}` : ``}/api`; config.swaggerUrl = `${config.swaggerUI}${config.swaggerPort}`; config.uiUrl = `${config.hostUI}${config.portUI ? `:${config.portUI}` : ``}/#`; diff --git a/backend/src/config/load-env.js b/backend/src/config/load-env.js new file mode 100644 index 0000000..a02af68 --- /dev/null +++ b/backend/src/config/load-env.js @@ -0,0 +1,52 @@ +const fs = require('fs'); +const path = require('path'); + +const ENV_FILE = path.resolve(__dirname, '..', '..', '.env'); + +function parseEnvLine(line) { + const trimmed = line.trim(); + + if (!trimmed || trimmed.startsWith('#')) { + return null; + } + + const separatorIndex = trimmed.indexOf('='); + + if (separatorIndex === -1) { + return null; + } + + const key = trimmed.slice(0, separatorIndex).trim(); + let value = trimmed.slice(separatorIndex + 1).trim(); + + if (!key) { + return null; + } + + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + + return { key, value }; +} + +function loadEnvFile() { + if (!fs.existsSync(ENV_FILE)) { + return; + } + + const file = fs.readFileSync(ENV_FILE, 'utf8'); + + for (const line of file.split(/\r?\n/)) { + const entry = parseEnvLine(line); + + if (entry && process.env[entry.key] === undefined) { + process.env[entry.key] = entry.value; + } + } +} + +loadEnvFile(); diff --git a/backend/src/constants/app.js b/backend/src/constants/app.js new file mode 100644 index 0000000..f27aee2 --- /dev/null +++ b/backend/src/constants/app.js @@ -0,0 +1,55 @@ +const DEFAULT_DEV_API_PORT = '8080'; +const DEFAULT_DEV_UI_PORT = '3000'; +const DEFAULT_DEV_HOST = 'http://localhost'; +const DEFAULT_EMAIL_FROM = 'School Chain Manager '; +const DEFAULT_EMAIL_HOST = 'email-smtp.us-east-1.amazonaws.com'; +const DEFAULT_EMAIL_PORT = 587; +const DEFAULT_FLATLOGIC_HOST = 'http://localhost:3000/projects'; +const PRODUCTION_FLATLOGIC_HOST = 'https://flatlogic.com/projects'; +const DEFAULT_PEXELS_QUERY = 'Lighthouse guiding ships at dawn'; +const DEFAULT_PEXELS_PAGE = 1; +const DEFAULT_PEXELS_PER_PAGE = 1; +const PEXELS_IMAGE_ORIENTATION = 'portrait'; +const PEXELS_VIDEO_ORIENTATION = 'portrait'; +const PEXELS_MULTIPLE_IMAGE_ORIENTATION = 'square'; +const PEXELS_IMAGE_SEARCH_URL = 'https://api.pexels.com/v1/search'; +const PEXELS_VIDEO_SEARCH_URL = 'https://api.pexels.com/videos/search'; +const PICSUM_FALLBACK_URL = 'https://picsum.photos/600'; +const PICSUM_FALLBACK_PHOTOGRAPHER = 'Random Picsum'; +const UNKNOWN_PHOTOGRAPHER_LABEL = 'Unknown'; +const PEXELS_MULTIPLE_IMAGE_DEFAULT_QUERIES = Object.freeze(['home', 'apple', 'pizza', 'mountains', 'cat']); +const PEXELS_FALLBACK_IMAGE = Object.freeze({ + src: 'https://images.pexels.com/photos/8199252/pexels-photo-8199252.jpeg', + photographer: 'Yan Krukau', + photographer_url: 'https://www.pexels.com/@yankrukov', +}); +const DEFAULT_DEV_DB_HOST = 'localhost'; +const DEFAULT_DEV_DB_NAME = 'db_school_chain_manager'; +const DEFAULT_DEV_DB_USER = 'postgres'; + +module.exports = { + DEFAULT_DEV_API_PORT, + DEFAULT_DEV_UI_PORT, + DEFAULT_DEV_HOST, + DEFAULT_EMAIL_FROM, + DEFAULT_EMAIL_HOST, + DEFAULT_EMAIL_PORT, + DEFAULT_FLATLOGIC_HOST, + PRODUCTION_FLATLOGIC_HOST, + DEFAULT_PEXELS_QUERY, + DEFAULT_PEXELS_PAGE, + DEFAULT_PEXELS_PER_PAGE, + PEXELS_IMAGE_ORIENTATION, + PEXELS_VIDEO_ORIENTATION, + PEXELS_MULTIPLE_IMAGE_ORIENTATION, + PEXELS_IMAGE_SEARCH_URL, + PEXELS_VIDEO_SEARCH_URL, + PICSUM_FALLBACK_URL, + PICSUM_FALLBACK_PHOTOGRAPHER, + UNKNOWN_PHOTOGRAPHER_LABEL, + PEXELS_MULTIPLE_IMAGE_DEFAULT_QUERIES, + PEXELS_FALLBACK_IMAGE, + DEFAULT_DEV_DB_HOST, + DEFAULT_DEV_DB_NAME, + DEFAULT_DEV_DB_USER, +}; diff --git a/backend/src/constants/auth.js b/backend/src/constants/auth.js new file mode 100644 index 0000000..eb75589 --- /dev/null +++ b/backend/src/constants/auth.js @@ -0,0 +1,43 @@ +const AUTH_PROVIDERS = Object.freeze({ + LOCAL: 'local', + GOOGLE: 'google', + MICROSOFT: 'microsoft', +}); + +const BCRYPT_SALT_ROUNDS = 12; +const JWT_EXPIRES_IN = '15m'; +const JWT_EXPIRES_IN_MS = 15 * 60 * 1000; +const AUTH_COOKIE_NAME = 'school_chain_session'; +const AUTH_REFRESH_COOKIE_NAME = 'school_chain_refresh'; +const AUTH_COOKIE_PATH = '/'; +const REFRESH_TOKEN_EXPIRES_IN_MS = 14 * 24 * 60 * 60 * 1000; +const REFRESH_TOKEN_BYTES = 64; +const REFRESH_TOKEN_HASH_ALGORITHM = 'sha256'; +const AUTH_COOKIE_SAME_SITE_VALUES = Object.freeze([ + 'strict', + 'lax', + 'none', +]); +const DEFAULT_AUTH_COOKIE_SAME_SITE = 'lax'; +const UNSAFE_HTTP_METHODS = Object.freeze([ + 'POST', + 'PUT', + 'PATCH', + 'DELETE', +]); + +module.exports = { + AUTH_PROVIDERS, + AUTH_COOKIE_NAME, + AUTH_REFRESH_COOKIE_NAME, + AUTH_COOKIE_PATH, + AUTH_COOKIE_SAME_SITE_VALUES, + BCRYPT_SALT_ROUNDS, + DEFAULT_AUTH_COOKIE_SAME_SITE, + JWT_EXPIRES_IN, + JWT_EXPIRES_IN_MS, + REFRESH_TOKEN_BYTES, + REFRESH_TOKEN_EXPIRES_IN_MS, + REFRESH_TOKEN_HASH_ALGORITHM, + UNSAFE_HTTP_METHODS, +}; diff --git a/backend/src/constants/campus-attendance.js b/backend/src/constants/campus-attendance.js new file mode 100644 index 0000000..8817f91 --- /dev/null +++ b/backend/src/constants/campus-attendance.js @@ -0,0 +1,63 @@ +const { + GENERATED_ROLE_NAMES, + GENERATED_ROLE_TO_PRODUCT_ROLE, + PRODUCT_ROLE_VALUES, + STAFF_TYPE_TO_PRODUCT_ROLE, +} = require('./roles'); + +const CAMPUS_ATTENDANCE_TENANT_WIDE_ROLE_NAMES = Object.freeze([ + GENERATED_ROLE_NAMES.SUPER_ADMIN, + GENERATED_ROLE_NAMES.ADMIN, + GENERATED_ROLE_NAMES.PLATFORM_OWNER, +]); + +const CAMPUS_ATTENDANCE_MANAGER_ROLE_NAMES = Object.freeze([ + ...CAMPUS_ATTENDANCE_TENANT_WIDE_ROLE_NAMES, + GENERATED_ROLE_NAMES.TENANT_DIRECTOR, + GENERATED_ROLE_NAMES.CAMPUS_MANAGER, + GENERATED_ROLE_NAMES.FINANCE_OFFICER, +]); + +const CAMPUS_ATTENDANCE_MANAGE_PRODUCT_ROLES = Object.freeze([ + PRODUCT_ROLE_VALUES.OFFICE, + PRODUCT_ROLE_VALUES.DIRECTOR, + PRODUCT_ROLE_VALUES.SUPERINTENDENT, +]); + +const CAMPUS_ATTENDANCE_DEFAULT_LIMIT = 120; +const CAMPUS_ATTENDANCE_MAX_LIMIT = 366; + +function normalizeCampusKey(value) { + if (typeof value !== 'string' || value.trim().length === 0) { + return null; + } + + const normalized = value.trim().toLowerCase().replace(/\s+campus$/u, ''); + + return normalized || null; +} + +function getProductRole(currentUser) { + const roleName = currentUser?.app_role?.name; + const staffProfile = Array.isArray(currentUser?.staff_user) ? currentUser.staff_user[0] : null; + + if (roleName && GENERATED_ROLE_TO_PRODUCT_ROLE[roleName]) { + return GENERATED_ROLE_TO_PRODUCT_ROLE[roleName]; + } + + if (staffProfile?.staff_type && STAFF_TYPE_TO_PRODUCT_ROLE[staffProfile.staff_type]) { + return STAFF_TYPE_TO_PRODUCT_ROLE[staffProfile.staff_type]; + } + + return PRODUCT_ROLE_VALUES.TEACHER; +} + +module.exports = { + CAMPUS_ATTENDANCE_DEFAULT_LIMIT, + CAMPUS_ATTENDANCE_MANAGER_ROLE_NAMES, + CAMPUS_ATTENDANCE_MANAGE_PRODUCT_ROLES, + CAMPUS_ATTENDANCE_MAX_LIMIT, + CAMPUS_ATTENDANCE_TENANT_WIDE_ROLE_NAMES, + getProductRole, + normalizeCampusKey, +}; diff --git a/backend/src/constants/campuses.js b/backend/src/constants/campuses.js new file mode 100644 index 0000000..51f990c --- /dev/null +++ b/backend/src/constants/campuses.js @@ -0,0 +1,96 @@ +const PRODUCT_CAMPUS_SEED_ROWS = Object.freeze([ + { + id: '7e15d693-3f7c-4bc6-a399-8345002af8cf', + name: 'Tigers Campus', + code: 'tigers', + mascot: 'Tigers', + color: 'bg-orange-500', + bgGradient: 'from-orange-500 to-amber-500', + borderColor: 'border-orange-500/30', + textColor: 'text-orange-400', + bgLight: 'bg-orange-500/10', + description: 'Strength, courage & determination', + isOnline: false, + active: true, + importHash: 'product-campus-tigers', + }, + { + id: '6ac9c04e-729d-41b8-9058-cd3aa26b832c', + name: 'Gators Campus', + code: 'gators', + mascot: 'Gators', + color: 'bg-emerald-500', + bgGradient: 'from-emerald-500 to-green-500', + borderColor: 'border-emerald-500/30', + textColor: 'text-emerald-400', + bgLight: 'bg-emerald-500/10', + description: 'Resilience, adaptability & power', + isOnline: false, + active: true, + importHash: 'product-campus-gators', + }, + { + id: '829c4d4b-525e-408a-ae7a-0358c50726f7', + name: 'Hawks Campus', + code: 'hawks', + mascot: 'Hawks', + color: 'bg-red-500', + bgGradient: 'from-red-500 to-rose-500', + borderColor: 'border-red-500/30', + textColor: 'text-red-400', + bgLight: 'bg-red-500/10', + description: 'Vision, focus & leadership', + isOnline: false, + active: true, + importHash: 'product-campus-hawks', + }, + { + id: '848eb809-b2e2-4c0f-ac6b-cb910fd7e26d', + name: 'Owls Campus', + code: 'owls', + mascot: 'Owls', + color: 'bg-purple-500', + bgGradient: 'from-purple-500 to-violet-500', + borderColor: 'border-purple-500/30', + textColor: 'text-purple-400', + bgLight: 'bg-purple-500/10', + description: 'Wisdom, insight & virtual learning', + isOnline: true, + active: true, + importHash: 'product-campus-owls', + }, + { + id: '6670d72a-cf6b-4f92-9e21-378ac81df3d8', + name: 'Wildcats Campus', + code: 'wildcats', + mascot: 'Wildcats', + color: 'bg-blue-500', + bgGradient: 'from-blue-500 to-cyan-500', + borderColor: 'border-blue-500/30', + textColor: 'text-blue-400', + bgLight: 'bg-blue-500/10', + description: 'Agility, independence & spirit', + isOnline: false, + active: true, + importHash: 'product-campus-wildcats', + }, + { + id: '4a331c45-b463-4748-9e90-23d0e4b41aaf', + name: 'Grizzlies Campus', + code: 'grizzlies', + mascot: 'Grizzlies', + color: 'bg-amber-700', + bgGradient: 'from-amber-700 to-yellow-600', + borderColor: 'border-amber-700/30', + textColor: 'text-amber-500', + bgLight: 'bg-amber-700/10', + description: 'Strength, protection & community', + isOnline: false, + active: true, + importHash: 'product-campus-grizzlies', + }, +]); + +module.exports = { + PRODUCT_CAMPUS_SEED_ROWS, +}; diff --git a/backend/src/constants/communications.js b/backend/src/constants/communications.js new file mode 100644 index 0000000..43c231c --- /dev/null +++ b/backend/src/constants/communications.js @@ -0,0 +1,50 @@ +const { GENERATED_ROLE_NAMES } = require('./roles'); + +const COMMUNICATION_CHANNELS = Object.freeze({ + IN_APP: 'in_app', +}); + +const COMMUNICATION_AUDIENCES = Object.freeze({ + GUARDIANS: 'guardians', + STAFF: 'staff', +}); + +const COMMUNICATION_STATUSES = Object.freeze({ + SENT: 'sent', +}); + +const COMMUNICATION_RECIPIENT_TYPES = Object.freeze({ + GUARDIAN: 'guardian', +}); + +const COMMUNICATION_EVENT_TYPES = Object.freeze({ + MEETING: 'meeting', + DRILL: 'drill', + EVENT: 'event', + DEADLINE: 'deadline', +}); + +const COMMUNICATION_MANAGER_ROLE_NAMES = Object.freeze([ + GENERATED_ROLE_NAMES.SUPER_ADMIN, + GENERATED_ROLE_NAMES.ADMIN, + GENERATED_ROLE_NAMES.PLATFORM_OWNER, + GENERATED_ROLE_NAMES.TENANT_DIRECTOR, + GENERATED_ROLE_NAMES.CAMPUS_MANAGER, +]); + +const COMMUNICATION_TENANT_WIDE_ROLE_NAMES = Object.freeze([ + GENERATED_ROLE_NAMES.SUPER_ADMIN, + GENERATED_ROLE_NAMES.ADMIN, + GENERATED_ROLE_NAMES.PLATFORM_OWNER, + GENERATED_ROLE_NAMES.TENANT_DIRECTOR, +]); + +module.exports = { + COMMUNICATION_AUDIENCES, + COMMUNICATION_CHANNELS, + COMMUNICATION_EVENT_TYPES, + COMMUNICATION_MANAGER_ROLE_NAMES, + COMMUNICATION_RECIPIENT_TYPES, + COMMUNICATION_STATUSES, + COMMUNICATION_TENANT_WIDE_ROLE_NAMES, +}; diff --git a/backend/src/constants/content-catalog.js b/backend/src/constants/content-catalog.js new file mode 100644 index 0000000..c58673c --- /dev/null +++ b/backend/src/constants/content-catalog.js @@ -0,0 +1,13 @@ +const { GENERATED_ROLE_NAMES } = require('./roles'); + +const CONTENT_CATALOG_MANAGER_ROLE_NAMES = Object.freeze([ + GENERATED_ROLE_NAMES.SUPER_ADMIN, + GENERATED_ROLE_NAMES.ADMIN, + GENERATED_ROLE_NAMES.PLATFORM_OWNER, + GENERATED_ROLE_NAMES.TENANT_DIRECTOR, + GENERATED_ROLE_NAMES.CAMPUS_MANAGER, +]); + +module.exports = { + CONTENT_CATALOG_MANAGER_ROLE_NAMES, +}; diff --git a/backend/src/constants/frame.js b/backend/src/constants/frame.js new file mode 100644 index 0000000..b454d42 --- /dev/null +++ b/backend/src/constants/frame.js @@ -0,0 +1,13 @@ +const { GENERATED_ROLE_NAMES } = require('./roles'); + +const FRAME_EDITOR_ROLE_NAMES = Object.freeze([ + GENERATED_ROLE_NAMES.SUPER_ADMIN, + GENERATED_ROLE_NAMES.ADMIN, + GENERATED_ROLE_NAMES.PLATFORM_OWNER, + GENERATED_ROLE_NAMES.TENANT_DIRECTOR, + GENERATED_ROLE_NAMES.CAMPUS_MANAGER, +]); + +module.exports = { + FRAME_EDITOR_ROLE_NAMES, +}; diff --git a/backend/src/constants/personality.js b/backend/src/constants/personality.js new file mode 100644 index 0000000..fee96aa --- /dev/null +++ b/backend/src/constants/personality.js @@ -0,0 +1,13 @@ +const { GENERATED_ROLE_NAMES } = require('./roles'); + +const PERSONALITY_REPORT_ROLE_NAMES = Object.freeze([ + GENERATED_ROLE_NAMES.SUPER_ADMIN, + GENERATED_ROLE_NAMES.ADMIN, + GENERATED_ROLE_NAMES.PLATFORM_OWNER, + GENERATED_ROLE_NAMES.TENANT_DIRECTOR, + GENERATED_ROLE_NAMES.CAMPUS_MANAGER, +]); + +module.exports = { + PERSONALITY_REPORT_ROLE_NAMES, +}; diff --git a/backend/src/constants/roles.js b/backend/src/constants/roles.js new file mode 100644 index 0000000..031de2b --- /dev/null +++ b/backend/src/constants/roles.js @@ -0,0 +1,40 @@ +const GENERATED_ROLE_NAMES = Object.freeze({ + SUPER_ADMIN: 'Super Administrator', + ADMIN: 'Administrator', + PLATFORM_OWNER: 'Platform Owner', + TENANT_DIRECTOR: 'Tenant Director', + CAMPUS_MANAGER: 'Campus Manager', + ACADEMIC_COORDINATOR: 'Academic Coordinator', + FINANCE_OFFICER: 'Finance Officer', +}); + +const PRODUCT_ROLE_VALUES = Object.freeze({ + TEACHER: 'teacher', + PARA: 'para', + OFFICE: 'office', + DIRECTOR: 'director', + SUPERINTENDENT: 'superintendent', +}); + +const GENERATED_ROLE_TO_PRODUCT_ROLE = Object.freeze({ + [GENERATED_ROLE_NAMES.SUPER_ADMIN]: PRODUCT_ROLE_VALUES.SUPERINTENDENT, + [GENERATED_ROLE_NAMES.ADMIN]: PRODUCT_ROLE_VALUES.SUPERINTENDENT, + [GENERATED_ROLE_NAMES.PLATFORM_OWNER]: PRODUCT_ROLE_VALUES.SUPERINTENDENT, + [GENERATED_ROLE_NAMES.TENANT_DIRECTOR]: PRODUCT_ROLE_VALUES.DIRECTOR, + [GENERATED_ROLE_NAMES.CAMPUS_MANAGER]: PRODUCT_ROLE_VALUES.DIRECTOR, + [GENERATED_ROLE_NAMES.ACADEMIC_COORDINATOR]: PRODUCT_ROLE_VALUES.TEACHER, + [GENERATED_ROLE_NAMES.FINANCE_OFFICER]: PRODUCT_ROLE_VALUES.OFFICE, +}); + +const STAFF_TYPE_TO_PRODUCT_ROLE = Object.freeze({ + teacher: PRODUCT_ROLE_VALUES.TEACHER, + admin: PRODUCT_ROLE_VALUES.OFFICE, + support: PRODUCT_ROLE_VALUES.PARA, +}); + +module.exports = { + GENERATED_ROLE_TO_PRODUCT_ROLE, + GENERATED_ROLE_NAMES, + PRODUCT_ROLE_VALUES, + STAFF_TYPE_TO_PRODUCT_ROLE, +}; diff --git a/backend/src/constants/safety-quiz.js b/backend/src/constants/safety-quiz.js new file mode 100644 index 0000000..57c0342 --- /dev/null +++ b/backend/src/constants/safety-quiz.js @@ -0,0 +1,13 @@ +const { GENERATED_ROLE_NAMES } = require('./roles'); + +const SAFETY_QUIZ_REPORT_ROLE_NAMES = Object.freeze([ + GENERATED_ROLE_NAMES.SUPER_ADMIN, + GENERATED_ROLE_NAMES.ADMIN, + GENERATED_ROLE_NAMES.PLATFORM_OWNER, + GENERATED_ROLE_NAMES.TENANT_DIRECTOR, + GENERATED_ROLE_NAMES.CAMPUS_MANAGER, +]); + +module.exports = { + SAFETY_QUIZ_REPORT_ROLE_NAMES, +}; diff --git a/backend/src/constants/staff-attendance.js b/backend/src/constants/staff-attendance.js new file mode 100644 index 0000000..69d15c4 --- /dev/null +++ b/backend/src/constants/staff-attendance.js @@ -0,0 +1,32 @@ +const { GENERATED_ROLE_NAMES } = require('./roles'); + +const STAFF_ATTENDANCE_STATUSES = Object.freeze({ + PRESENT: 'present', + LATE: 'late', + ABSENT: 'absent', +}); + +const STAFF_ATTENDANCE_REPORT_ROLE_NAMES = Object.freeze([ + GENERATED_ROLE_NAMES.SUPER_ADMIN, + GENERATED_ROLE_NAMES.ADMIN, + GENERATED_ROLE_NAMES.PLATFORM_OWNER, + GENERATED_ROLE_NAMES.TENANT_DIRECTOR, + GENERATED_ROLE_NAMES.CAMPUS_MANAGER, +]); + +const STAFF_ATTENDANCE_TENANT_WIDE_ROLE_NAMES = Object.freeze([ + GENERATED_ROLE_NAMES.SUPER_ADMIN, + GENERATED_ROLE_NAMES.ADMIN, + GENERATED_ROLE_NAMES.PLATFORM_OWNER, +]); + +const STAFF_ATTENDANCE_DEFAULT_LIMIT = 90; +const STAFF_ATTENDANCE_MAX_LIMIT = 366; + +module.exports = { + STAFF_ATTENDANCE_DEFAULT_LIMIT, + STAFF_ATTENDANCE_MAX_LIMIT, + STAFF_ATTENDANCE_REPORT_ROLE_NAMES, + STAFF_ATTENDANCE_STATUSES, + STAFF_ATTENDANCE_TENANT_WIDE_ROLE_NAMES, +}; diff --git a/backend/src/constants/user-progress.js b/backend/src/constants/user-progress.js new file mode 100644 index 0000000..d4220bc --- /dev/null +++ b/backend/src/constants/user-progress.js @@ -0,0 +1,11 @@ +const USER_PROGRESS_TYPES = Object.freeze({ + SIGN_LEARNED: 'sign_learned', + ZONE_CHECKIN: 'zone_checkin', +}); + +const ZONE_CHECKIN_ITEM_ID = 'current'; + +module.exports = { + USER_PROGRESS_TYPES, + ZONE_CHECKIN_ITEM_ID, +}; diff --git a/backend/src/constants/walkthrough.js b/backend/src/constants/walkthrough.js new file mode 100644 index 0000000..d9e678a --- /dev/null +++ b/backend/src/constants/walkthrough.js @@ -0,0 +1,21 @@ +const { GENERATED_ROLE_NAMES } = require('./roles'); + +const WALKTHROUGH_MANAGER_ROLE_NAMES = Object.freeze([ + GENERATED_ROLE_NAMES.SUPER_ADMIN, + GENERATED_ROLE_NAMES.ADMIN, + GENERATED_ROLE_NAMES.PLATFORM_OWNER, + GENERATED_ROLE_NAMES.TENANT_DIRECTOR, + GENERATED_ROLE_NAMES.CAMPUS_MANAGER, +]); + +const WALKTHROUGH_TENANT_WIDE_ROLE_NAMES = Object.freeze([ + GENERATED_ROLE_NAMES.SUPER_ADMIN, + GENERATED_ROLE_NAMES.ADMIN, + GENERATED_ROLE_NAMES.PLATFORM_OWNER, + GENERATED_ROLE_NAMES.TENANT_DIRECTOR, +]); + +module.exports = { + WALKTHROUGH_MANAGER_ROLE_NAMES, + WALKTHROUGH_TENANT_WIDE_ROLE_NAMES, +}; diff --git a/backend/src/db/api/auth_refresh_tokens.js b/backend/src/db/api/auth_refresh_tokens.js new file mode 100644 index 0000000..c6b68b9 --- /dev/null +++ b/backend/src/db/api/auth_refresh_tokens.js @@ -0,0 +1,56 @@ +const db = require('../models'); + +module.exports = class AuthRefreshTokensDBApi { + static async create(data, options = {}) { + return db.auth_refresh_tokens.create( + { + userId: data.userId, + organizationId: data.organizationId || null, + tokenHash: data.tokenHash, + familyId: data.familyId, + previousTokenId: data.previousTokenId || null, + userAgent: data.userAgent || null, + ipAddress: data.ipAddress || null, + expiresAt: data.expiresAt, + revokedAt: null, + replacedByTokenId: null, + }, + { transaction: options.transaction }, + ); + } + + static async findByHash(tokenHash, options = {}) { + return db.auth_refresh_tokens.findOne({ + where: { tokenHash }, + transaction: options.transaction, + }); + } + + static async revoke(id, replacedByTokenId, options = {}) { + return db.auth_refresh_tokens.update( + { + revokedAt: new Date(), + replacedByTokenId: replacedByTokenId || null, + }, + { + where: { id }, + transaction: options.transaction, + }, + ); + } + + static async revokeFamily(familyId, options = {}) { + return db.auth_refresh_tokens.update( + { + revokedAt: new Date(), + }, + { + where: { + familyId, + revokedAt: null, + }, + transaction: options.transaction, + }, + ); + } +}; diff --git a/backend/src/db/api/campuses.js b/backend/src/db/api/campuses.js index 346dbe3..1a00377 100644 --- a/backend/src/db/api/campuses.js +++ b/backend/src/db/api/campuses.js @@ -45,6 +45,46 @@ module.exports = class CampusesDBApi { || null , + + mascot: data.mascot + || + null + , + + color: data.color + || + null + , + + bgGradient: data.bgGradient + || + null + , + + borderColor: data.borderColor + || + null + , + + textColor: data.textColor + || + null + , + + bgLight: data.bgLight + || + null + , + + description: data.description + || + null + , + + isOnline: data.isOnline + || + false + , active: data.active || @@ -104,6 +144,46 @@ module.exports = class CampusesDBApi { email: item.email || null + , + + mascot: item.mascot + || + null + , + + color: item.color + || + null + , + + bgGradient: item.bgGradient + || + null + , + + borderColor: item.borderColor + || + null + , + + textColor: item.textColor + || + null + , + + bgLight: item.bgLight + || + null + , + + description: item.description + || + null + , + + isOnline: item.isOnline + || + false , active: item.active @@ -152,6 +232,22 @@ module.exports = class CampusesDBApi { if (data.email !== undefined) updatePayload.email = data.email; + + if (data.mascot !== undefined) updatePayload.mascot = data.mascot; + + if (data.color !== undefined) updatePayload.color = data.color; + + if (data.bgGradient !== undefined) updatePayload.bgGradient = data.bgGradient; + + if (data.borderColor !== undefined) updatePayload.borderColor = data.borderColor; + + if (data.textColor !== undefined) updatePayload.textColor = data.textColor; + + if (data.bgLight !== undefined) updatePayload.bgLight = data.bgLight; + + if (data.description !== undefined) updatePayload.description = data.description; + + if (data.isOnline !== undefined) updatePayload.isOnline = data.isOnline; if (data.active !== undefined) updatePayload.active = data.active; @@ -553,4 +649,3 @@ module.exports = class CampusesDBApi { }; - diff --git a/backend/src/db/db.config.js b/backend/src/db/db.config.js index 291725c..997df4d 100644 --- a/backend/src/db/db.config.js +++ b/backend/src/db/db.config.js @@ -1,4 +1,10 @@ +require('../config/load-env'); +const { + DEFAULT_DEV_DB_HOST, + DEFAULT_DEV_DB_NAME, + DEFAULT_DEV_DB_USER, +} = require('../constants/app'); module.exports = { production: { @@ -12,11 +18,12 @@ module.exports = { seederStorage: 'sequelize', }, development: { - username: 'postgres', + username: process.env.DB_USER || DEFAULT_DEV_DB_USER, dialect: 'postgres', - password: '', - database: 'db_school_chain_manager', - host: process.env.DB_HOST || 'localhost', + password: process.env.DB_PASS || '', + database: process.env.DB_NAME || DEFAULT_DEV_DB_NAME, + host: process.env.DB_HOST || DEFAULT_DEV_DB_HOST, + port: process.env.DB_PORT, logging: console.log, seederStorage: 'sequelize', }, diff --git a/backend/src/db/migrations/20260608000000-create-frame-entries.js b/backend/src/db/migrations/20260608000000-create-frame-entries.js new file mode 100644 index 0000000..bc2dbbc --- /dev/null +++ b/backend/src/db/migrations/20260608000000-create-frame-entries.js @@ -0,0 +1,151 @@ +module.exports = { + async up(queryInterface, Sequelize) { + const transaction = await queryInterface.sequelize.transaction(); + + try { + const rows = await queryInterface.sequelize.query( + "SELECT to_regclass('public.frame_entries') AS regclass_name;", + { + transaction, + type: Sequelize.QueryTypes.SELECT, + }, + ); + + if (rows[0].regclass_name) { + await transaction.commit(); + return; + } + + await queryInterface.createTable( + 'frame_entries', + { + id: { + type: Sequelize.DataTypes.UUID, + defaultValue: Sequelize.DataTypes.UUIDV4, + primaryKey: true, + }, + week_of: { + type: Sequelize.DataTypes.TEXT, + allowNull: false, + }, + posted_date: { + type: Sequelize.DataTypes.TEXT, + allowNull: false, + }, + formal: { + type: Sequelize.DataTypes.TEXT, + allowNull: false, + }, + recognition: { + type: Sequelize.DataTypes.TEXT, + allowNull: false, + }, + application: { + type: Sequelize.DataTypes.TEXT, + allowNull: false, + }, + management: { + type: Sequelize.DataTypes.TEXT, + allowNull: false, + }, + emotional: { + type: Sequelize.DataTypes.TEXT, + allowNull: false, + }, + author: { + type: Sequelize.DataTypes.TEXT, + allowNull: false, + }, + organizationId: { + type: Sequelize.DataTypes.UUID, + allowNull: false, + references: { + key: 'id', + model: 'organizations', + }, + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }, + campusId: { + type: Sequelize.DataTypes.UUID, + allowNull: true, + references: { + key: 'id', + model: 'campuses', + }, + onDelete: 'SET NULL', + onUpdate: 'CASCADE', + }, + createdById: { + type: Sequelize.DataTypes.UUID, + allowNull: true, + references: { + key: 'id', + model: 'users', + }, + onDelete: 'SET NULL', + onUpdate: 'CASCADE', + }, + updatedById: { + type: Sequelize.DataTypes.UUID, + allowNull: true, + references: { + key: 'id', + model: 'users', + }, + onDelete: 'SET NULL', + onUpdate: 'CASCADE', + }, + createdAt: { + type: Sequelize.DataTypes.DATE, + allowNull: false, + }, + updatedAt: { + type: Sequelize.DataTypes.DATE, + allowNull: false, + }, + deletedAt: { + type: Sequelize.DataTypes.DATE, + allowNull: true, + }, + importHash: { + type: Sequelize.DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + }, + { transaction }, + ); + + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + }, + + async down(queryInterface, Sequelize) { + const transaction = await queryInterface.sequelize.transaction(); + + try { + const rows = await queryInterface.sequelize.query( + "SELECT to_regclass('public.frame_entries') AS regclass_name;", + { + transaction, + type: Sequelize.QueryTypes.SELECT, + }, + ); + + if (!rows[0].regclass_name) { + await transaction.commit(); + return; + } + + await queryInterface.dropTable('frame_entries', { transaction }); + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + }, +}; diff --git a/backend/src/db/migrations/20260608001000-create-user-progress.js b/backend/src/db/migrations/20260608001000-create-user-progress.js new file mode 100644 index 0000000..c2bb5a5 --- /dev/null +++ b/backend/src/db/migrations/20260608001000-create-user-progress.js @@ -0,0 +1,158 @@ +module.exports = { + async up(queryInterface, Sequelize) { + const transaction = await queryInterface.sequelize.transaction(); + + try { + const rows = await queryInterface.sequelize.query( + "SELECT to_regclass('public.user_progress') AS regclass_name;", + { + transaction, + type: Sequelize.QueryTypes.SELECT, + }, + ); + + if (rows[0].regclass_name) { + await transaction.commit(); + return; + } + + await queryInterface.createTable( + 'user_progress', + { + id: { + type: Sequelize.DataTypes.UUID, + defaultValue: Sequelize.DataTypes.UUIDV4, + primaryKey: true, + }, + progress_type: { + type: Sequelize.DataTypes.TEXT, + allowNull: false, + }, + item_id: { + type: Sequelize.DataTypes.TEXT, + allowNull: false, + }, + value: { + type: Sequelize.DataTypes.TEXT, + allowNull: true, + }, + score: { + type: Sequelize.DataTypes.INTEGER, + allowNull: true, + }, + metadata: { + type: Sequelize.DataTypes.JSONB, + allowNull: true, + }, + organizationId: { + type: Sequelize.DataTypes.UUID, + allowNull: false, + references: { + key: 'id', + model: 'organizations', + }, + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }, + campusId: { + type: Sequelize.DataTypes.UUID, + allowNull: true, + references: { + key: 'id', + model: 'campuses', + }, + onDelete: 'SET NULL', + onUpdate: 'CASCADE', + }, + userId: { + type: Sequelize.DataTypes.UUID, + allowNull: false, + references: { + key: 'id', + model: 'users', + }, + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }, + createdById: { + type: Sequelize.DataTypes.UUID, + allowNull: true, + references: { + key: 'id', + model: 'users', + }, + onDelete: 'SET NULL', + onUpdate: 'CASCADE', + }, + updatedById: { + type: Sequelize.DataTypes.UUID, + allowNull: true, + references: { + key: 'id', + model: 'users', + }, + onDelete: 'SET NULL', + onUpdate: 'CASCADE', + }, + createdAt: { + type: Sequelize.DataTypes.DATE, + allowNull: false, + }, + updatedAt: { + type: Sequelize.DataTypes.DATE, + allowNull: false, + }, + deletedAt: { + type: Sequelize.DataTypes.DATE, + allowNull: true, + }, + importHash: { + type: Sequelize.DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + }, + { transaction }, + ); + + await queryInterface.addIndex( + 'user_progress', + ['organizationId', 'userId', 'progress_type', 'item_id'], + { + name: 'user_progress_owner_item_idx', + transaction, + }, + ); + + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + }, + + async down(queryInterface, Sequelize) { + const transaction = await queryInterface.sequelize.transaction(); + + try { + const rows = await queryInterface.sequelize.query( + "SELECT to_regclass('public.user_progress') AS regclass_name;", + { + transaction, + type: Sequelize.QueryTypes.SELECT, + }, + ); + + if (!rows[0].regclass_name) { + await transaction.commit(); + return; + } + + await queryInterface.dropTable('user_progress', { transaction }); + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + }, +}; diff --git a/backend/src/db/migrations/20260608002000-create-safety-quiz-results.js b/backend/src/db/migrations/20260608002000-create-safety-quiz-results.js new file mode 100644 index 0000000..510a598 --- /dev/null +++ b/backend/src/db/migrations/20260608002000-create-safety-quiz-results.js @@ -0,0 +1,174 @@ +module.exports = { + async up(queryInterface, Sequelize) { + const transaction = await queryInterface.sequelize.transaction(); + + try { + const rows = await queryInterface.sequelize.query( + "SELECT to_regclass('public.safety_quiz_results') AS regclass_name;", + { + transaction, + type: Sequelize.QueryTypes.SELECT, + }, + ); + + if (rows[0].regclass_name) { + await transaction.commit(); + return; + } + + await queryInterface.createTable( + 'safety_quiz_results', + { + id: { + type: Sequelize.DataTypes.UUID, + defaultValue: Sequelize.DataTypes.UUIDV4, + primaryKey: true, + }, + quiz_id: { + type: Sequelize.DataTypes.TEXT, + allowNull: false, + }, + quiz_title: { + type: Sequelize.DataTypes.TEXT, + allowNull: false, + }, + week_of: { + type: Sequelize.DataTypes.TEXT, + allowNull: false, + }, + score: { + type: Sequelize.DataTypes.INTEGER, + allowNull: false, + }, + total_questions: { + type: Sequelize.DataTypes.INTEGER, + allowNull: false, + }, + answers: { + type: Sequelize.DataTypes.JSONB, + allowNull: false, + }, + user_name: { + type: Sequelize.DataTypes.TEXT, + allowNull: false, + }, + user_role: { + type: Sequelize.DataTypes.TEXT, + allowNull: false, + }, + completed_at: { + type: Sequelize.DataTypes.DATE, + allowNull: false, + }, + organizationId: { + type: Sequelize.DataTypes.UUID, + allowNull: false, + references: { + key: 'id', + model: 'organizations', + }, + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }, + campusId: { + type: Sequelize.DataTypes.UUID, + allowNull: true, + references: { + key: 'id', + model: 'campuses', + }, + onDelete: 'SET NULL', + onUpdate: 'CASCADE', + }, + userId: { + type: Sequelize.DataTypes.UUID, + allowNull: false, + references: { + key: 'id', + model: 'users', + }, + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }, + createdById: { + type: Sequelize.DataTypes.UUID, + allowNull: true, + references: { + key: 'id', + model: 'users', + }, + onDelete: 'SET NULL', + onUpdate: 'CASCADE', + }, + updatedById: { + type: Sequelize.DataTypes.UUID, + allowNull: true, + references: { + key: 'id', + model: 'users', + }, + onDelete: 'SET NULL', + onUpdate: 'CASCADE', + }, + createdAt: { + type: Sequelize.DataTypes.DATE, + allowNull: false, + }, + updatedAt: { + type: Sequelize.DataTypes.DATE, + allowNull: false, + }, + deletedAt: { + type: Sequelize.DataTypes.DATE, + allowNull: true, + }, + importHash: { + type: Sequelize.DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + }, + { transaction }, + ); + + await queryInterface.addIndex( + 'safety_quiz_results', + ['organizationId', 'week_of', 'userId'], + { + name: 'safety_quiz_results_org_week_user_idx', + transaction, + }, + ); + + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + }, + + async down(queryInterface, Sequelize) { + const transaction = await queryInterface.sequelize.transaction(); + + try { + const rows = await queryInterface.sequelize.query( + "SELECT to_regclass('public.safety_quiz_results') AS regclass_name;", + { + transaction, + type: Sequelize.QueryTypes.SELECT, + }, + ); + + if (!rows[0].regclass_name) { + await transaction.commit(); + return; + } + + await queryInterface.dropTable('safety_quiz_results', { transaction }); + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + }, +}; diff --git a/backend/src/db/migrations/20260608003000-create-walkthrough-checkins.js b/backend/src/db/migrations/20260608003000-create-walkthrough-checkins.js new file mode 100644 index 0000000..0f1fd5a --- /dev/null +++ b/backend/src/db/migrations/20260608003000-create-walkthrough-checkins.js @@ -0,0 +1,127 @@ +module.exports = { + async up(queryInterface, Sequelize) { + const transaction = await queryInterface.sequelize.transaction(); + + try { + const rows = await queryInterface.sequelize.query( + "SELECT to_regclass('public.walkthrough_checkins') AS regclass_name;", + { + transaction, + type: Sequelize.QueryTypes.SELECT, + }, + ); + + if (rows[0].regclass_name) { + await transaction.commit(); + return; + } + + await queryInterface.createTable( + 'walkthrough_checkins', + { + id: { + type: Sequelize.DataTypes.UUID, + defaultValue: Sequelize.DataTypes.UUIDV4, + primaryKey: true, + }, + teacher_name: { type: Sequelize.DataTypes.TEXT, allowNull: false }, + classroom: { type: Sequelize.DataTypes.TEXT, allowNull: false }, + director_name: { type: Sequelize.DataTypes.TEXT, allowNull: false }, + check_in_date: { type: Sequelize.DataTypes.DATEONLY, allowNull: false }, + check_in_time: { type: Sequelize.DataTypes.TIME, allowNull: false }, + attitude_rating: { type: Sequelize.DataTypes.INTEGER, allowNull: false }, + attitude_comment: { type: Sequelize.DataTypes.TEXT, allowNull: true }, + classroom_management_rating: { type: Sequelize.DataTypes.INTEGER, allowNull: false }, + classroom_management_comment: { type: Sequelize.DataTypes.TEXT, allowNull: true }, + cleanliness_rating: { type: Sequelize.DataTypes.INTEGER, allowNull: false }, + cleanliness_comment: { type: Sequelize.DataTypes.TEXT, allowNull: true }, + vibes_rating: { type: Sequelize.DataTypes.INTEGER, allowNull: false }, + vibes_comment: { type: Sequelize.DataTypes.TEXT, allowNull: true }, + team_dynamics_rating: { type: Sequelize.DataTypes.INTEGER, allowNull: false }, + team_dynamics_comment: { type: Sequelize.DataTypes.TEXT, allowNull: true }, + emergency_exit_rating: { type: Sequelize.DataTypes.INTEGER, allowNull: false }, + emergency_exit_comment: { type: Sequelize.DataTypes.TEXT, allowNull: true }, + lesson_plan_rating: { type: Sequelize.DataTypes.INTEGER, allowNull: false }, + lesson_plan_comment: { type: Sequelize.DataTypes.TEXT, allowNull: true }, + overall_notes: { type: Sequelize.DataTypes.TEXT, allowNull: true }, + organizationId: { + type: Sequelize.DataTypes.UUID, + allowNull: false, + references: { key: 'id', model: 'organizations' }, + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }, + campusId: { + type: Sequelize.DataTypes.UUID, + allowNull: true, + references: { key: 'id', model: 'campuses' }, + onDelete: 'SET NULL', + onUpdate: 'CASCADE', + }, + createdById: { + type: Sequelize.DataTypes.UUID, + allowNull: true, + references: { key: 'id', model: 'users' }, + onDelete: 'SET NULL', + onUpdate: 'CASCADE', + }, + updatedById: { + type: Sequelize.DataTypes.UUID, + allowNull: true, + references: { key: 'id', model: 'users' }, + onDelete: 'SET NULL', + onUpdate: 'CASCADE', + }, + createdAt: { type: Sequelize.DataTypes.DATE, allowNull: false }, + updatedAt: { type: Sequelize.DataTypes.DATE, allowNull: false }, + deletedAt: { type: Sequelize.DataTypes.DATE, allowNull: true }, + importHash: { + type: Sequelize.DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + }, + { transaction }, + ); + + await queryInterface.addIndex( + 'walkthrough_checkins', + ['organizationId', 'check_in_date'], + { + name: 'walkthrough_checkins_org_date_idx', + transaction, + }, + ); + + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + }, + + async down(queryInterface, Sequelize) { + const transaction = await queryInterface.sequelize.transaction(); + + try { + const rows = await queryInterface.sequelize.query( + "SELECT to_regclass('public.walkthrough_checkins') AS regclass_name;", + { + transaction, + type: Sequelize.QueryTypes.SELECT, + }, + ); + + if (!rows[0].regclass_name) { + await transaction.commit(); + return; + } + + await queryInterface.dropTable('walkthrough_checkins', { transaction }); + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + }, +}; diff --git a/backend/src/db/migrations/20260608004000-create-communication-events.js b/backend/src/db/migrations/20260608004000-create-communication-events.js new file mode 100644 index 0000000..227887c --- /dev/null +++ b/backend/src/db/migrations/20260608004000-create-communication-events.js @@ -0,0 +1,145 @@ +module.exports = { + async up(queryInterface, Sequelize) { + const transaction = await queryInterface.sequelize.transaction(); + + try { + const rows = await queryInterface.sequelize.query( + "SELECT to_regclass('public.communication_events') AS regclass_name;", + { + transaction, + type: Sequelize.QueryTypes.SELECT, + }, + ); + + if (rows[0].regclass_name) { + await transaction.commit(); + return; + } + + await queryInterface.createTable( + 'communication_events', + { + id: { + type: Sequelize.DataTypes.UUID, + defaultValue: Sequelize.DataTypes.UUIDV4, + primaryKey: true, + }, + title: { + type: Sequelize.DataTypes.TEXT, + allowNull: false, + }, + event_date: { + type: Sequelize.DataTypes.DATEONLY, + allowNull: false, + }, + event_type: { + type: Sequelize.DataTypes.TEXT, + allowNull: false, + }, + roles: { + type: Sequelize.DataTypes.JSONB, + allowNull: false, + defaultValue: [], + }, + organizationId: { + type: Sequelize.DataTypes.UUID, + allowNull: false, + references: { + key: 'id', + model: 'organizations', + }, + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }, + campusId: { + type: Sequelize.DataTypes.UUID, + allowNull: true, + references: { + key: 'id', + model: 'campuses', + }, + onDelete: 'SET NULL', + onUpdate: 'CASCADE', + }, + createdById: { + type: Sequelize.DataTypes.UUID, + allowNull: true, + references: { + key: 'id', + model: 'users', + }, + onDelete: 'SET NULL', + onUpdate: 'CASCADE', + }, + updatedById: { + type: Sequelize.DataTypes.UUID, + allowNull: true, + references: { + key: 'id', + model: 'users', + }, + onDelete: 'SET NULL', + onUpdate: 'CASCADE', + }, + createdAt: { + type: Sequelize.DataTypes.DATE, + allowNull: false, + }, + updatedAt: { + type: Sequelize.DataTypes.DATE, + allowNull: false, + }, + deletedAt: { + type: Sequelize.DataTypes.DATE, + allowNull: true, + }, + importHash: { + type: Sequelize.DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + }, + { transaction }, + ); + + await queryInterface.addIndex( + 'communication_events', + ['organizationId', 'campusId', 'event_date'], + { + name: 'communication_events_org_campus_date_idx', + transaction, + }, + ); + + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + }, + + async down(queryInterface, Sequelize) { + const transaction = await queryInterface.sequelize.transaction(); + + try { + const rows = await queryInterface.sequelize.query( + "SELECT to_regclass('public.communication_events') AS regclass_name;", + { + transaction, + type: Sequelize.QueryTypes.SELECT, + }, + ); + + if (!rows[0].regclass_name) { + await transaction.commit(); + return; + } + + await queryInterface.dropTable('communication_events', { transaction }); + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + }, +}; diff --git a/backend/src/db/migrations/20260608005000-create-personality-quiz-results.js b/backend/src/db/migrations/20260608005000-create-personality-quiz-results.js new file mode 100644 index 0000000..0299bdf --- /dev/null +++ b/backend/src/db/migrations/20260608005000-create-personality-quiz-results.js @@ -0,0 +1,160 @@ +module.exports = { + async up(queryInterface, Sequelize) { + const transaction = await queryInterface.sequelize.transaction(); + + try { + const rows = await queryInterface.sequelize.query( + "SELECT to_regclass('public.personality_quiz_results') AS regclass_name;", + { + transaction, + type: Sequelize.QueryTypes.SELECT, + }, + ); + + if (rows[0].regclass_name) { + await transaction.commit(); + return; + } + + await queryInterface.createTable( + 'personality_quiz_results', + { + id: { + type: Sequelize.DataTypes.UUID, + defaultValue: Sequelize.DataTypes.UUIDV4, + primaryKey: true, + }, + personality_type: { + type: Sequelize.DataTypes.TEXT, + allowNull: false, + }, + quiz_answers: { + type: Sequelize.DataTypes.JSONB, + allowNull: false, + }, + completed_at: { + type: Sequelize.DataTypes.DATE, + allowNull: false, + }, + organizationId: { + type: Sequelize.DataTypes.UUID, + allowNull: false, + references: { + key: 'id', + model: 'organizations', + }, + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }, + campusId: { + type: Sequelize.DataTypes.UUID, + allowNull: true, + references: { + key: 'id', + model: 'campuses', + }, + onDelete: 'SET NULL', + onUpdate: 'CASCADE', + }, + userId: { + type: Sequelize.DataTypes.UUID, + allowNull: false, + references: { + key: 'id', + model: 'users', + }, + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }, + createdById: { + type: Sequelize.DataTypes.UUID, + allowNull: true, + references: { + key: 'id', + model: 'users', + }, + onDelete: 'SET NULL', + onUpdate: 'CASCADE', + }, + updatedById: { + type: Sequelize.DataTypes.UUID, + allowNull: true, + references: { + key: 'id', + model: 'users', + }, + onDelete: 'SET NULL', + onUpdate: 'CASCADE', + }, + createdAt: { + type: Sequelize.DataTypes.DATE, + allowNull: false, + }, + updatedAt: { + type: Sequelize.DataTypes.DATE, + allowNull: false, + }, + deletedAt: { + type: Sequelize.DataTypes.DATE, + allowNull: true, + }, + importHash: { + type: Sequelize.DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + }, + { transaction }, + ); + + await queryInterface.addIndex( + 'personality_quiz_results', + ['organizationId', 'userId'], + { + unique: true, + name: 'personality_quiz_results_org_user_unique', + transaction, + }, + ); + + await queryInterface.addIndex( + 'personality_quiz_results', + ['organizationId', 'campusId', 'personality_type'], + { + name: 'personality_quiz_results_distribution_idx', + transaction, + }, + ); + + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + }, + + async down(queryInterface, Sequelize) { + const transaction = await queryInterface.sequelize.transaction(); + + try { + const rows = await queryInterface.sequelize.query( + "SELECT to_regclass('public.personality_quiz_results') AS regclass_name;", + { + transaction, + type: Sequelize.QueryTypes.SELECT, + }, + ); + + if (!rows[0].regclass_name) { + await transaction.commit(); + return; + } + + await queryInterface.dropTable('personality_quiz_results', { transaction }); + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + }, +}; diff --git a/backend/src/db/migrations/20260608006000-create-campus-attendance-config.js b/backend/src/db/migrations/20260608006000-create-campus-attendance-config.js new file mode 100644 index 0000000..d731364 --- /dev/null +++ b/backend/src/db/migrations/20260608006000-create-campus-attendance-config.js @@ -0,0 +1,141 @@ +module.exports = { + async up(queryInterface, Sequelize) { + const transaction = await queryInterface.sequelize.transaction(); + + try { + const rows = await queryInterface.sequelize.query( + "SELECT to_regclass('public.campus_attendance_config') AS regclass_name;", + { + transaction, + type: Sequelize.QueryTypes.SELECT, + }, + ); + + if (rows[0].regclass_name) { + await transaction.commit(); + return; + } + + await queryInterface.createTable( + 'campus_attendance_config', + { + id: { + type: Sequelize.DataTypes.UUID, + defaultValue: Sequelize.DataTypes.UUIDV4, + primaryKey: true, + }, + campus_key: { + type: Sequelize.DataTypes.TEXT, + allowNull: false, + }, + attendance_link: { + type: Sequelize.DataTypes.TEXT, + allowNull: true, + }, + updated_by_label: { + type: Sequelize.DataTypes.TEXT, + allowNull: true, + }, + organizationId: { + type: Sequelize.DataTypes.UUID, + allowNull: false, + references: { + key: 'id', + model: 'organizations', + }, + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }, + campusId: { + type: Sequelize.DataTypes.UUID, + allowNull: true, + references: { + key: 'id', + model: 'campuses', + }, + onDelete: 'SET NULL', + onUpdate: 'CASCADE', + }, + createdById: { + type: Sequelize.DataTypes.UUID, + allowNull: true, + references: { + key: 'id', + model: 'users', + }, + onDelete: 'SET NULL', + onUpdate: 'CASCADE', + }, + updatedById: { + type: Sequelize.DataTypes.UUID, + allowNull: true, + references: { + key: 'id', + model: 'users', + }, + onDelete: 'SET NULL', + onUpdate: 'CASCADE', + }, + createdAt: { + type: Sequelize.DataTypes.DATE, + allowNull: false, + }, + updatedAt: { + type: Sequelize.DataTypes.DATE, + allowNull: false, + }, + deletedAt: { + type: Sequelize.DataTypes.DATE, + allowNull: true, + }, + importHash: { + type: Sequelize.DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + }, + { transaction }, + ); + + await queryInterface.addIndex( + 'campus_attendance_config', + ['organizationId', 'campus_key'], + { + unique: true, + name: 'campus_attendance_config_org_campus_key_unique', + transaction, + }, + ); + + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + }, + + async down(queryInterface, Sequelize) { + const transaction = await queryInterface.sequelize.transaction(); + + try { + const rows = await queryInterface.sequelize.query( + "SELECT to_regclass('public.campus_attendance_config') AS regclass_name;", + { + transaction, + type: Sequelize.QueryTypes.SELECT, + }, + ); + + if (!rows[0].regclass_name) { + await transaction.commit(); + return; + } + + await queryInterface.dropTable('campus_attendance_config', { transaction }); + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + }, +}; diff --git a/backend/src/db/migrations/20260608007000-create-campus-attendance-summaries.js b/backend/src/db/migrations/20260608007000-create-campus-attendance-summaries.js new file mode 100644 index 0000000..148b754 --- /dev/null +++ b/backend/src/db/migrations/20260608007000-create-campus-attendance-summaries.js @@ -0,0 +1,166 @@ +module.exports = { + async up(queryInterface, Sequelize) { + const transaction = await queryInterface.sequelize.transaction(); + + try { + const rows = await queryInterface.sequelize.query( + "SELECT to_regclass('public.campus_attendance_summaries') AS regclass_name;", + { + transaction, + type: Sequelize.QueryTypes.SELECT, + }, + ); + + if (rows[0].regclass_name) { + await transaction.commit(); + return; + } + + await queryInterface.createTable( + 'campus_attendance_summaries', + { + id: { + type: Sequelize.DataTypes.UUID, + defaultValue: Sequelize.DataTypes.UUIDV4, + primaryKey: true, + }, + campus_key: { + type: Sequelize.DataTypes.TEXT, + allowNull: false, + }, + attendance_date: { + type: Sequelize.DataTypes.DATEONLY, + allowNull: false, + }, + total_enrolled: { + type: Sequelize.DataTypes.INTEGER, + allowNull: false, + }, + total_present: { + type: Sequelize.DataTypes.INTEGER, + allowNull: false, + }, + total_absent: { + type: Sequelize.DataTypes.INTEGER, + allowNull: false, + }, + total_tardy: { + type: Sequelize.DataTypes.INTEGER, + allowNull: false, + defaultValue: 0, + }, + attendance_percentage: { + type: Sequelize.DataTypes.DECIMAL(5, 2), + allowNull: false, + }, + recorded_by_label: { + type: Sequelize.DataTypes.TEXT, + allowNull: true, + }, + notes: { + type: Sequelize.DataTypes.TEXT, + allowNull: true, + }, + organizationId: { + type: Sequelize.DataTypes.UUID, + allowNull: false, + references: { + key: 'id', + model: 'organizations', + }, + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }, + campusId: { + type: Sequelize.DataTypes.UUID, + allowNull: true, + references: { + key: 'id', + model: 'campuses', + }, + onDelete: 'SET NULL', + onUpdate: 'CASCADE', + }, + createdById: { + type: Sequelize.DataTypes.UUID, + allowNull: true, + references: { + key: 'id', + model: 'users', + }, + onDelete: 'SET NULL', + onUpdate: 'CASCADE', + }, + updatedById: { + type: Sequelize.DataTypes.UUID, + allowNull: true, + references: { + key: 'id', + model: 'users', + }, + onDelete: 'SET NULL', + onUpdate: 'CASCADE', + }, + createdAt: { + type: Sequelize.DataTypes.DATE, + allowNull: false, + }, + updatedAt: { + type: Sequelize.DataTypes.DATE, + allowNull: false, + }, + deletedAt: { + type: Sequelize.DataTypes.DATE, + allowNull: true, + }, + importHash: { + type: Sequelize.DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + }, + { transaction }, + ); + + await queryInterface.addIndex( + 'campus_attendance_summaries', + ['organizationId', 'campus_key', 'attendance_date'], + { + unique: true, + name: 'campus_attendance_summaries_org_campus_date_unique', + transaction, + }, + ); + + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + }, + + async down(queryInterface, Sequelize) { + const transaction = await queryInterface.sequelize.transaction(); + + try { + const rows = await queryInterface.sequelize.query( + "SELECT to_regclass('public.campus_attendance_summaries') AS regclass_name;", + { + transaction, + type: Sequelize.QueryTypes.SELECT, + }, + ); + + if (!rows[0].regclass_name) { + await transaction.commit(); + return; + } + + await queryInterface.dropTable('campus_attendance_summaries', { transaction }); + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + }, +}; diff --git a/backend/src/db/migrations/20260608008000-create-staff-attendance-records.js b/backend/src/db/migrations/20260608008000-create-staff-attendance-records.js new file mode 100644 index 0000000..38ba791 --- /dev/null +++ b/backend/src/db/migrations/20260608008000-create-staff-attendance-records.js @@ -0,0 +1,170 @@ +const { STAFF_ATTENDANCE_STATUSES } = require('../../constants/staff-attendance'); + +module.exports = { + async up(queryInterface, Sequelize) { + const transaction = await queryInterface.sequelize.transaction(); + + try { + const rows = await queryInterface.sequelize.query( + "SELECT to_regclass('public.staff_attendance_records') AS regclass_name;", + { + transaction, + type: Sequelize.QueryTypes.SELECT, + }, + ); + + if (rows[0].regclass_name) { + await transaction.commit(); + return; + } + + await queryInterface.createTable( + 'staff_attendance_records', + { + id: { + type: Sequelize.DataTypes.UUID, + defaultValue: Sequelize.DataTypes.UUIDV4, + primaryKey: true, + }, + attendance_date: { + type: Sequelize.DataTypes.DATEONLY, + allowNull: false, + }, + status: { + type: Sequelize.DataTypes.ENUM(...Object.values(STAFF_ATTENDANCE_STATUSES)), + allowNull: false, + }, + note: { + type: Sequelize.DataTypes.TEXT, + allowNull: true, + }, + user_name: { + type: Sequelize.DataTypes.TEXT, + allowNull: false, + }, + user_role: { + type: Sequelize.DataTypes.TEXT, + allowNull: true, + }, + organizationId: { + type: Sequelize.DataTypes.UUID, + allowNull: false, + references: { + key: 'id', + model: 'organizations', + }, + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }, + campusId: { + type: Sequelize.DataTypes.UUID, + allowNull: true, + references: { + key: 'id', + model: 'campuses', + }, + onDelete: 'SET NULL', + onUpdate: 'CASCADE', + }, + userId: { + type: Sequelize.DataTypes.UUID, + allowNull: false, + references: { + key: 'id', + model: 'users', + }, + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }, + createdById: { + type: Sequelize.DataTypes.UUID, + allowNull: true, + references: { + key: 'id', + model: 'users', + }, + onDelete: 'SET NULL', + onUpdate: 'CASCADE', + }, + updatedById: { + type: Sequelize.DataTypes.UUID, + allowNull: true, + references: { + key: 'id', + model: 'users', + }, + onDelete: 'SET NULL', + onUpdate: 'CASCADE', + }, + createdAt: { + type: Sequelize.DataTypes.DATE, + allowNull: false, + }, + updatedAt: { + type: Sequelize.DataTypes.DATE, + allowNull: false, + }, + deletedAt: { + type: Sequelize.DataTypes.DATE, + allowNull: true, + }, + importHash: { + type: Sequelize.DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + }, + { transaction }, + ); + + await queryInterface.addIndex( + 'staff_attendance_records', + ['organizationId', 'campusId', 'attendance_date'], + { + name: 'staff_attendance_records_org_campus_date_idx', + transaction, + }, + ); + + await queryInterface.addIndex( + 'staff_attendance_records', + ['organizationId', 'userId', 'attendance_date'], + { + name: 'staff_attendance_records_org_user_date_idx', + transaction, + }, + ); + + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + }, + + async down(queryInterface, Sequelize) { + const transaction = await queryInterface.sequelize.transaction(); + + try { + const rows = await queryInterface.sequelize.query( + "SELECT to_regclass('public.staff_attendance_records') AS regclass_name;", + { + transaction, + type: Sequelize.QueryTypes.SELECT, + }, + ); + + if (!rows[0].regclass_name) { + await transaction.commit(); + return; + } + + await queryInterface.dropTable('staff_attendance_records', { transaction }); + await queryInterface.sequelize.query('DROP TYPE IF EXISTS "enum_staff_attendance_records_status";', { transaction }); + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + }, +}; diff --git a/backend/src/db/migrations/20260608009000-create-auth-refresh-tokens.js b/backend/src/db/migrations/20260608009000-create-auth-refresh-tokens.js new file mode 100644 index 0000000..9c23ac5 --- /dev/null +++ b/backend/src/db/migrations/20260608009000-create-auth-refresh-tokens.js @@ -0,0 +1,148 @@ +module.exports = { + async up(queryInterface, Sequelize) { + const transaction = await queryInterface.sequelize.transaction(); + + try { + const rows = await queryInterface.sequelize.query( + "SELECT to_regclass('public.auth_refresh_tokens') AS regclass_name;", + { + transaction, + type: Sequelize.QueryTypes.SELECT, + }, + ); + + if (rows[0].regclass_name) { + await transaction.commit(); + return; + } + + await queryInterface.createTable( + 'auth_refresh_tokens', + { + id: { + type: Sequelize.DataTypes.UUID, + defaultValue: Sequelize.DataTypes.UUIDV4, + primaryKey: true, + }, + userId: { + type: Sequelize.DataTypes.UUID, + allowNull: false, + references: { + key: 'id', + model: 'users', + }, + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }, + organizationId: { + type: Sequelize.DataTypes.UUID, + allowNull: true, + references: { + key: 'id', + model: 'organizations', + }, + onDelete: 'SET NULL', + onUpdate: 'CASCADE', + }, + tokenHash: { + type: Sequelize.DataTypes.TEXT, + allowNull: false, + }, + familyId: { + type: Sequelize.DataTypes.UUID, + allowNull: false, + }, + previousTokenId: { + type: Sequelize.DataTypes.UUID, + allowNull: true, + }, + userAgent: { + type: Sequelize.DataTypes.TEXT, + allowNull: true, + }, + ipAddress: { + type: Sequelize.DataTypes.TEXT, + allowNull: true, + }, + expiresAt: { + type: Sequelize.DataTypes.DATE, + allowNull: false, + }, + revokedAt: { + type: Sequelize.DataTypes.DATE, + allowNull: true, + }, + replacedByTokenId: { + type: Sequelize.DataTypes.UUID, + allowNull: true, + }, + createdAt: { + type: Sequelize.DataTypes.DATE, + allowNull: false, + }, + updatedAt: { + type: Sequelize.DataTypes.DATE, + allowNull: false, + }, + }, + { transaction }, + ); + + await queryInterface.addIndex( + 'auth_refresh_tokens', + ['tokenHash'], + { + name: 'auth_refresh_tokens_token_hash_idx', + transaction, + unique: true, + }, + ); + await queryInterface.addIndex( + 'auth_refresh_tokens', + ['familyId'], + { + name: 'auth_refresh_tokens_family_id_idx', + transaction, + }, + ); + await queryInterface.addIndex( + 'auth_refresh_tokens', + ['userId'], + { + name: 'auth_refresh_tokens_user_id_idx', + transaction, + }, + ); + + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + }, + + async down(queryInterface, Sequelize) { + const transaction = await queryInterface.sequelize.transaction(); + + try { + const rows = await queryInterface.sequelize.query( + "SELECT to_regclass('public.auth_refresh_tokens') AS regclass_name;", + { + transaction, + type: Sequelize.QueryTypes.SELECT, + }, + ); + + if (!rows[0].regclass_name) { + await transaction.commit(); + return; + } + + await queryInterface.dropTable('auth_refresh_tokens', { transaction }); + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + }, +}; diff --git a/backend/src/db/migrations/20260608101000-add-campus-branding-fields.js b/backend/src/db/migrations/20260608101000-add-campus-branding-fields.js new file mode 100644 index 0000000..3e16f50 --- /dev/null +++ b/backend/src/db/migrations/20260608101000-add-campus-branding-fields.js @@ -0,0 +1,58 @@ +'use strict'; + +const CAMPUS_BRANDING_COLUMNS = Object.freeze({ + mascot: { + type: 'TEXT', + allowNull: true, + }, + color: { + type: 'TEXT', + allowNull: true, + }, + bgGradient: { + type: 'TEXT', + allowNull: true, + }, + borderColor: { + type: 'TEXT', + allowNull: true, + }, + textColor: { + type: 'TEXT', + allowNull: true, + }, + bgLight: { + type: 'TEXT', + allowNull: true, + }, + description: { + type: 'TEXT', + allowNull: true, + }, + isOnline: { + type: 'BOOLEAN', + allowNull: false, + defaultValue: false, + }, +}); + +function toColumnDefinition(Sequelize, definition) { + return { + ...definition, + type: Sequelize.DataTypes[definition.type], + }; +} + +module.exports = { + up: async (queryInterface, Sequelize) => { + await Promise.all(Object.entries(CAMPUS_BRANDING_COLUMNS).map(([columnName, definition]) => ( + queryInterface.addColumn('campuses', columnName, toColumnDefinition(Sequelize, definition)) + ))); + }, + + down: async (queryInterface) => { + await Promise.all(Object.keys(CAMPUS_BRANDING_COLUMNS).map((columnName) => ( + queryInterface.removeColumn('campuses', columnName) + ))); + }, +}; diff --git a/backend/src/db/migrations/20260608102000-create-content-catalog.js b/backend/src/db/migrations/20260608102000-create-content-catalog.js new file mode 100644 index 0000000..bdbda54 --- /dev/null +++ b/backend/src/db/migrations/20260608102000-create-content-catalog.js @@ -0,0 +1,53 @@ +'use strict'; + +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.createTable('content_catalog', { + id: { + type: Sequelize.DataTypes.UUID, + defaultValue: Sequelize.DataTypes.UUIDV4, + primaryKey: true, + }, + content_type: { + type: Sequelize.DataTypes.TEXT, + allowNull: false, + unique: true, + }, + payload: { + type: Sequelize.DataTypes.JSONB, + allowNull: false, + }, + active: { + type: Sequelize.DataTypes.BOOLEAN, + allowNull: false, + defaultValue: true, + }, + createdAt: { + type: Sequelize.DataTypes.DATE, + allowNull: false, + }, + updatedAt: { + type: Sequelize.DataTypes.DATE, + allowNull: false, + }, + deletedAt: { + type: Sequelize.DataTypes.DATE, + allowNull: true, + }, + importHash: { + type: Sequelize.DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + }); + + await queryInterface.addIndex('content_catalog', ['content_type'], { + name: 'content_catalog_content_type_idx', + unique: true, + }); + }, + + async down(queryInterface) { + await queryInterface.dropTable('content_catalog'); + }, +}; diff --git a/backend/src/db/models/auth_refresh_tokens.js b/backend/src/db/models/auth_refresh_tokens.js new file mode 100644 index 0000000..2002d48 --- /dev/null +++ b/backend/src/db/models/auth_refresh_tokens.js @@ -0,0 +1,77 @@ +module.exports = function(sequelize, DataTypes) { + const auth_refresh_tokens = sequelize.define( + 'auth_refresh_tokens', + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + userId: { + type: DataTypes.UUID, + allowNull: false, + }, + organizationId: { + type: DataTypes.UUID, + allowNull: true, + }, + tokenHash: { + type: DataTypes.TEXT, + allowNull: false, + unique: true, + }, + familyId: { + type: DataTypes.UUID, + allowNull: false, + }, + previousTokenId: { + type: DataTypes.UUID, + allowNull: true, + }, + userAgent: { + type: DataTypes.TEXT, + allowNull: true, + }, + ipAddress: { + type: DataTypes.TEXT, + allowNull: true, + }, + expiresAt: { + type: DataTypes.DATE, + allowNull: false, + }, + revokedAt: { + type: DataTypes.DATE, + allowNull: true, + }, + replacedByTokenId: { + type: DataTypes.UUID, + allowNull: true, + }, + }, + { + timestamps: true, + freezeTableName: true, + }, + ); + + auth_refresh_tokens.associate = (db) => { + db.auth_refresh_tokens.belongsTo(db.users, { + as: 'user', + foreignKey: { + name: 'userId', + }, + constraints: false, + }); + + db.auth_refresh_tokens.belongsTo(db.organizations, { + as: 'organization', + foreignKey: { + name: 'organizationId', + }, + constraints: false, + }); + }; + + return auth_refresh_tokens; +}; diff --git a/backend/src/db/models/campus_attendance_config.js b/backend/src/db/models/campus_attendance_config.js new file mode 100644 index 0000000..be233cd --- /dev/null +++ b/backend/src/db/models/campus_attendance_config.js @@ -0,0 +1,62 @@ +module.exports = function(sequelize, DataTypes) { + const campus_attendance_config = sequelize.define( + 'campus_attendance_config', + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + campus_key: { + type: DataTypes.TEXT, + allowNull: false, + }, + attendance_link: { + type: DataTypes.TEXT, + allowNull: true, + }, + updated_by_label: { + type: DataTypes.TEXT, + allowNull: true, + }, + importHash: { + type: DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + }, + { + timestamps: true, + paranoid: true, + freezeTableName: true, + }, + ); + + campus_attendance_config.associate = (db) => { + db.campus_attendance_config.belongsTo(db.organizations, { + as: 'organization', + foreignKey: { + name: 'organizationId', + }, + constraints: false, + }); + + db.campus_attendance_config.belongsTo(db.campuses, { + as: 'campus', + foreignKey: { + name: 'campusId', + }, + constraints: false, + }); + + db.campus_attendance_config.belongsTo(db.users, { + as: 'createdBy', + }); + + db.campus_attendance_config.belongsTo(db.users, { + as: 'updatedBy', + }); + }; + + return campus_attendance_config; +}; diff --git a/backend/src/db/models/campus_attendance_summaries.js b/backend/src/db/models/campus_attendance_summaries.js new file mode 100644 index 0000000..810281b --- /dev/null +++ b/backend/src/db/models/campus_attendance_summaries.js @@ -0,0 +1,87 @@ +module.exports = function(sequelize, DataTypes) { + const campus_attendance_summaries = sequelize.define( + 'campus_attendance_summaries', + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + campus_key: { + type: DataTypes.TEXT, + allowNull: false, + }, + attendance_date: { + type: DataTypes.DATEONLY, + allowNull: false, + }, + total_enrolled: { + type: DataTypes.INTEGER, + allowNull: false, + }, + total_present: { + type: DataTypes.INTEGER, + allowNull: false, + }, + total_absent: { + type: DataTypes.INTEGER, + allowNull: false, + }, + total_tardy: { + type: DataTypes.INTEGER, + allowNull: false, + defaultValue: 0, + }, + attendance_percentage: { + type: DataTypes.DECIMAL(5, 2), + allowNull: false, + }, + recorded_by_label: { + type: DataTypes.TEXT, + allowNull: true, + }, + notes: { + type: DataTypes.TEXT, + allowNull: true, + }, + importHash: { + type: DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + }, + { + timestamps: true, + paranoid: true, + freezeTableName: true, + }, + ); + + campus_attendance_summaries.associate = (db) => { + db.campus_attendance_summaries.belongsTo(db.organizations, { + as: 'organization', + foreignKey: { + name: 'organizationId', + }, + constraints: false, + }); + + db.campus_attendance_summaries.belongsTo(db.campuses, { + as: 'campus', + foreignKey: { + name: 'campusId', + }, + constraints: false, + }); + + db.campus_attendance_summaries.belongsTo(db.users, { + as: 'createdBy', + }); + + db.campus_attendance_summaries.belongsTo(db.users, { + as: 'updatedBy', + }); + }; + + return campus_attendance_summaries; +}; diff --git a/backend/src/db/models/campuses.js b/backend/src/db/models/campuses.js index cd00e99..fa3ea58 100644 --- a/backend/src/db/models/campuses.js +++ b/backend/src/db/models/campuses.js @@ -47,6 +47,64 @@ email: { + }, + +mascot: { + type: DataTypes.TEXT, + + + + }, + +color: { + type: DataTypes.TEXT, + + + + }, + +bgGradient: { + type: DataTypes.TEXT, + + + + }, + +borderColor: { + type: DataTypes.TEXT, + + + + }, + +textColor: { + type: DataTypes.TEXT, + + + + }, + +bgLight: { + type: DataTypes.TEXT, + + + + }, + +description: { + type: DataTypes.TEXT, + + + + }, + +isOnline: { + type: DataTypes.BOOLEAN, + + allowNull: false, + defaultValue: false, + + }, active: { @@ -198,4 +256,3 @@ active: { return campuses; }; - diff --git a/backend/src/db/models/communication_events.js b/backend/src/db/models/communication_events.js new file mode 100644 index 0000000..20ba340 --- /dev/null +++ b/backend/src/db/models/communication_events.js @@ -0,0 +1,67 @@ +module.exports = function(sequelize, DataTypes) { + const communication_events = sequelize.define( + 'communication_events', + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + title: { + type: DataTypes.TEXT, + allowNull: false, + }, + event_date: { + type: DataTypes.DATEONLY, + allowNull: false, + }, + event_type: { + type: DataTypes.TEXT, + allowNull: false, + }, + roles: { + type: DataTypes.JSONB, + allowNull: false, + defaultValue: [], + }, + importHash: { + type: DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + }, + { + timestamps: true, + paranoid: true, + freezeTableName: true, + }, + ); + + communication_events.associate = (db) => { + db.communication_events.belongsTo(db.organizations, { + as: 'organization', + foreignKey: { + name: 'organizationId', + }, + constraints: false, + }); + + db.communication_events.belongsTo(db.campuses, { + as: 'campus', + foreignKey: { + name: 'campusId', + }, + constraints: false, + }); + + db.communication_events.belongsTo(db.users, { + as: 'createdBy', + }); + + db.communication_events.belongsTo(db.users, { + as: 'updatedBy', + }); + }; + + return communication_events; +}; diff --git a/backend/src/db/models/content_catalog.js b/backend/src/db/models/content_catalog.js new file mode 100644 index 0000000..95816d4 --- /dev/null +++ b/backend/src/db/models/content_catalog.js @@ -0,0 +1,38 @@ +module.exports = function(sequelize, DataTypes) { + const content_catalog = sequelize.define( + 'content_catalog', + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + content_type: { + type: DataTypes.TEXT, + allowNull: false, + unique: true, + }, + payload: { + type: DataTypes.JSONB, + allowNull: false, + }, + active: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: true, + }, + importHash: { + type: DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + }, + { + timestamps: true, + paranoid: true, + freezeTableName: true, + }, + ); + + return content_catalog; +}; diff --git a/backend/src/db/models/frame_entries.js b/backend/src/db/models/frame_entries.js new file mode 100644 index 0000000..bf51c2e --- /dev/null +++ b/backend/src/db/models/frame_entries.js @@ -0,0 +1,82 @@ +module.exports = function(sequelize, DataTypes) { + const frame_entries = sequelize.define( + 'frame_entries', + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + week_of: { + type: DataTypes.TEXT, + allowNull: false, + }, + posted_date: { + type: DataTypes.TEXT, + allowNull: false, + }, + formal: { + type: DataTypes.TEXT, + allowNull: false, + }, + recognition: { + type: DataTypes.TEXT, + allowNull: false, + }, + application: { + type: DataTypes.TEXT, + allowNull: false, + }, + management: { + type: DataTypes.TEXT, + allowNull: false, + }, + emotional: { + type: DataTypes.TEXT, + allowNull: false, + }, + author: { + type: DataTypes.TEXT, + allowNull: false, + }, + importHash: { + type: DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + }, + { + timestamps: true, + paranoid: true, + freezeTableName: true, + }, + ); + + frame_entries.associate = (db) => { + db.frame_entries.belongsTo(db.organizations, { + as: 'organization', + foreignKey: { + name: 'organizationId', + }, + constraints: false, + }); + + db.frame_entries.belongsTo(db.campuses, { + as: 'campus', + foreignKey: { + name: 'campusId', + }, + constraints: false, + }); + + db.frame_entries.belongsTo(db.users, { + as: 'createdBy', + }); + + db.frame_entries.belongsTo(db.users, { + as: 'updatedBy', + }); + }; + + return frame_entries; +}; diff --git a/backend/src/db/models/personality_quiz_results.js b/backend/src/db/models/personality_quiz_results.js new file mode 100644 index 0000000..8dc1abc --- /dev/null +++ b/backend/src/db/models/personality_quiz_results.js @@ -0,0 +1,70 @@ +module.exports = function(sequelize, DataTypes) { + const personality_quiz_results = sequelize.define( + 'personality_quiz_results', + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + personality_type: { + type: DataTypes.TEXT, + allowNull: false, + }, + quiz_answers: { + type: DataTypes.JSONB, + allowNull: false, + }, + completed_at: { + type: DataTypes.DATE, + allowNull: false, + }, + importHash: { + type: DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + }, + { + timestamps: true, + paranoid: true, + freezeTableName: true, + }, + ); + + personality_quiz_results.associate = (db) => { + db.personality_quiz_results.belongsTo(db.organizations, { + as: 'organization', + foreignKey: { + name: 'organizationId', + }, + constraints: false, + }); + + db.personality_quiz_results.belongsTo(db.campuses, { + as: 'campus', + foreignKey: { + name: 'campusId', + }, + constraints: false, + }); + + db.personality_quiz_results.belongsTo(db.users, { + as: 'user', + foreignKey: { + name: 'userId', + }, + constraints: false, + }); + + db.personality_quiz_results.belongsTo(db.users, { + as: 'createdBy', + }); + + db.personality_quiz_results.belongsTo(db.users, { + as: 'updatedBy', + }); + }; + + return personality_quiz_results; +}; diff --git a/backend/src/db/models/safety_quiz_results.js b/backend/src/db/models/safety_quiz_results.js new file mode 100644 index 0000000..54d4dcc --- /dev/null +++ b/backend/src/db/models/safety_quiz_results.js @@ -0,0 +1,94 @@ +module.exports = function(sequelize, DataTypes) { + const safety_quiz_results = sequelize.define( + 'safety_quiz_results', + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + quiz_id: { + type: DataTypes.TEXT, + allowNull: false, + }, + quiz_title: { + type: DataTypes.TEXT, + allowNull: false, + }, + week_of: { + type: DataTypes.TEXT, + allowNull: false, + }, + score: { + type: DataTypes.INTEGER, + allowNull: false, + }, + total_questions: { + type: DataTypes.INTEGER, + allowNull: false, + }, + answers: { + type: DataTypes.JSONB, + allowNull: false, + }, + user_name: { + type: DataTypes.TEXT, + allowNull: false, + }, + user_role: { + type: DataTypes.TEXT, + allowNull: false, + }, + completed_at: { + type: DataTypes.DATE, + allowNull: false, + }, + importHash: { + type: DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + }, + { + timestamps: true, + paranoid: true, + freezeTableName: true, + }, + ); + + safety_quiz_results.associate = (db) => { + safety_quiz_results.belongsTo(db.organizations, { + as: 'organization', + foreignKey: { + name: 'organizationId', + }, + constraints: false, + }); + + safety_quiz_results.belongsTo(db.campuses, { + as: 'campus', + foreignKey: { + name: 'campusId', + }, + constraints: false, + }); + + safety_quiz_results.belongsTo(db.users, { + as: 'user', + foreignKey: { + name: 'userId', + }, + constraints: false, + }); + + safety_quiz_results.belongsTo(db.users, { + as: 'createdBy', + }); + + safety_quiz_results.belongsTo(db.users, { + as: 'updatedBy', + }); + }; + + return safety_quiz_results; +}; diff --git a/backend/src/db/models/staff_attendance_records.js b/backend/src/db/models/staff_attendance_records.js new file mode 100644 index 0000000..818b2c5 --- /dev/null +++ b/backend/src/db/models/staff_attendance_records.js @@ -0,0 +1,81 @@ +const { STAFF_ATTENDANCE_STATUSES } = require('../../constants/staff-attendance'); + +module.exports = function(sequelize, DataTypes) { + const staff_attendance_records = sequelize.define( + 'staff_attendance_records', + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + attendance_date: { + type: DataTypes.DATEONLY, + allowNull: false, + }, + status: { + type: DataTypes.ENUM, + values: Object.values(STAFF_ATTENDANCE_STATUSES), + allowNull: false, + }, + note: { + type: DataTypes.TEXT, + allowNull: true, + }, + user_name: { + type: DataTypes.TEXT, + allowNull: false, + }, + user_role: { + type: DataTypes.TEXT, + allowNull: true, + }, + importHash: { + type: DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + }, + { + timestamps: true, + paranoid: true, + freezeTableName: true, + }, + ); + + staff_attendance_records.associate = (db) => { + db.staff_attendance_records.belongsTo(db.organizations, { + as: 'organization', + foreignKey: { + name: 'organizationId', + }, + constraints: false, + }); + + db.staff_attendance_records.belongsTo(db.campuses, { + as: 'campus', + foreignKey: { + name: 'campusId', + }, + constraints: false, + }); + + db.staff_attendance_records.belongsTo(db.users, { + as: 'user', + foreignKey: { + name: 'userId', + }, + constraints: false, + }); + + db.staff_attendance_records.belongsTo(db.users, { + as: 'createdBy', + }); + + db.staff_attendance_records.belongsTo(db.users, { + as: 'updatedBy', + }); + }; + + return staff_attendance_records; +}; diff --git a/backend/src/db/models/user_progress.js b/backend/src/db/models/user_progress.js new file mode 100644 index 0000000..c7b91f7 --- /dev/null +++ b/backend/src/db/models/user_progress.js @@ -0,0 +1,78 @@ +module.exports = function(sequelize, DataTypes) { + const user_progress = sequelize.define( + 'user_progress', + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + progress_type: { + type: DataTypes.TEXT, + allowNull: false, + }, + item_id: { + type: DataTypes.TEXT, + allowNull: false, + }, + value: { + type: DataTypes.TEXT, + allowNull: true, + }, + score: { + type: DataTypes.INTEGER, + allowNull: true, + }, + metadata: { + type: DataTypes.JSONB, + allowNull: true, + }, + importHash: { + type: DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + }, + { + timestamps: true, + paranoid: true, + freezeTableName: true, + }, + ); + + user_progress.associate = (db) => { + db.user_progress.belongsTo(db.organizations, { + as: 'organization', + foreignKey: { + name: 'organizationId', + }, + constraints: false, + }); + + db.user_progress.belongsTo(db.campuses, { + as: 'campus', + foreignKey: { + name: 'campusId', + }, + constraints: false, + }); + + db.user_progress.belongsTo(db.users, { + as: 'user', + foreignKey: { + name: 'userId', + }, + constraints: false, + }); + + db.user_progress.belongsTo(db.users, { + as: 'createdBy', + }); + + db.user_progress.belongsTo(db.users, { + as: 'updatedBy', + }); + }; + + return user_progress; +}; diff --git a/backend/src/db/models/walkthrough_checkins.js b/backend/src/db/models/walkthrough_checkins.js new file mode 100644 index 0000000..98efed6 --- /dev/null +++ b/backend/src/db/models/walkthrough_checkins.js @@ -0,0 +1,130 @@ +module.exports = function(sequelize, DataTypes) { + const walkthrough_checkins = sequelize.define( + 'walkthrough_checkins', + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + teacher_name: { + type: DataTypes.TEXT, + allowNull: false, + }, + classroom: { + type: DataTypes.TEXT, + allowNull: false, + }, + director_name: { + type: DataTypes.TEXT, + allowNull: false, + }, + check_in_date: { + type: DataTypes.DATEONLY, + allowNull: false, + }, + check_in_time: { + type: DataTypes.TIME, + allowNull: false, + }, + attitude_rating: { + type: DataTypes.INTEGER, + allowNull: false, + }, + attitude_comment: { + type: DataTypes.TEXT, + allowNull: true, + }, + classroom_management_rating: { + type: DataTypes.INTEGER, + allowNull: false, + }, + classroom_management_comment: { + type: DataTypes.TEXT, + allowNull: true, + }, + cleanliness_rating: { + type: DataTypes.INTEGER, + allowNull: false, + }, + cleanliness_comment: { + type: DataTypes.TEXT, + allowNull: true, + }, + vibes_rating: { + type: DataTypes.INTEGER, + allowNull: false, + }, + vibes_comment: { + type: DataTypes.TEXT, + allowNull: true, + }, + team_dynamics_rating: { + type: DataTypes.INTEGER, + allowNull: false, + }, + team_dynamics_comment: { + type: DataTypes.TEXT, + allowNull: true, + }, + emergency_exit_rating: { + type: DataTypes.INTEGER, + allowNull: false, + }, + emergency_exit_comment: { + type: DataTypes.TEXT, + allowNull: true, + }, + lesson_plan_rating: { + type: DataTypes.INTEGER, + allowNull: false, + }, + lesson_plan_comment: { + type: DataTypes.TEXT, + allowNull: true, + }, + overall_notes: { + type: DataTypes.TEXT, + allowNull: true, + }, + importHash: { + type: DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + }, + { + timestamps: true, + paranoid: true, + freezeTableName: true, + }, + ); + + walkthrough_checkins.associate = (db) => { + walkthrough_checkins.belongsTo(db.organizations, { + as: 'organization', + foreignKey: { + name: 'organizationId', + }, + constraints: false, + }); + + walkthrough_checkins.belongsTo(db.campuses, { + as: 'campus', + foreignKey: { + name: 'campusId', + }, + constraints: false, + }); + + walkthrough_checkins.belongsTo(db.users, { + as: 'createdBy', + }); + + walkthrough_checkins.belongsTo(db.users, { + as: 'updatedBy', + }); + }; + + return walkthrough_checkins; +}; diff --git a/backend/src/db/seeders/20231127130745-sample-data.js b/backend/src/db/seeders/20231127130745-sample-data.js deleted file mode 100644 index bee0999..0000000 --- a/backend/src/db/seeders/20231127130745-sample-data.js +++ /dev/null @@ -1,10508 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - -const db = require('../models'); -const Users = db.users; - - - - - - -const Organizations = db.organizations; - -const Campuses = db.campuses; - -const AcademicYears = db.academic_years; - -const Grades = db.grades; - -const Subjects = db.subjects; - -const Students = db.students; - -const Guardians = db.guardians; - -const Staff = db.staff; - -const Classes = db.classes; - -const ClassEnrollments = db.class_enrollments; - -const ClassSubjects = db.class_subjects; - -const Timetables = db.timetables; - -const TimetablePeriods = db.timetable_periods; - -const AttendanceSessions = db.attendance_sessions; - -const AttendanceRecords = db.attendance_records; - -const FeePlans = db.fee_plans; - -const Invoices = db.invoices; - -const Payments = db.payments; - -const Assessments = db.assessments; - -const AssessmentResults = db.assessment_results; - -const Messages = db.messages; - -const MessageRecipients = db.message_recipients; - -const Documents = db.documents; - - - - - - - -const OrganizationsData = [ - - { - - - - - "name": "Alan Turing", - - - - }, - - { - - - - - "name": "Grace Hopper", - - - - }, - - { - - - - - "name": "Grace Hopper", - - - - }, - - { - - - - - "name": "Grace Hopper", - - - - }, - -]; - - - -const CampusesData = [ - - { - - - - - // type code here for "relation_one" field - - - - - - - "name": "Marie Curie", - - - - - - - "code": "Alan Turing", - - - - - - - "address": "Alan Turing", - - - - - - - "phone": "Marie Curie", - - - - - - - "email": "Grace Hopper", - - - - - - - "active": true, - - - - }, - - { - - - - - // type code here for "relation_one" field - - - - - - - "name": "Marie Curie", - - - - - - - "code": "Marie Curie", - - - - - - - "address": "Ada Lovelace", - - - - - - - "phone": "Marie Curie", - - - - - - - "email": "Ada Lovelace", - - - - - - - "active": false, - - - - }, - - { - - - - - // type code here for "relation_one" field - - - - - - - "name": "Grace Hopper", - - - - - - - "code": "Grace Hopper", - - - - - - - "address": "Grace Hopper", - - - - - - - "phone": "Ada Lovelace", - - - - - - - "email": "Alan Turing", - - - - - - - "active": false, - - - - }, - - { - - - - - // type code here for "relation_one" field - - - - - - - "name": "Grace Hopper", - - - - - - - "code": "Alan Turing", - - - - - - - "address": "Grace Hopper", - - - - - - - "phone": "Grace Hopper", - - - - - - - "email": "Marie Curie", - - - - - - - "active": true, - - - - }, - -]; - - - -const AcademicYearsData = [ - - { - - - - - // type code here for "relation_one" field - - - - - - - "name": "Alan Turing", - - - - - - - "start_date": new Date(Date.now()), - - - - - - - "end_date": new Date(Date.now()), - - - - - - - "current": true, - - - - }, - - { - - - - - // type code here for "relation_one" field - - - - - - - "name": "Grace Hopper", - - - - - - - "start_date": new Date(Date.now()), - - - - - - - "end_date": new Date(Date.now()), - - - - - - - "current": true, - - - - }, - - { - - - - - // type code here for "relation_one" field - - - - - - - "name": "Grace Hopper", - - - - - - - "start_date": new Date(Date.now()), - - - - - - - "end_date": new Date(Date.now()), - - - - - - - "current": true, - - - - }, - - { - - - - - // type code here for "relation_one" field - - - - - - - "name": "Ada Lovelace", - - - - - - - "start_date": new Date(Date.now()), - - - - - - - "end_date": new Date(Date.now()), - - - - - - - "current": true, - - - - }, - -]; - - - -const GradesData = [ - - { - - - - - // type code here for "relation_one" field - - - - - - - "name": "Grace Hopper", - - - - - - - "code": "Alan Turing", - - - - - - - "sort_order": 2, - - - - - - - "description": "Alan Turing", - - - - }, - - { - - - - - // type code here for "relation_one" field - - - - - - - "name": "Marie Curie", - - - - - - - "code": "Ada Lovelace", - - - - - - - "sort_order": 9, - - - - - - - "description": "Alan Turing", - - - - }, - - { - - - - - // type code here for "relation_one" field - - - - - - - "name": "Grace Hopper", - - - - - - - "code": "Alan Turing", - - - - - - - "sort_order": 1, - - - - - - - "description": "Alan Turing", - - - - }, - - { - - - - - // type code here for "relation_one" field - - - - - - - "name": "Grace Hopper", - - - - - - - "code": "Ada Lovelace", - - - - - - - "sort_order": 8, - - - - - - - "description": "Marie Curie", - - - - }, - -]; - - - -const SubjectsData = [ - - { - - - - - // type code here for "relation_one" field - - - - - - - "name": "Alan Turing", - - - - - - - "code": "Marie Curie", - - - - - - - "description": "Alan Turing", - - - - }, - - { - - - - - // type code here for "relation_one" field - - - - - - - "name": "Grace Hopper", - - - - - - - "code": "Grace Hopper", - - - - - - - "description": "Marie Curie", - - - - }, - - { - - - - - // type code here for "relation_one" field - - - - - - - "name": "Ada Lovelace", - - - - - - - "code": "Grace Hopper", - - - - - - - "description": "Ada Lovelace", - - - - }, - - { - - - - - // type code here for "relation_one" field - - - - - - - "name": "Grace Hopper", - - - - - - - "code": "Alan Turing", - - - - - - - "description": "Alan Turing", - - - - }, - -]; - - - -const StudentsData = [ - - { - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - "student_number": "Grace Hopper", - - - - - - - "first_name": "Alan Turing", - - - - - - - "last_name": "Grace Hopper", - - - - - - - "gender": "prefer_not_to_say", - - - - - - - "date_of_birth": new Date(Date.now()), - - - - - - - "enrollment_date": new Date(Date.now()), - - - - - - - "status": "graduated", - - - - - - - "email": "Grace Hopper", - - - - - - - "phone": "Alan Turing", - - - - - - - "address": "Alan Turing", - - - - - - - // type code here for "images" field - - - - }, - - { - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - "student_number": "Alan Turing", - - - - - - - "first_name": "Marie Curie", - - - - - - - "last_name": "Grace Hopper", - - - - - - - "gender": "male", - - - - - - - "date_of_birth": new Date(Date.now()), - - - - - - - "enrollment_date": new Date(Date.now()), - - - - - - - "status": "transferred", - - - - - - - "email": "Ada Lovelace", - - - - - - - "phone": "Ada Lovelace", - - - - - - - "address": "Alan Turing", - - - - - - - // type code here for "images" field - - - - }, - - { - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - "student_number": "Marie Curie", - - - - - - - "first_name": "Alan Turing", - - - - - - - "last_name": "Marie Curie", - - - - - - - "gender": "prefer_not_to_say", - - - - - - - "date_of_birth": new Date(Date.now()), - - - - - - - "enrollment_date": new Date(Date.now()), - - - - - - - "status": "inactive", - - - - - - - "email": "Alan Turing", - - - - - - - "phone": "Grace Hopper", - - - - - - - "address": "Alan Turing", - - - - - - - // type code here for "images" field - - - - }, - - { - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - "student_number": "Alan Turing", - - - - - - - "first_name": "Ada Lovelace", - - - - - - - "last_name": "Ada Lovelace", - - - - - - - "gender": "female", - - - - - - - "date_of_birth": new Date(Date.now()), - - - - - - - "enrollment_date": new Date(Date.now()), - - - - - - - "status": "transferred", - - - - - - - "email": "Ada Lovelace", - - - - - - - "phone": "Marie Curie", - - - - - - - "address": "Grace Hopper", - - - - - - - // type code here for "images" field - - - - }, - -]; - - - -const GuardiansData = [ - - { - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - "full_name": "Alan Turing", - - - - - - - "relationship": "guardian", - - - - - - - "phone": "Ada Lovelace", - - - - - - - "email": "Ada Lovelace", - - - - - - - "address": "Ada Lovelace", - - - - - - - "primary_contact": true, - - - - }, - - { - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - "full_name": "Grace Hopper", - - - - - - - "relationship": "other", - - - - - - - "phone": "Alan Turing", - - - - - - - "email": "Grace Hopper", - - - - - - - "address": "Alan Turing", - - - - - - - "primary_contact": true, - - - - }, - - { - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - "full_name": "Alan Turing", - - - - - - - "relationship": "father", - - - - - - - "phone": "Alan Turing", - - - - - - - "email": "Ada Lovelace", - - - - - - - "address": "Ada Lovelace", - - - - - - - "primary_contact": true, - - - - }, - - { - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - "full_name": "Marie Curie", - - - - - - - "relationship": "guardian", - - - - - - - "phone": "Alan Turing", - - - - - - - "email": "Marie Curie", - - - - - - - "address": "Alan Turing", - - - - - - - "primary_contact": true, - - - - }, - -]; - - - -const StaffData = [ - - { - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - "employee_number": "Marie Curie", - - - - - - - "job_title": "Marie Curie", - - - - - - - "staff_type": "admin", - - - - - - - "hire_date": new Date(Date.now()), - - - - - - - "status": "on_leave", - - - - - - - // type code here for "images" field - - - - }, - - { - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - "employee_number": "Marie Curie", - - - - - - - "job_title": "Grace Hopper", - - - - - - - "staff_type": "support", - - - - - - - "hire_date": new Date(Date.now()), - - - - - - - "status": "on_leave", - - - - - - - // type code here for "images" field - - - - }, - - { - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - "employee_number": "Grace Hopper", - - - - - - - "job_title": "Grace Hopper", - - - - - - - "staff_type": "support", - - - - - - - "hire_date": new Date(Date.now()), - - - - - - - "status": "active", - - - - - - - // type code here for "images" field - - - - }, - - { - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - "employee_number": "Grace Hopper", - - - - - - - "job_title": "Marie Curie", - - - - - - - "staff_type": "admin", - - - - - - - "hire_date": new Date(Date.now()), - - - - - - - "status": "inactive", - - - - - - - // type code here for "images" field - - - - }, - -]; - - - -const ClassesData = [ - - { - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - "name": "Marie Curie", - - - - - - - "section": "Marie Curie", - - - - - - - // type code here for "relation_one" field - - - - - - - "capacity": 5, - - - - - - - "status": "active", - - - - }, - - { - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - "name": "Marie Curie", - - - - - - - "section": "Marie Curie", - - - - - - - // type code here for "relation_one" field - - - - - - - "capacity": 8, - - - - - - - "status": "active", - - - - }, - - { - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - "name": "Ada Lovelace", - - - - - - - "section": "Ada Lovelace", - - - - - - - // type code here for "relation_one" field - - - - - - - "capacity": 5, - - - - - - - "status": "archived", - - - - }, - - { - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - "name": "Ada Lovelace", - - - - - - - "section": "Grace Hopper", - - - - - - - // type code here for "relation_one" field - - - - - - - "capacity": 5, - - - - - - - "status": "active", - - - - }, - -]; - - - -const ClassEnrollmentsData = [ - - { - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - "enrolled_on": new Date(Date.now()), - - - - - - - "ended_on": new Date(Date.now()), - - - - - - - "status": "active", - - - - }, - - { - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - "enrolled_on": new Date(Date.now()), - - - - - - - "ended_on": new Date(Date.now()), - - - - - - - "status": "dropped", - - - - }, - - { - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - "enrolled_on": new Date(Date.now()), - - - - - - - "ended_on": new Date(Date.now()), - - - - - - - "status": "active", - - - - }, - - { - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - "enrolled_on": new Date(Date.now()), - - - - - - - "ended_on": new Date(Date.now()), - - - - - - - "status": "dropped", - - - - }, - -]; - - - -const ClassSubjectsData = [ - - { - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - "status": "archived", - - - - }, - - { - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - "status": "active", - - - - }, - - { - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - "status": "active", - - - - }, - - { - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - "status": "archived", - - - - }, - -]; - - - -const TimetablesData = [ - - { - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - "name": "Alan Turing", - - - - - - - "effective_from": new Date(Date.now()), - - - - - - - "effective_to": new Date(Date.now()), - - - - - - - "status": "active", - - - - }, - - { - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - "name": "Ada Lovelace", - - - - - - - "effective_from": new Date(Date.now()), - - - - - - - "effective_to": new Date(Date.now()), - - - - - - - "status": "active", - - - - }, - - { - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - "name": "Grace Hopper", - - - - - - - "effective_from": new Date(Date.now()), - - - - - - - "effective_to": new Date(Date.now()), - - - - - - - "status": "archived", - - - - }, - - { - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - "name": "Grace Hopper", - - - - - - - "effective_from": new Date(Date.now()), - - - - - - - "effective_to": new Date(Date.now()), - - - - - - - "status": "active", - - - - }, - -]; - - - -const TimetablePeriodsData = [ - - { - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - "day_of_week": "wednesday", - - - - - - - "starts_at": new Date(Date.now()), - - - - - - - "ends_at": new Date(Date.now()), - - - - - - - "room": "Marie Curie", - - - - }, - - { - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - "day_of_week": "wednesday", - - - - - - - "starts_at": new Date(Date.now()), - - - - - - - "ends_at": new Date(Date.now()), - - - - - - - "room": "Marie Curie", - - - - }, - - { - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - "day_of_week": "saturday", - - - - - - - "starts_at": new Date(Date.now()), - - - - - - - "ends_at": new Date(Date.now()), - - - - - - - "room": "Grace Hopper", - - - - }, - - { - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - "day_of_week": "wednesday", - - - - - - - "starts_at": new Date(Date.now()), - - - - - - - "ends_at": new Date(Date.now()), - - - - - - - "room": "Marie Curie", - - - - }, - -]; - - - -const AttendanceSessionsData = [ - - { - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - "session_date": new Date(Date.now()), - - - - - - - "session_type": "homeroom", - - - - - - - "notes": "Grace Hopper", - - - - }, - - { - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - "session_date": new Date(Date.now()), - - - - - - - "session_type": "homeroom", - - - - - - - "notes": "Ada Lovelace", - - - - }, - - { - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - "session_date": new Date(Date.now()), - - - - - - - "session_type": "exam", - - - - - - - "notes": "Grace Hopper", - - - - }, - - { - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - "session_date": new Date(Date.now()), - - - - - - - "session_type": "event", - - - - - - - "notes": "Alan Turing", - - - - }, - -]; - - - -const AttendanceRecordsData = [ - - { - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - "status": "absent", - - - - - - - "minutes_late": 8, - - - - - - - "remarks": "Ada Lovelace", - - - - }, - - { - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - "status": "late", - - - - - - - "minutes_late": 9, - - - - - - - "remarks": "Alan Turing", - - - - }, - - { - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - "status": "excused", - - - - - - - "minutes_late": 2, - - - - - - - "remarks": "Alan Turing", - - - - }, - - { - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - "status": "absent", - - - - - - - "minutes_late": 8, - - - - - - - "remarks": "Grace Hopper", - - - - }, - -]; - - - -const FeePlansData = [ - - { - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - "name": "Grace Hopper", - - - - - - - "billing_cycle": "monthly", - - - - - - - "total_amount": 1.31, - - - - - - - "active": true, - - - - - - - "notes": "Ada Lovelace", - - - - }, - - { - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - "name": "Alan Turing", - - - - - - - "billing_cycle": "one_time", - - - - - - - "total_amount": 8.92, - - - - - - - "active": true, - - - - - - - "notes": "Marie Curie", - - - - }, - - { - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - "name": "Grace Hopper", - - - - - - - "billing_cycle": "annual", - - - - - - - "total_amount": 7.36, - - - - - - - "active": false, - - - - - - - "notes": "Grace Hopper", - - - - }, - - { - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - "name": "Marie Curie", - - - - - - - "billing_cycle": "one_time", - - - - - - - "total_amount": 5.57, - - - - - - - "active": true, - - - - - - - "notes": "Marie Curie", - - - - }, - -]; - - - -const InvoicesData = [ - - { - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - "invoice_number": "Grace Hopper", - - - - - - - "issue_date": new Date(Date.now()), - - - - - - - "due_date": new Date(Date.now()), - - - - - - - "subtotal": 1.42, - - - - - - - "discount_amount": 7.75, - - - - - - - "tax_amount": 7.96, - - - - - - - "total_amount": 6.64, - - - - - - - "balance_due": 6.13, - - - - - - - "status": "void", - - - - - - - "notes": "Alan Turing", - - - - - - - // type code here for "files" field - - - - }, - - { - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - "invoice_number": "Marie Curie", - - - - - - - "issue_date": new Date(Date.now()), - - - - - - - "due_date": new Date(Date.now()), - - - - - - - "subtotal": 1.01, - - - - - - - "discount_amount": 1.19, - - - - - - - "tax_amount": 5.93, - - - - - - - "total_amount": 5.66, - - - - - - - "balance_due": 6.53, - - - - - - - "status": "void", - - - - - - - "notes": "Alan Turing", - - - - - - - // type code here for "files" field - - - - }, - - { - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - "invoice_number": "Marie Curie", - - - - - - - "issue_date": new Date(Date.now()), - - - - - - - "due_date": new Date(Date.now()), - - - - - - - "subtotal": 6.33, - - - - - - - "discount_amount": 5.35, - - - - - - - "tax_amount": 4.66, - - - - - - - "total_amount": 9.22, - - - - - - - "balance_due": 5.79, - - - - - - - "status": "paid", - - - - - - - "notes": "Ada Lovelace", - - - - - - - // type code here for "files" field - - - - }, - - { - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - "invoice_number": "Ada Lovelace", - - - - - - - "issue_date": new Date(Date.now()), - - - - - - - "due_date": new Date(Date.now()), - - - - - - - "subtotal": 5.87, - - - - - - - "discount_amount": 4.89, - - - - - - - "tax_amount": 9.28, - - - - - - - "total_amount": 3.69, - - - - - - - "balance_due": 2.0, - - - - - - - "status": "draft", - - - - - - - "notes": "Ada Lovelace", - - - - - - - // type code here for "files" field - - - - }, - -]; - - - -const PaymentsData = [ - - { - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - "receipt_number": "Ada Lovelace", - - - - - - - "paid_at": new Date(Date.now()), - - - - - - - "amount": 2.81, - - - - - - - "method": "other", - - - - - - - "reference_code": "Alan Turing", - - - - - - - "notes": "Grace Hopper", - - - - - - - // type code here for "files" field - - - - }, - - { - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - "receipt_number": "Ada Lovelace", - - - - - - - "paid_at": new Date(Date.now()), - - - - - - - "amount": 3.29, - - - - - - - "method": "mobile_money", - - - - - - - "reference_code": "Alan Turing", - - - - - - - "notes": "Grace Hopper", - - - - - - - // type code here for "files" field - - - - }, - - { - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - "receipt_number": "Alan Turing", - - - - - - - "paid_at": new Date(Date.now()), - - - - - - - "amount": 1.69, - - - - - - - "method": "card", - - - - - - - "reference_code": "Grace Hopper", - - - - - - - "notes": "Alan Turing", - - - - - - - // type code here for "files" field - - - - }, - - { - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - "receipt_number": "Ada Lovelace", - - - - - - - "paid_at": new Date(Date.now()), - - - - - - - "amount": 5.7, - - - - - - - "method": "card", - - - - - - - "reference_code": "Marie Curie", - - - - - - - "notes": "Marie Curie", - - - - - - - // type code here for "files" field - - - - }, - -]; - - - -const AssessmentsData = [ - - { - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - "name": "Marie Curie", - - - - - - - "assessment_type": "homework", - - - - - - - "assigned_at": new Date(Date.now()), - - - - - - - "due_at": new Date(Date.now()), - - - - - - - "max_score": 2.75, - - - - - - - "status": "published", - - - - - - - "instructions": "Alan Turing", - - - - - - - // type code here for "files" field - - - - }, - - { - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - "name": "Marie Curie", - - - - - - - "assessment_type": "final", - - - - - - - "assigned_at": new Date(Date.now()), - - - - - - - "due_at": new Date(Date.now()), - - - - - - - "max_score": 5.24, - - - - - - - "status": "draft", - - - - - - - "instructions": "Alan Turing", - - - - - - - // type code here for "files" field - - - - }, - - { - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - "name": "Alan Turing", - - - - - - - "assessment_type": "midterm", - - - - - - - "assigned_at": new Date(Date.now()), - - - - - - - "due_at": new Date(Date.now()), - - - - - - - "max_score": 0.58, - - - - - - - "status": "published", - - - - - - - "instructions": "Marie Curie", - - - - - - - // type code here for "files" field - - - - }, - - { - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - "name": "Marie Curie", - - - - - - - "assessment_type": "midterm", - - - - - - - "assigned_at": new Date(Date.now()), - - - - - - - "due_at": new Date(Date.now()), - - - - - - - "max_score": 0.85, - - - - - - - "status": "published", - - - - - - - "instructions": "Alan Turing", - - - - - - - // type code here for "files" field - - - - }, - -]; - - - -const AssessmentResultsData = [ - - { - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - "score": 9.28, - - - - - - - "grade_letter": "E", - - - - - - - "remarks": "Marie Curie", - - - - }, - - { - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - "score": 7.44, - - - - - - - "grade_letter": "A", - - - - - - - "remarks": "Marie Curie", - - - - }, - - { - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - "score": 1.43, - - - - - - - "grade_letter": "F", - - - - - - - "remarks": "Grace Hopper", - - - - }, - - { - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - "score": 2.59, - - - - - - - "grade_letter": "B", - - - - - - - "remarks": "Ada Lovelace", - - - - }, - -]; - - - -const MessagesData = [ - - { - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - "subject": "Marie Curie", - - - - - - - "body": "Alan Turing", - - - - - - - "channel": "in_app", - - - - - - - "audience": "class", - - - - - - - "sent_at": new Date(Date.now()), - - - - - - - "status": "scheduled", - - - - - - - // type code here for "files" field - - - - }, - - { - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - "subject": "Grace Hopper", - - - - - - - "body": "Ada Lovelace", - - - - - - - "channel": "in_app", - - - - - - - "audience": "class", - - - - - - - "sent_at": new Date(Date.now()), - - - - - - - "status": "scheduled", - - - - - - - // type code here for "files" field - - - - }, - - { - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - "subject": "Ada Lovelace", - - - - - - - "body": "Alan Turing", - - - - - - - "channel": "sms", - - - - - - - "audience": "campus", - - - - - - - "sent_at": new Date(Date.now()), - - - - - - - "status": "sent", - - - - - - - // type code here for "files" field - - - - }, - - { - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - "subject": "Marie Curie", - - - - - - - "body": "Alan Turing", - - - - - - - "channel": "sms", - - - - - - - "audience": "all_org", - - - - - - - "sent_at": new Date(Date.now()), - - - - - - - "status": "failed", - - - - - - - // type code here for "files" field - - - - }, - -]; - - - -const MessageRecipientsData = [ - - { - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - "recipient_type": "user", - - - - - - - "recipient_label": "Grace Hopper", - - - - - - - "destination": "Alan Turing", - - - - - - - "delivery_status": "pending", - - - - - - - "delivered_at": new Date(Date.now()), - - - - - - - "read_at": new Date(Date.now()), - - - - }, - - { - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - "recipient_type": "guardian", - - - - - - - "recipient_label": "Alan Turing", - - - - - - - "destination": "Grace Hopper", - - - - - - - "delivery_status": "delivered", - - - - - - - "delivered_at": new Date(Date.now()), - - - - - - - "read_at": new Date(Date.now()), - - - - }, - - { - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - "recipient_type": "student", - - - - - - - "recipient_label": "Marie Curie", - - - - - - - "destination": "Marie Curie", - - - - - - - "delivery_status": "read", - - - - - - - "delivered_at": new Date(Date.now()), - - - - - - - "read_at": new Date(Date.now()), - - - - }, - - { - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - "recipient_type": "guardian", - - - - - - - "recipient_label": "Ada Lovelace", - - - - - - - "destination": "Ada Lovelace", - - - - - - - "delivery_status": "sent", - - - - - - - "delivered_at": new Date(Date.now()), - - - - - - - "read_at": new Date(Date.now()), - - - - }, - -]; - - - -const DocumentsData = [ - - { - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - "entity_type": "campus", - - - - - - - "entity_reference": "Marie Curie", - - - - - - - "name": "Marie Curie", - - - - - - - "category": "receipt", - - - - - - - // type code here for "files" field - - - - - - - "uploaded_at": new Date(Date.now()), - - - - - - - "notes": "Grace Hopper", - - - - }, - - { - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - "entity_type": "student", - - - - - - - "entity_reference": "Grace Hopper", - - - - - - - "name": "Ada Lovelace", - - - - - - - "category": "id", - - - - - - - // type code here for "files" field - - - - - - - "uploaded_at": new Date(Date.now()), - - - - - - - "notes": "Marie Curie", - - - - }, - - { - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - "entity_type": "staff", - - - - - - - "entity_reference": "Ada Lovelace", - - - - - - - "name": "Alan Turing", - - - - - - - "category": "medical", - - - - - - - // type code here for "files" field - - - - - - - "uploaded_at": new Date(Date.now()), - - - - - - - "notes": "Grace Hopper", - - - - }, - - { - - - - - // type code here for "relation_one" field - - - - - - - // type code here for "relation_one" field - - - - - - - "entity_type": "organization", - - - - - - - "entity_reference": "Alan Turing", - - - - - - - "name": "Grace Hopper", - - - - - - - "category": "receipt", - - - - - - - // type code here for "files" field - - - - - - - "uploaded_at": new Date(Date.now()), - - - - - - - "notes": "Grace Hopper", - - - - }, - -]; - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - // Similar logic for "relation_many" - - - - - async function associateUserWithOrganization() { - - const relatedOrganization0 = await Organizations.findOne({ - offset: Math.floor(Math.random() * (await Organizations.count())), - }); - const User0 = await Users.findOne({ - order: [['id', 'ASC']], - offset: 0 - }); - if (User0?.setOrganization) - { - await - User0. - setOrganization(relatedOrganization0); - } - - const relatedOrganization1 = await Organizations.findOne({ - offset: Math.floor(Math.random() * (await Organizations.count())), - }); - const User1 = await Users.findOne({ - order: [['id', 'ASC']], - offset: 1 - }); - if (User1?.setOrganization) - { - await - User1. - setOrganization(relatedOrganization1); - } - - const relatedOrganization2 = await Organizations.findOne({ - offset: Math.floor(Math.random() * (await Organizations.count())), - }); - const User2 = await Users.findOne({ - order: [['id', 'ASC']], - offset: 2 - }); - if (User2?.setOrganization) - { - await - User2. - setOrganization(relatedOrganization2); - } - - const relatedOrganization3 = await Organizations.findOne({ - offset: Math.floor(Math.random() * (await Organizations.count())), - }); - const User3 = await Users.findOne({ - order: [['id', 'ASC']], - offset: 3 - }); - if (User3?.setOrganization) - { - await - User3. - setOrganization(relatedOrganization3); - } - - } - - - - - - - - - - - - - - - - - async function associateCampusWithOrganization() { - - const relatedOrganization0 = await Organizations.findOne({ - offset: Math.floor(Math.random() * (await Organizations.count())), - }); - const Campus0 = await Campuses.findOne({ - order: [['id', 'ASC']], - offset: 0 - }); - if (Campus0?.setOrganization) - { - await - Campus0. - setOrganization(relatedOrganization0); - } - - const relatedOrganization1 = await Organizations.findOne({ - offset: Math.floor(Math.random() * (await Organizations.count())), - }); - const Campus1 = await Campuses.findOne({ - order: [['id', 'ASC']], - offset: 1 - }); - if (Campus1?.setOrganization) - { - await - Campus1. - setOrganization(relatedOrganization1); - } - - const relatedOrganization2 = await Organizations.findOne({ - offset: Math.floor(Math.random() * (await Organizations.count())), - }); - const Campus2 = await Campuses.findOne({ - order: [['id', 'ASC']], - offset: 2 - }); - if (Campus2?.setOrganization) - { - await - Campus2. - setOrganization(relatedOrganization2); - } - - const relatedOrganization3 = await Organizations.findOne({ - offset: Math.floor(Math.random() * (await Organizations.count())), - }); - const Campus3 = await Campuses.findOne({ - order: [['id', 'ASC']], - offset: 3 - }); - if (Campus3?.setOrganization) - { - await - Campus3. - setOrganization(relatedOrganization3); - } - - } - - - - - - - - - - - - - - - - - - - - - async function associateAcademicYearWithOrganization() { - - const relatedOrganization0 = await Organizations.findOne({ - offset: Math.floor(Math.random() * (await Organizations.count())), - }); - const AcademicYear0 = await AcademicYears.findOne({ - order: [['id', 'ASC']], - offset: 0 - }); - if (AcademicYear0?.setOrganization) - { - await - AcademicYear0. - setOrganization(relatedOrganization0); - } - - const relatedOrganization1 = await Organizations.findOne({ - offset: Math.floor(Math.random() * (await Organizations.count())), - }); - const AcademicYear1 = await AcademicYears.findOne({ - order: [['id', 'ASC']], - offset: 1 - }); - if (AcademicYear1?.setOrganization) - { - await - AcademicYear1. - setOrganization(relatedOrganization1); - } - - const relatedOrganization2 = await Organizations.findOne({ - offset: Math.floor(Math.random() * (await Organizations.count())), - }); - const AcademicYear2 = await AcademicYears.findOne({ - order: [['id', 'ASC']], - offset: 2 - }); - if (AcademicYear2?.setOrganization) - { - await - AcademicYear2. - setOrganization(relatedOrganization2); - } - - const relatedOrganization3 = await Organizations.findOne({ - offset: Math.floor(Math.random() * (await Organizations.count())), - }); - const AcademicYear3 = await AcademicYears.findOne({ - order: [['id', 'ASC']], - offset: 3 - }); - if (AcademicYear3?.setOrganization) - { - await - AcademicYear3. - setOrganization(relatedOrganization3); - } - - } - - - - - - - - - - - - - - - - - async function associateGradeWithOrganization() { - - const relatedOrganization0 = await Organizations.findOne({ - offset: Math.floor(Math.random() * (await Organizations.count())), - }); - const Grade0 = await Grades.findOne({ - order: [['id', 'ASC']], - offset: 0 - }); - if (Grade0?.setOrganization) - { - await - Grade0. - setOrganization(relatedOrganization0); - } - - const relatedOrganization1 = await Organizations.findOne({ - offset: Math.floor(Math.random() * (await Organizations.count())), - }); - const Grade1 = await Grades.findOne({ - order: [['id', 'ASC']], - offset: 1 - }); - if (Grade1?.setOrganization) - { - await - Grade1. - setOrganization(relatedOrganization1); - } - - const relatedOrganization2 = await Organizations.findOne({ - offset: Math.floor(Math.random() * (await Organizations.count())), - }); - const Grade2 = await Grades.findOne({ - order: [['id', 'ASC']], - offset: 2 - }); - if (Grade2?.setOrganization) - { - await - Grade2. - setOrganization(relatedOrganization2); - } - - const relatedOrganization3 = await Organizations.findOne({ - offset: Math.floor(Math.random() * (await Organizations.count())), - }); - const Grade3 = await Grades.findOne({ - order: [['id', 'ASC']], - offset: 3 - }); - if (Grade3?.setOrganization) - { - await - Grade3. - setOrganization(relatedOrganization3); - } - - } - - - - - - - - - - - - - - - - - async function associateSubjectWithOrganization() { - - const relatedOrganization0 = await Organizations.findOne({ - offset: Math.floor(Math.random() * (await Organizations.count())), - }); - const Subject0 = await Subjects.findOne({ - order: [['id', 'ASC']], - offset: 0 - }); - if (Subject0?.setOrganization) - { - await - Subject0. - setOrganization(relatedOrganization0); - } - - const relatedOrganization1 = await Organizations.findOne({ - offset: Math.floor(Math.random() * (await Organizations.count())), - }); - const Subject1 = await Subjects.findOne({ - order: [['id', 'ASC']], - offset: 1 - }); - if (Subject1?.setOrganization) - { - await - Subject1. - setOrganization(relatedOrganization1); - } - - const relatedOrganization2 = await Organizations.findOne({ - offset: Math.floor(Math.random() * (await Organizations.count())), - }); - const Subject2 = await Subjects.findOne({ - order: [['id', 'ASC']], - offset: 2 - }); - if (Subject2?.setOrganization) - { - await - Subject2. - setOrganization(relatedOrganization2); - } - - const relatedOrganization3 = await Organizations.findOne({ - offset: Math.floor(Math.random() * (await Organizations.count())), - }); - const Subject3 = await Subjects.findOne({ - order: [['id', 'ASC']], - offset: 3 - }); - if (Subject3?.setOrganization) - { - await - Subject3. - setOrganization(relatedOrganization3); - } - - } - - - - - - - - - - - - - - - async function associateStudentWithOrganization() { - - const relatedOrganization0 = await Organizations.findOne({ - offset: Math.floor(Math.random() * (await Organizations.count())), - }); - const Student0 = await Students.findOne({ - order: [['id', 'ASC']], - offset: 0 - }); - if (Student0?.setOrganization) - { - await - Student0. - setOrganization(relatedOrganization0); - } - - const relatedOrganization1 = await Organizations.findOne({ - offset: Math.floor(Math.random() * (await Organizations.count())), - }); - const Student1 = await Students.findOne({ - order: [['id', 'ASC']], - offset: 1 - }); - if (Student1?.setOrganization) - { - await - Student1. - setOrganization(relatedOrganization1); - } - - const relatedOrganization2 = await Organizations.findOne({ - offset: Math.floor(Math.random() * (await Organizations.count())), - }); - const Student2 = await Students.findOne({ - order: [['id', 'ASC']], - offset: 2 - }); - if (Student2?.setOrganization) - { - await - Student2. - setOrganization(relatedOrganization2); - } - - const relatedOrganization3 = await Organizations.findOne({ - offset: Math.floor(Math.random() * (await Organizations.count())), - }); - const Student3 = await Students.findOne({ - order: [['id', 'ASC']], - offset: 3 - }); - if (Student3?.setOrganization) - { - await - Student3. - setOrganization(relatedOrganization3); - } - - } - - - - - async function associateStudentWithCampu() { - - const relatedCampu0 = await Campuses.findOne({ - offset: Math.floor(Math.random() * (await Campuses.count())), - }); - const Student0 = await Students.findOne({ - order: [['id', 'ASC']], - offset: 0 - }); - if (Student0?.setCampu) - { - await - Student0. - setCampu(relatedCampu0); - } - - const relatedCampu1 = await Campuses.findOne({ - offset: Math.floor(Math.random() * (await Campuses.count())), - }); - const Student1 = await Students.findOne({ - order: [['id', 'ASC']], - offset: 1 - }); - if (Student1?.setCampu) - { - await - Student1. - setCampu(relatedCampu1); - } - - const relatedCampu2 = await Campuses.findOne({ - offset: Math.floor(Math.random() * (await Campuses.count())), - }); - const Student2 = await Students.findOne({ - order: [['id', 'ASC']], - offset: 2 - }); - if (Student2?.setCampu) - { - await - Student2. - setCampu(relatedCampu2); - } - - const relatedCampu3 = await Campuses.findOne({ - offset: Math.floor(Math.random() * (await Campuses.count())), - }); - const Student3 = await Students.findOne({ - order: [['id', 'ASC']], - offset: 3 - }); - if (Student3?.setCampu) - { - await - Student3. - setCampu(relatedCampu3); - } - - } - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - async function associateGuardianWithOrganization() { - - const relatedOrganization0 = await Organizations.findOne({ - offset: Math.floor(Math.random() * (await Organizations.count())), - }); - const Guardian0 = await Guardians.findOne({ - order: [['id', 'ASC']], - offset: 0 - }); - if (Guardian0?.setOrganization) - { - await - Guardian0. - setOrganization(relatedOrganization0); - } - - const relatedOrganization1 = await Organizations.findOne({ - offset: Math.floor(Math.random() * (await Organizations.count())), - }); - const Guardian1 = await Guardians.findOne({ - order: [['id', 'ASC']], - offset: 1 - }); - if (Guardian1?.setOrganization) - { - await - Guardian1. - setOrganization(relatedOrganization1); - } - - const relatedOrganization2 = await Organizations.findOne({ - offset: Math.floor(Math.random() * (await Organizations.count())), - }); - const Guardian2 = await Guardians.findOne({ - order: [['id', 'ASC']], - offset: 2 - }); - if (Guardian2?.setOrganization) - { - await - Guardian2. - setOrganization(relatedOrganization2); - } - - const relatedOrganization3 = await Organizations.findOne({ - offset: Math.floor(Math.random() * (await Organizations.count())), - }); - const Guardian3 = await Guardians.findOne({ - order: [['id', 'ASC']], - offset: 3 - }); - if (Guardian3?.setOrganization) - { - await - Guardian3. - setOrganization(relatedOrganization3); - } - - } - - - - - async function associateGuardianWithStudent() { - - const relatedStudent0 = await Students.findOne({ - offset: Math.floor(Math.random() * (await Students.count())), - }); - const Guardian0 = await Guardians.findOne({ - order: [['id', 'ASC']], - offset: 0 - }); - if (Guardian0?.setStudent) - { - await - Guardian0. - setStudent(relatedStudent0); - } - - const relatedStudent1 = await Students.findOne({ - offset: Math.floor(Math.random() * (await Students.count())), - }); - const Guardian1 = await Guardians.findOne({ - order: [['id', 'ASC']], - offset: 1 - }); - if (Guardian1?.setStudent) - { - await - Guardian1. - setStudent(relatedStudent1); - } - - const relatedStudent2 = await Students.findOne({ - offset: Math.floor(Math.random() * (await Students.count())), - }); - const Guardian2 = await Guardians.findOne({ - order: [['id', 'ASC']], - offset: 2 - }); - if (Guardian2?.setStudent) - { - await - Guardian2. - setStudent(relatedStudent2); - } - - const relatedStudent3 = await Students.findOne({ - offset: Math.floor(Math.random() * (await Students.count())), - }); - const Guardian3 = await Guardians.findOne({ - order: [['id', 'ASC']], - offset: 3 - }); - if (Guardian3?.setStudent) - { - await - Guardian3. - setStudent(relatedStudent3); - } - - } - - - - - - - - - - - - - - - - - - - - - async function associateStaffWithOrganization() { - - const relatedOrganization0 = await Organizations.findOne({ - offset: Math.floor(Math.random() * (await Organizations.count())), - }); - const Staff0 = await Staff.findOne({ - order: [['id', 'ASC']], - offset: 0 - }); - if (Staff0?.setOrganization) - { - await - Staff0. - setOrganization(relatedOrganization0); - } - - const relatedOrganization1 = await Organizations.findOne({ - offset: Math.floor(Math.random() * (await Organizations.count())), - }); - const Staff1 = await Staff.findOne({ - order: [['id', 'ASC']], - offset: 1 - }); - if (Staff1?.setOrganization) - { - await - Staff1. - setOrganization(relatedOrganization1); - } - - const relatedOrganization2 = await Organizations.findOne({ - offset: Math.floor(Math.random() * (await Organizations.count())), - }); - const Staff2 = await Staff.findOne({ - order: [['id', 'ASC']], - offset: 2 - }); - if (Staff2?.setOrganization) - { - await - Staff2. - setOrganization(relatedOrganization2); - } - - const relatedOrganization3 = await Organizations.findOne({ - offset: Math.floor(Math.random() * (await Organizations.count())), - }); - const Staff3 = await Staff.findOne({ - order: [['id', 'ASC']], - offset: 3 - }); - if (Staff3?.setOrganization) - { - await - Staff3. - setOrganization(relatedOrganization3); - } - - } - - - - - async function associateStaffWithCampu() { - - const relatedCampu0 = await Campuses.findOne({ - offset: Math.floor(Math.random() * (await Campuses.count())), - }); - const Staff0 = await Staff.findOne({ - order: [['id', 'ASC']], - offset: 0 - }); - if (Staff0?.setCampu) - { - await - Staff0. - setCampu(relatedCampu0); - } - - const relatedCampu1 = await Campuses.findOne({ - offset: Math.floor(Math.random() * (await Campuses.count())), - }); - const Staff1 = await Staff.findOne({ - order: [['id', 'ASC']], - offset: 1 - }); - if (Staff1?.setCampu) - { - await - Staff1. - setCampu(relatedCampu1); - } - - const relatedCampu2 = await Campuses.findOne({ - offset: Math.floor(Math.random() * (await Campuses.count())), - }); - const Staff2 = await Staff.findOne({ - order: [['id', 'ASC']], - offset: 2 - }); - if (Staff2?.setCampu) - { - await - Staff2. - setCampu(relatedCampu2); - } - - const relatedCampu3 = await Campuses.findOne({ - offset: Math.floor(Math.random() * (await Campuses.count())), - }); - const Staff3 = await Staff.findOne({ - order: [['id', 'ASC']], - offset: 3 - }); - if (Staff3?.setCampu) - { - await - Staff3. - setCampu(relatedCampu3); - } - - } - - - - - async function associateStaffWithUser() { - - const relatedUser0 = await Users.findOne({ - offset: Math.floor(Math.random() * (await Users.count())), - }); - const Staff0 = await Staff.findOne({ - order: [['id', 'ASC']], - offset: 0 - }); - if (Staff0?.setUser) - { - await - Staff0. - setUser(relatedUser0); - } - - const relatedUser1 = await Users.findOne({ - offset: Math.floor(Math.random() * (await Users.count())), - }); - const Staff1 = await Staff.findOne({ - order: [['id', 'ASC']], - offset: 1 - }); - if (Staff1?.setUser) - { - await - Staff1. - setUser(relatedUser1); - } - - const relatedUser2 = await Users.findOne({ - offset: Math.floor(Math.random() * (await Users.count())), - }); - const Staff2 = await Staff.findOne({ - order: [['id', 'ASC']], - offset: 2 - }); - if (Staff2?.setUser) - { - await - Staff2. - setUser(relatedUser2); - } - - const relatedUser3 = await Users.findOne({ - offset: Math.floor(Math.random() * (await Users.count())), - }); - const Staff3 = await Staff.findOne({ - order: [['id', 'ASC']], - offset: 3 - }); - if (Staff3?.setUser) - { - await - Staff3. - setUser(relatedUser3); - } - - } - - - - - - - - - - - - - - - - - - - - - async function associateClassWithOrganization() { - - const relatedOrganization0 = await Organizations.findOne({ - offset: Math.floor(Math.random() * (await Organizations.count())), - }); - const Class0 = await Classes.findOne({ - order: [['id', 'ASC']], - offset: 0 - }); - if (Class0?.setOrganization) - { - await - Class0. - setOrganization(relatedOrganization0); - } - - const relatedOrganization1 = await Organizations.findOne({ - offset: Math.floor(Math.random() * (await Organizations.count())), - }); - const Class1 = await Classes.findOne({ - order: [['id', 'ASC']], - offset: 1 - }); - if (Class1?.setOrganization) - { - await - Class1. - setOrganization(relatedOrganization1); - } - - const relatedOrganization2 = await Organizations.findOne({ - offset: Math.floor(Math.random() * (await Organizations.count())), - }); - const Class2 = await Classes.findOne({ - order: [['id', 'ASC']], - offset: 2 - }); - if (Class2?.setOrganization) - { - await - Class2. - setOrganization(relatedOrganization2); - } - - const relatedOrganization3 = await Organizations.findOne({ - offset: Math.floor(Math.random() * (await Organizations.count())), - }); - const Class3 = await Classes.findOne({ - order: [['id', 'ASC']], - offset: 3 - }); - if (Class3?.setOrganization) - { - await - Class3. - setOrganization(relatedOrganization3); - } - - } - - - - - async function associateClassWithCampu() { - - const relatedCampu0 = await Campuses.findOne({ - offset: Math.floor(Math.random() * (await Campuses.count())), - }); - const Class0 = await Classes.findOne({ - order: [['id', 'ASC']], - offset: 0 - }); - if (Class0?.setCampu) - { - await - Class0. - setCampu(relatedCampu0); - } - - const relatedCampu1 = await Campuses.findOne({ - offset: Math.floor(Math.random() * (await Campuses.count())), - }); - const Class1 = await Classes.findOne({ - order: [['id', 'ASC']], - offset: 1 - }); - if (Class1?.setCampu) - { - await - Class1. - setCampu(relatedCampu1); - } - - const relatedCampu2 = await Campuses.findOne({ - offset: Math.floor(Math.random() * (await Campuses.count())), - }); - const Class2 = await Classes.findOne({ - order: [['id', 'ASC']], - offset: 2 - }); - if (Class2?.setCampu) - { - await - Class2. - setCampu(relatedCampu2); - } - - const relatedCampu3 = await Campuses.findOne({ - offset: Math.floor(Math.random() * (await Campuses.count())), - }); - const Class3 = await Classes.findOne({ - order: [['id', 'ASC']], - offset: 3 - }); - if (Class3?.setCampu) - { - await - Class3. - setCampu(relatedCampu3); - } - - } - - - - - async function associateClassWithAcademic_year() { - - const relatedAcademic_year0 = await AcademicYears.findOne({ - offset: Math.floor(Math.random() * (await AcademicYears.count())), - }); - const Class0 = await Classes.findOne({ - order: [['id', 'ASC']], - offset: 0 - }); - if (Class0?.setAcademic_year) - { - await - Class0. - setAcademic_year(relatedAcademic_year0); - } - - const relatedAcademic_year1 = await AcademicYears.findOne({ - offset: Math.floor(Math.random() * (await AcademicYears.count())), - }); - const Class1 = await Classes.findOne({ - order: [['id', 'ASC']], - offset: 1 - }); - if (Class1?.setAcademic_year) - { - await - Class1. - setAcademic_year(relatedAcademic_year1); - } - - const relatedAcademic_year2 = await AcademicYears.findOne({ - offset: Math.floor(Math.random() * (await AcademicYears.count())), - }); - const Class2 = await Classes.findOne({ - order: [['id', 'ASC']], - offset: 2 - }); - if (Class2?.setAcademic_year) - { - await - Class2. - setAcademic_year(relatedAcademic_year2); - } - - const relatedAcademic_year3 = await AcademicYears.findOne({ - offset: Math.floor(Math.random() * (await AcademicYears.count())), - }); - const Class3 = await Classes.findOne({ - order: [['id', 'ASC']], - offset: 3 - }); - if (Class3?.setAcademic_year) - { - await - Class3. - setAcademic_year(relatedAcademic_year3); - } - - } - - - - - async function associateClassWithGrade() { - - const relatedGrade0 = await Grades.findOne({ - offset: Math.floor(Math.random() * (await Grades.count())), - }); - const Class0 = await Classes.findOne({ - order: [['id', 'ASC']], - offset: 0 - }); - if (Class0?.setGrade) - { - await - Class0. - setGrade(relatedGrade0); - } - - const relatedGrade1 = await Grades.findOne({ - offset: Math.floor(Math.random() * (await Grades.count())), - }); - const Class1 = await Classes.findOne({ - order: [['id', 'ASC']], - offset: 1 - }); - if (Class1?.setGrade) - { - await - Class1. - setGrade(relatedGrade1); - } - - const relatedGrade2 = await Grades.findOne({ - offset: Math.floor(Math.random() * (await Grades.count())), - }); - const Class2 = await Classes.findOne({ - order: [['id', 'ASC']], - offset: 2 - }); - if (Class2?.setGrade) - { - await - Class2. - setGrade(relatedGrade2); - } - - const relatedGrade3 = await Grades.findOne({ - offset: Math.floor(Math.random() * (await Grades.count())), - }); - const Class3 = await Classes.findOne({ - order: [['id', 'ASC']], - offset: 3 - }); - if (Class3?.setGrade) - { - await - Class3. - setGrade(relatedGrade3); - } - - } - - - - - - - - - async function associateClassWithHomeroom_teacher() { - - const relatedHomeroom_teacher0 = await Staff.findOne({ - offset: Math.floor(Math.random() * (await Staff.count())), - }); - const Class0 = await Classes.findOne({ - order: [['id', 'ASC']], - offset: 0 - }); - if (Class0?.setHomeroom_teacher) - { - await - Class0. - setHomeroom_teacher(relatedHomeroom_teacher0); - } - - const relatedHomeroom_teacher1 = await Staff.findOne({ - offset: Math.floor(Math.random() * (await Staff.count())), - }); - const Class1 = await Classes.findOne({ - order: [['id', 'ASC']], - offset: 1 - }); - if (Class1?.setHomeroom_teacher) - { - await - Class1. - setHomeroom_teacher(relatedHomeroom_teacher1); - } - - const relatedHomeroom_teacher2 = await Staff.findOne({ - offset: Math.floor(Math.random() * (await Staff.count())), - }); - const Class2 = await Classes.findOne({ - order: [['id', 'ASC']], - offset: 2 - }); - if (Class2?.setHomeroom_teacher) - { - await - Class2. - setHomeroom_teacher(relatedHomeroom_teacher2); - } - - const relatedHomeroom_teacher3 = await Staff.findOne({ - offset: Math.floor(Math.random() * (await Staff.count())), - }); - const Class3 = await Classes.findOne({ - order: [['id', 'ASC']], - offset: 3 - }); - if (Class3?.setHomeroom_teacher) - { - await - Class3. - setHomeroom_teacher(relatedHomeroom_teacher3); - } - - } - - - - - - - - - - - - - async function associateClassEnrollmentWithOrganization() { - - const relatedOrganization0 = await Organizations.findOne({ - offset: Math.floor(Math.random() * (await Organizations.count())), - }); - const ClassEnrollment0 = await ClassEnrollments.findOne({ - order: [['id', 'ASC']], - offset: 0 - }); - if (ClassEnrollment0?.setOrganization) - { - await - ClassEnrollment0. - setOrganization(relatedOrganization0); - } - - const relatedOrganization1 = await Organizations.findOne({ - offset: Math.floor(Math.random() * (await Organizations.count())), - }); - const ClassEnrollment1 = await ClassEnrollments.findOne({ - order: [['id', 'ASC']], - offset: 1 - }); - if (ClassEnrollment1?.setOrganization) - { - await - ClassEnrollment1. - setOrganization(relatedOrganization1); - } - - const relatedOrganization2 = await Organizations.findOne({ - offset: Math.floor(Math.random() * (await Organizations.count())), - }); - const ClassEnrollment2 = await ClassEnrollments.findOne({ - order: [['id', 'ASC']], - offset: 2 - }); - if (ClassEnrollment2?.setOrganization) - { - await - ClassEnrollment2. - setOrganization(relatedOrganization2); - } - - const relatedOrganization3 = await Organizations.findOne({ - offset: Math.floor(Math.random() * (await Organizations.count())), - }); - const ClassEnrollment3 = await ClassEnrollments.findOne({ - order: [['id', 'ASC']], - offset: 3 - }); - if (ClassEnrollment3?.setOrganization) - { - await - ClassEnrollment3. - setOrganization(relatedOrganization3); - } - - } - - - - - async function associateClassEnrollmentWithClas() { - - const relatedClas0 = await Classes.findOne({ - offset: Math.floor(Math.random() * (await Classes.count())), - }); - const ClassEnrollment0 = await ClassEnrollments.findOne({ - order: [['id', 'ASC']], - offset: 0 - }); - if (ClassEnrollment0?.setClas) - { - await - ClassEnrollment0. - setClas(relatedClas0); - } - - const relatedClas1 = await Classes.findOne({ - offset: Math.floor(Math.random() * (await Classes.count())), - }); - const ClassEnrollment1 = await ClassEnrollments.findOne({ - order: [['id', 'ASC']], - offset: 1 - }); - if (ClassEnrollment1?.setClas) - { - await - ClassEnrollment1. - setClas(relatedClas1); - } - - const relatedClas2 = await Classes.findOne({ - offset: Math.floor(Math.random() * (await Classes.count())), - }); - const ClassEnrollment2 = await ClassEnrollments.findOne({ - order: [['id', 'ASC']], - offset: 2 - }); - if (ClassEnrollment2?.setClas) - { - await - ClassEnrollment2. - setClas(relatedClas2); - } - - const relatedClas3 = await Classes.findOne({ - offset: Math.floor(Math.random() * (await Classes.count())), - }); - const ClassEnrollment3 = await ClassEnrollments.findOne({ - order: [['id', 'ASC']], - offset: 3 - }); - if (ClassEnrollment3?.setClas) - { - await - ClassEnrollment3. - setClas(relatedClas3); - } - - } - - - - - async function associateClassEnrollmentWithStudent() { - - const relatedStudent0 = await Students.findOne({ - offset: Math.floor(Math.random() * (await Students.count())), - }); - const ClassEnrollment0 = await ClassEnrollments.findOne({ - order: [['id', 'ASC']], - offset: 0 - }); - if (ClassEnrollment0?.setStudent) - { - await - ClassEnrollment0. - setStudent(relatedStudent0); - } - - const relatedStudent1 = await Students.findOne({ - offset: Math.floor(Math.random() * (await Students.count())), - }); - const ClassEnrollment1 = await ClassEnrollments.findOne({ - order: [['id', 'ASC']], - offset: 1 - }); - if (ClassEnrollment1?.setStudent) - { - await - ClassEnrollment1. - setStudent(relatedStudent1); - } - - const relatedStudent2 = await Students.findOne({ - offset: Math.floor(Math.random() * (await Students.count())), - }); - const ClassEnrollment2 = await ClassEnrollments.findOne({ - order: [['id', 'ASC']], - offset: 2 - }); - if (ClassEnrollment2?.setStudent) - { - await - ClassEnrollment2. - setStudent(relatedStudent2); - } - - const relatedStudent3 = await Students.findOne({ - offset: Math.floor(Math.random() * (await Students.count())), - }); - const ClassEnrollment3 = await ClassEnrollments.findOne({ - order: [['id', 'ASC']], - offset: 3 - }); - if (ClassEnrollment3?.setStudent) - { - await - ClassEnrollment3. - setStudent(relatedStudent3); - } - - } - - - - - - - - - - - - - - - async function associateClassSubjectWithOrganization() { - - const relatedOrganization0 = await Organizations.findOne({ - offset: Math.floor(Math.random() * (await Organizations.count())), - }); - const ClassSubject0 = await ClassSubjects.findOne({ - order: [['id', 'ASC']], - offset: 0 - }); - if (ClassSubject0?.setOrganization) - { - await - ClassSubject0. - setOrganization(relatedOrganization0); - } - - const relatedOrganization1 = await Organizations.findOne({ - offset: Math.floor(Math.random() * (await Organizations.count())), - }); - const ClassSubject1 = await ClassSubjects.findOne({ - order: [['id', 'ASC']], - offset: 1 - }); - if (ClassSubject1?.setOrganization) - { - await - ClassSubject1. - setOrganization(relatedOrganization1); - } - - const relatedOrganization2 = await Organizations.findOne({ - offset: Math.floor(Math.random() * (await Organizations.count())), - }); - const ClassSubject2 = await ClassSubjects.findOne({ - order: [['id', 'ASC']], - offset: 2 - }); - if (ClassSubject2?.setOrganization) - { - await - ClassSubject2. - setOrganization(relatedOrganization2); - } - - const relatedOrganization3 = await Organizations.findOne({ - offset: Math.floor(Math.random() * (await Organizations.count())), - }); - const ClassSubject3 = await ClassSubjects.findOne({ - order: [['id', 'ASC']], - offset: 3 - }); - if (ClassSubject3?.setOrganization) - { - await - ClassSubject3. - setOrganization(relatedOrganization3); - } - - } - - - - - async function associateClassSubjectWithClas() { - - const relatedClas0 = await Classes.findOne({ - offset: Math.floor(Math.random() * (await Classes.count())), - }); - const ClassSubject0 = await ClassSubjects.findOne({ - order: [['id', 'ASC']], - offset: 0 - }); - if (ClassSubject0?.setClas) - { - await - ClassSubject0. - setClas(relatedClas0); - } - - const relatedClas1 = await Classes.findOne({ - offset: Math.floor(Math.random() * (await Classes.count())), - }); - const ClassSubject1 = await ClassSubjects.findOne({ - order: [['id', 'ASC']], - offset: 1 - }); - if (ClassSubject1?.setClas) - { - await - ClassSubject1. - setClas(relatedClas1); - } - - const relatedClas2 = await Classes.findOne({ - offset: Math.floor(Math.random() * (await Classes.count())), - }); - const ClassSubject2 = await ClassSubjects.findOne({ - order: [['id', 'ASC']], - offset: 2 - }); - if (ClassSubject2?.setClas) - { - await - ClassSubject2. - setClas(relatedClas2); - } - - const relatedClas3 = await Classes.findOne({ - offset: Math.floor(Math.random() * (await Classes.count())), - }); - const ClassSubject3 = await ClassSubjects.findOne({ - order: [['id', 'ASC']], - offset: 3 - }); - if (ClassSubject3?.setClas) - { - await - ClassSubject3. - setClas(relatedClas3); - } - - } - - - - - async function associateClassSubjectWithSubject() { - - const relatedSubject0 = await Subjects.findOne({ - offset: Math.floor(Math.random() * (await Subjects.count())), - }); - const ClassSubject0 = await ClassSubjects.findOne({ - order: [['id', 'ASC']], - offset: 0 - }); - if (ClassSubject0?.setSubject) - { - await - ClassSubject0. - setSubject(relatedSubject0); - } - - const relatedSubject1 = await Subjects.findOne({ - offset: Math.floor(Math.random() * (await Subjects.count())), - }); - const ClassSubject1 = await ClassSubjects.findOne({ - order: [['id', 'ASC']], - offset: 1 - }); - if (ClassSubject1?.setSubject) - { - await - ClassSubject1. - setSubject(relatedSubject1); - } - - const relatedSubject2 = await Subjects.findOne({ - offset: Math.floor(Math.random() * (await Subjects.count())), - }); - const ClassSubject2 = await ClassSubjects.findOne({ - order: [['id', 'ASC']], - offset: 2 - }); - if (ClassSubject2?.setSubject) - { - await - ClassSubject2. - setSubject(relatedSubject2); - } - - const relatedSubject3 = await Subjects.findOne({ - offset: Math.floor(Math.random() * (await Subjects.count())), - }); - const ClassSubject3 = await ClassSubjects.findOne({ - order: [['id', 'ASC']], - offset: 3 - }); - if (ClassSubject3?.setSubject) - { - await - ClassSubject3. - setSubject(relatedSubject3); - } - - } - - - - - async function associateClassSubjectWithTeacher() { - - const relatedTeacher0 = await Staff.findOne({ - offset: Math.floor(Math.random() * (await Staff.count())), - }); - const ClassSubject0 = await ClassSubjects.findOne({ - order: [['id', 'ASC']], - offset: 0 - }); - if (ClassSubject0?.setTeacher) - { - await - ClassSubject0. - setTeacher(relatedTeacher0); - } - - const relatedTeacher1 = await Staff.findOne({ - offset: Math.floor(Math.random() * (await Staff.count())), - }); - const ClassSubject1 = await ClassSubjects.findOne({ - order: [['id', 'ASC']], - offset: 1 - }); - if (ClassSubject1?.setTeacher) - { - await - ClassSubject1. - setTeacher(relatedTeacher1); - } - - const relatedTeacher2 = await Staff.findOne({ - offset: Math.floor(Math.random() * (await Staff.count())), - }); - const ClassSubject2 = await ClassSubjects.findOne({ - order: [['id', 'ASC']], - offset: 2 - }); - if (ClassSubject2?.setTeacher) - { - await - ClassSubject2. - setTeacher(relatedTeacher2); - } - - const relatedTeacher3 = await Staff.findOne({ - offset: Math.floor(Math.random() * (await Staff.count())), - }); - const ClassSubject3 = await ClassSubjects.findOne({ - order: [['id', 'ASC']], - offset: 3 - }); - if (ClassSubject3?.setTeacher) - { - await - ClassSubject3. - setTeacher(relatedTeacher3); - } - - } - - - - - - - - - - - async function associateTimetableWithOrganization() { - - const relatedOrganization0 = await Organizations.findOne({ - offset: Math.floor(Math.random() * (await Organizations.count())), - }); - const Timetable0 = await Timetables.findOne({ - order: [['id', 'ASC']], - offset: 0 - }); - if (Timetable0?.setOrganization) - { - await - Timetable0. - setOrganization(relatedOrganization0); - } - - const relatedOrganization1 = await Organizations.findOne({ - offset: Math.floor(Math.random() * (await Organizations.count())), - }); - const Timetable1 = await Timetables.findOne({ - order: [['id', 'ASC']], - offset: 1 - }); - if (Timetable1?.setOrganization) - { - await - Timetable1. - setOrganization(relatedOrganization1); - } - - const relatedOrganization2 = await Organizations.findOne({ - offset: Math.floor(Math.random() * (await Organizations.count())), - }); - const Timetable2 = await Timetables.findOne({ - order: [['id', 'ASC']], - offset: 2 - }); - if (Timetable2?.setOrganization) - { - await - Timetable2. - setOrganization(relatedOrganization2); - } - - const relatedOrganization3 = await Organizations.findOne({ - offset: Math.floor(Math.random() * (await Organizations.count())), - }); - const Timetable3 = await Timetables.findOne({ - order: [['id', 'ASC']], - offset: 3 - }); - if (Timetable3?.setOrganization) - { - await - Timetable3. - setOrganization(relatedOrganization3); - } - - } - - - - - async function associateTimetableWithCampu() { - - const relatedCampu0 = await Campuses.findOne({ - offset: Math.floor(Math.random() * (await Campuses.count())), - }); - const Timetable0 = await Timetables.findOne({ - order: [['id', 'ASC']], - offset: 0 - }); - if (Timetable0?.setCampu) - { - await - Timetable0. - setCampu(relatedCampu0); - } - - const relatedCampu1 = await Campuses.findOne({ - offset: Math.floor(Math.random() * (await Campuses.count())), - }); - const Timetable1 = await Timetables.findOne({ - order: [['id', 'ASC']], - offset: 1 - }); - if (Timetable1?.setCampu) - { - await - Timetable1. - setCampu(relatedCampu1); - } - - const relatedCampu2 = await Campuses.findOne({ - offset: Math.floor(Math.random() * (await Campuses.count())), - }); - const Timetable2 = await Timetables.findOne({ - order: [['id', 'ASC']], - offset: 2 - }); - if (Timetable2?.setCampu) - { - await - Timetable2. - setCampu(relatedCampu2); - } - - const relatedCampu3 = await Campuses.findOne({ - offset: Math.floor(Math.random() * (await Campuses.count())), - }); - const Timetable3 = await Timetables.findOne({ - order: [['id', 'ASC']], - offset: 3 - }); - if (Timetable3?.setCampu) - { - await - Timetable3. - setCampu(relatedCampu3); - } - - } - - - - - async function associateTimetableWithAcademic_year() { - - const relatedAcademic_year0 = await AcademicYears.findOne({ - offset: Math.floor(Math.random() * (await AcademicYears.count())), - }); - const Timetable0 = await Timetables.findOne({ - order: [['id', 'ASC']], - offset: 0 - }); - if (Timetable0?.setAcademic_year) - { - await - Timetable0. - setAcademic_year(relatedAcademic_year0); - } - - const relatedAcademic_year1 = await AcademicYears.findOne({ - offset: Math.floor(Math.random() * (await AcademicYears.count())), - }); - const Timetable1 = await Timetables.findOne({ - order: [['id', 'ASC']], - offset: 1 - }); - if (Timetable1?.setAcademic_year) - { - await - Timetable1. - setAcademic_year(relatedAcademic_year1); - } - - const relatedAcademic_year2 = await AcademicYears.findOne({ - offset: Math.floor(Math.random() * (await AcademicYears.count())), - }); - const Timetable2 = await Timetables.findOne({ - order: [['id', 'ASC']], - offset: 2 - }); - if (Timetable2?.setAcademic_year) - { - await - Timetable2. - setAcademic_year(relatedAcademic_year2); - } - - const relatedAcademic_year3 = await AcademicYears.findOne({ - offset: Math.floor(Math.random() * (await AcademicYears.count())), - }); - const Timetable3 = await Timetables.findOne({ - order: [['id', 'ASC']], - offset: 3 - }); - if (Timetable3?.setAcademic_year) - { - await - Timetable3. - setAcademic_year(relatedAcademic_year3); - } - - } - - - - - - - - - - - - - - - - - async function associateTimetablePeriodWithOrganization() { - - const relatedOrganization0 = await Organizations.findOne({ - offset: Math.floor(Math.random() * (await Organizations.count())), - }); - const TimetablePeriod0 = await TimetablePeriods.findOne({ - order: [['id', 'ASC']], - offset: 0 - }); - if (TimetablePeriod0?.setOrganization) - { - await - TimetablePeriod0. - setOrganization(relatedOrganization0); - } - - const relatedOrganization1 = await Organizations.findOne({ - offset: Math.floor(Math.random() * (await Organizations.count())), - }); - const TimetablePeriod1 = await TimetablePeriods.findOne({ - order: [['id', 'ASC']], - offset: 1 - }); - if (TimetablePeriod1?.setOrganization) - { - await - TimetablePeriod1. - setOrganization(relatedOrganization1); - } - - const relatedOrganization2 = await Organizations.findOne({ - offset: Math.floor(Math.random() * (await Organizations.count())), - }); - const TimetablePeriod2 = await TimetablePeriods.findOne({ - order: [['id', 'ASC']], - offset: 2 - }); - if (TimetablePeriod2?.setOrganization) - { - await - TimetablePeriod2. - setOrganization(relatedOrganization2); - } - - const relatedOrganization3 = await Organizations.findOne({ - offset: Math.floor(Math.random() * (await Organizations.count())), - }); - const TimetablePeriod3 = await TimetablePeriods.findOne({ - order: [['id', 'ASC']], - offset: 3 - }); - if (TimetablePeriod3?.setOrganization) - { - await - TimetablePeriod3. - setOrganization(relatedOrganization3); - } - - } - - - - - async function associateTimetablePeriodWithTimetable() { - - const relatedTimetable0 = await Timetables.findOne({ - offset: Math.floor(Math.random() * (await Timetables.count())), - }); - const TimetablePeriod0 = await TimetablePeriods.findOne({ - order: [['id', 'ASC']], - offset: 0 - }); - if (TimetablePeriod0?.setTimetable) - { - await - TimetablePeriod0. - setTimetable(relatedTimetable0); - } - - const relatedTimetable1 = await Timetables.findOne({ - offset: Math.floor(Math.random() * (await Timetables.count())), - }); - const TimetablePeriod1 = await TimetablePeriods.findOne({ - order: [['id', 'ASC']], - offset: 1 - }); - if (TimetablePeriod1?.setTimetable) - { - await - TimetablePeriod1. - setTimetable(relatedTimetable1); - } - - const relatedTimetable2 = await Timetables.findOne({ - offset: Math.floor(Math.random() * (await Timetables.count())), - }); - const TimetablePeriod2 = await TimetablePeriods.findOne({ - order: [['id', 'ASC']], - offset: 2 - }); - if (TimetablePeriod2?.setTimetable) - { - await - TimetablePeriod2. - setTimetable(relatedTimetable2); - } - - const relatedTimetable3 = await Timetables.findOne({ - offset: Math.floor(Math.random() * (await Timetables.count())), - }); - const TimetablePeriod3 = await TimetablePeriods.findOne({ - order: [['id', 'ASC']], - offset: 3 - }); - if (TimetablePeriod3?.setTimetable) - { - await - TimetablePeriod3. - setTimetable(relatedTimetable3); - } - - } - - - - - async function associateTimetablePeriodWithClass_subject() { - - const relatedClass_subject0 = await ClassSubjects.findOne({ - offset: Math.floor(Math.random() * (await ClassSubjects.count())), - }); - const TimetablePeriod0 = await TimetablePeriods.findOne({ - order: [['id', 'ASC']], - offset: 0 - }); - if (TimetablePeriod0?.setClass_subject) - { - await - TimetablePeriod0. - setClass_subject(relatedClass_subject0); - } - - const relatedClass_subject1 = await ClassSubjects.findOne({ - offset: Math.floor(Math.random() * (await ClassSubjects.count())), - }); - const TimetablePeriod1 = await TimetablePeriods.findOne({ - order: [['id', 'ASC']], - offset: 1 - }); - if (TimetablePeriod1?.setClass_subject) - { - await - TimetablePeriod1. - setClass_subject(relatedClass_subject1); - } - - const relatedClass_subject2 = await ClassSubjects.findOne({ - offset: Math.floor(Math.random() * (await ClassSubjects.count())), - }); - const TimetablePeriod2 = await TimetablePeriods.findOne({ - order: [['id', 'ASC']], - offset: 2 - }); - if (TimetablePeriod2?.setClass_subject) - { - await - TimetablePeriod2. - setClass_subject(relatedClass_subject2); - } - - const relatedClass_subject3 = await ClassSubjects.findOne({ - offset: Math.floor(Math.random() * (await ClassSubjects.count())), - }); - const TimetablePeriod3 = await TimetablePeriods.findOne({ - order: [['id', 'ASC']], - offset: 3 - }); - if (TimetablePeriod3?.setClass_subject) - { - await - TimetablePeriod3. - setClass_subject(relatedClass_subject3); - } - - } - - - - - - - - - - - - - - - - - async function associateAttendanceSessionWithOrganization() { - - const relatedOrganization0 = await Organizations.findOne({ - offset: Math.floor(Math.random() * (await Organizations.count())), - }); - const AttendanceSession0 = await AttendanceSessions.findOne({ - order: [['id', 'ASC']], - offset: 0 - }); - if (AttendanceSession0?.setOrganization) - { - await - AttendanceSession0. - setOrganization(relatedOrganization0); - } - - const relatedOrganization1 = await Organizations.findOne({ - offset: Math.floor(Math.random() * (await Organizations.count())), - }); - const AttendanceSession1 = await AttendanceSessions.findOne({ - order: [['id', 'ASC']], - offset: 1 - }); - if (AttendanceSession1?.setOrganization) - { - await - AttendanceSession1. - setOrganization(relatedOrganization1); - } - - const relatedOrganization2 = await Organizations.findOne({ - offset: Math.floor(Math.random() * (await Organizations.count())), - }); - const AttendanceSession2 = await AttendanceSessions.findOne({ - order: [['id', 'ASC']], - offset: 2 - }); - if (AttendanceSession2?.setOrganization) - { - await - AttendanceSession2. - setOrganization(relatedOrganization2); - } - - const relatedOrganization3 = await Organizations.findOne({ - offset: Math.floor(Math.random() * (await Organizations.count())), - }); - const AttendanceSession3 = await AttendanceSessions.findOne({ - order: [['id', 'ASC']], - offset: 3 - }); - if (AttendanceSession3?.setOrganization) - { - await - AttendanceSession3. - setOrganization(relatedOrganization3); - } - - } - - - - - async function associateAttendanceSessionWithCampu() { - - const relatedCampu0 = await Campuses.findOne({ - offset: Math.floor(Math.random() * (await Campuses.count())), - }); - const AttendanceSession0 = await AttendanceSessions.findOne({ - order: [['id', 'ASC']], - offset: 0 - }); - if (AttendanceSession0?.setCampu) - { - await - AttendanceSession0. - setCampu(relatedCampu0); - } - - const relatedCampu1 = await Campuses.findOne({ - offset: Math.floor(Math.random() * (await Campuses.count())), - }); - const AttendanceSession1 = await AttendanceSessions.findOne({ - order: [['id', 'ASC']], - offset: 1 - }); - if (AttendanceSession1?.setCampu) - { - await - AttendanceSession1. - setCampu(relatedCampu1); - } - - const relatedCampu2 = await Campuses.findOne({ - offset: Math.floor(Math.random() * (await Campuses.count())), - }); - const AttendanceSession2 = await AttendanceSessions.findOne({ - order: [['id', 'ASC']], - offset: 2 - }); - if (AttendanceSession2?.setCampu) - { - await - AttendanceSession2. - setCampu(relatedCampu2); - } - - const relatedCampu3 = await Campuses.findOne({ - offset: Math.floor(Math.random() * (await Campuses.count())), - }); - const AttendanceSession3 = await AttendanceSessions.findOne({ - order: [['id', 'ASC']], - offset: 3 - }); - if (AttendanceSession3?.setCampu) - { - await - AttendanceSession3. - setCampu(relatedCampu3); - } - - } - - - - - async function associateAttendanceSessionWithClas() { - - const relatedClas0 = await Classes.findOne({ - offset: Math.floor(Math.random() * (await Classes.count())), - }); - const AttendanceSession0 = await AttendanceSessions.findOne({ - order: [['id', 'ASC']], - offset: 0 - }); - if (AttendanceSession0?.setClas) - { - await - AttendanceSession0. - setClas(relatedClas0); - } - - const relatedClas1 = await Classes.findOne({ - offset: Math.floor(Math.random() * (await Classes.count())), - }); - const AttendanceSession1 = await AttendanceSessions.findOne({ - order: [['id', 'ASC']], - offset: 1 - }); - if (AttendanceSession1?.setClas) - { - await - AttendanceSession1. - setClas(relatedClas1); - } - - const relatedClas2 = await Classes.findOne({ - offset: Math.floor(Math.random() * (await Classes.count())), - }); - const AttendanceSession2 = await AttendanceSessions.findOne({ - order: [['id', 'ASC']], - offset: 2 - }); - if (AttendanceSession2?.setClas) - { - await - AttendanceSession2. - setClas(relatedClas2); - } - - const relatedClas3 = await Classes.findOne({ - offset: Math.floor(Math.random() * (await Classes.count())), - }); - const AttendanceSession3 = await AttendanceSessions.findOne({ - order: [['id', 'ASC']], - offset: 3 - }); - if (AttendanceSession3?.setClas) - { - await - AttendanceSession3. - setClas(relatedClas3); - } - - } - - - - - async function associateAttendanceSessionWithClass_subject() { - - const relatedClass_subject0 = await ClassSubjects.findOne({ - offset: Math.floor(Math.random() * (await ClassSubjects.count())), - }); - const AttendanceSession0 = await AttendanceSessions.findOne({ - order: [['id', 'ASC']], - offset: 0 - }); - if (AttendanceSession0?.setClass_subject) - { - await - AttendanceSession0. - setClass_subject(relatedClass_subject0); - } - - const relatedClass_subject1 = await ClassSubjects.findOne({ - offset: Math.floor(Math.random() * (await ClassSubjects.count())), - }); - const AttendanceSession1 = await AttendanceSessions.findOne({ - order: [['id', 'ASC']], - offset: 1 - }); - if (AttendanceSession1?.setClass_subject) - { - await - AttendanceSession1. - setClass_subject(relatedClass_subject1); - } - - const relatedClass_subject2 = await ClassSubjects.findOne({ - offset: Math.floor(Math.random() * (await ClassSubjects.count())), - }); - const AttendanceSession2 = await AttendanceSessions.findOne({ - order: [['id', 'ASC']], - offset: 2 - }); - if (AttendanceSession2?.setClass_subject) - { - await - AttendanceSession2. - setClass_subject(relatedClass_subject2); - } - - const relatedClass_subject3 = await ClassSubjects.findOne({ - offset: Math.floor(Math.random() * (await ClassSubjects.count())), - }); - const AttendanceSession3 = await AttendanceSessions.findOne({ - order: [['id', 'ASC']], - offset: 3 - }); - if (AttendanceSession3?.setClass_subject) - { - await - AttendanceSession3. - setClass_subject(relatedClass_subject3); - } - - } - - - - - async function associateAttendanceSessionWithTaken_by() { - - const relatedTaken_by0 = await Staff.findOne({ - offset: Math.floor(Math.random() * (await Staff.count())), - }); - const AttendanceSession0 = await AttendanceSessions.findOne({ - order: [['id', 'ASC']], - offset: 0 - }); - if (AttendanceSession0?.setTaken_by) - { - await - AttendanceSession0. - setTaken_by(relatedTaken_by0); - } - - const relatedTaken_by1 = await Staff.findOne({ - offset: Math.floor(Math.random() * (await Staff.count())), - }); - const AttendanceSession1 = await AttendanceSessions.findOne({ - order: [['id', 'ASC']], - offset: 1 - }); - if (AttendanceSession1?.setTaken_by) - { - await - AttendanceSession1. - setTaken_by(relatedTaken_by1); - } - - const relatedTaken_by2 = await Staff.findOne({ - offset: Math.floor(Math.random() * (await Staff.count())), - }); - const AttendanceSession2 = await AttendanceSessions.findOne({ - order: [['id', 'ASC']], - offset: 2 - }); - if (AttendanceSession2?.setTaken_by) - { - await - AttendanceSession2. - setTaken_by(relatedTaken_by2); - } - - const relatedTaken_by3 = await Staff.findOne({ - offset: Math.floor(Math.random() * (await Staff.count())), - }); - const AttendanceSession3 = await AttendanceSessions.findOne({ - order: [['id', 'ASC']], - offset: 3 - }); - if (AttendanceSession3?.setTaken_by) - { - await - AttendanceSession3. - setTaken_by(relatedTaken_by3); - } - - } - - - - - - - - - - - - - - - async function associateAttendanceRecordWithOrganization() { - - const relatedOrganization0 = await Organizations.findOne({ - offset: Math.floor(Math.random() * (await Organizations.count())), - }); - const AttendanceRecord0 = await AttendanceRecords.findOne({ - order: [['id', 'ASC']], - offset: 0 - }); - if (AttendanceRecord0?.setOrganization) - { - await - AttendanceRecord0. - setOrganization(relatedOrganization0); - } - - const relatedOrganization1 = await Organizations.findOne({ - offset: Math.floor(Math.random() * (await Organizations.count())), - }); - const AttendanceRecord1 = await AttendanceRecords.findOne({ - order: [['id', 'ASC']], - offset: 1 - }); - if (AttendanceRecord1?.setOrganization) - { - await - AttendanceRecord1. - setOrganization(relatedOrganization1); - } - - const relatedOrganization2 = await Organizations.findOne({ - offset: Math.floor(Math.random() * (await Organizations.count())), - }); - const AttendanceRecord2 = await AttendanceRecords.findOne({ - order: [['id', 'ASC']], - offset: 2 - }); - if (AttendanceRecord2?.setOrganization) - { - await - AttendanceRecord2. - setOrganization(relatedOrganization2); - } - - const relatedOrganization3 = await Organizations.findOne({ - offset: Math.floor(Math.random() * (await Organizations.count())), - }); - const AttendanceRecord3 = await AttendanceRecords.findOne({ - order: [['id', 'ASC']], - offset: 3 - }); - if (AttendanceRecord3?.setOrganization) - { - await - AttendanceRecord3. - setOrganization(relatedOrganization3); - } - - } - - - - - async function associateAttendanceRecordWithAttendance_session() { - - const relatedAttendance_session0 = await AttendanceSessions.findOne({ - offset: Math.floor(Math.random() * (await AttendanceSessions.count())), - }); - const AttendanceRecord0 = await AttendanceRecords.findOne({ - order: [['id', 'ASC']], - offset: 0 - }); - if (AttendanceRecord0?.setAttendance_session) - { - await - AttendanceRecord0. - setAttendance_session(relatedAttendance_session0); - } - - const relatedAttendance_session1 = await AttendanceSessions.findOne({ - offset: Math.floor(Math.random() * (await AttendanceSessions.count())), - }); - const AttendanceRecord1 = await AttendanceRecords.findOne({ - order: [['id', 'ASC']], - offset: 1 - }); - if (AttendanceRecord1?.setAttendance_session) - { - await - AttendanceRecord1. - setAttendance_session(relatedAttendance_session1); - } - - const relatedAttendance_session2 = await AttendanceSessions.findOne({ - offset: Math.floor(Math.random() * (await AttendanceSessions.count())), - }); - const AttendanceRecord2 = await AttendanceRecords.findOne({ - order: [['id', 'ASC']], - offset: 2 - }); - if (AttendanceRecord2?.setAttendance_session) - { - await - AttendanceRecord2. - setAttendance_session(relatedAttendance_session2); - } - - const relatedAttendance_session3 = await AttendanceSessions.findOne({ - offset: Math.floor(Math.random() * (await AttendanceSessions.count())), - }); - const AttendanceRecord3 = await AttendanceRecords.findOne({ - order: [['id', 'ASC']], - offset: 3 - }); - if (AttendanceRecord3?.setAttendance_session) - { - await - AttendanceRecord3. - setAttendance_session(relatedAttendance_session3); - } - - } - - - - - async function associateAttendanceRecordWithStudent() { - - const relatedStudent0 = await Students.findOne({ - offset: Math.floor(Math.random() * (await Students.count())), - }); - const AttendanceRecord0 = await AttendanceRecords.findOne({ - order: [['id', 'ASC']], - offset: 0 - }); - if (AttendanceRecord0?.setStudent) - { - await - AttendanceRecord0. - setStudent(relatedStudent0); - } - - const relatedStudent1 = await Students.findOne({ - offset: Math.floor(Math.random() * (await Students.count())), - }); - const AttendanceRecord1 = await AttendanceRecords.findOne({ - order: [['id', 'ASC']], - offset: 1 - }); - if (AttendanceRecord1?.setStudent) - { - await - AttendanceRecord1. - setStudent(relatedStudent1); - } - - const relatedStudent2 = await Students.findOne({ - offset: Math.floor(Math.random() * (await Students.count())), - }); - const AttendanceRecord2 = await AttendanceRecords.findOne({ - order: [['id', 'ASC']], - offset: 2 - }); - if (AttendanceRecord2?.setStudent) - { - await - AttendanceRecord2. - setStudent(relatedStudent2); - } - - const relatedStudent3 = await Students.findOne({ - offset: Math.floor(Math.random() * (await Students.count())), - }); - const AttendanceRecord3 = await AttendanceRecords.findOne({ - order: [['id', 'ASC']], - offset: 3 - }); - if (AttendanceRecord3?.setStudent) - { - await - AttendanceRecord3. - setStudent(relatedStudent3); - } - - } - - - - - - - - - - - - - - - async function associateFeePlanWithOrganization() { - - const relatedOrganization0 = await Organizations.findOne({ - offset: Math.floor(Math.random() * (await Organizations.count())), - }); - const FeePlan0 = await FeePlans.findOne({ - order: [['id', 'ASC']], - offset: 0 - }); - if (FeePlan0?.setOrganization) - { - await - FeePlan0. - setOrganization(relatedOrganization0); - } - - const relatedOrganization1 = await Organizations.findOne({ - offset: Math.floor(Math.random() * (await Organizations.count())), - }); - const FeePlan1 = await FeePlans.findOne({ - order: [['id', 'ASC']], - offset: 1 - }); - if (FeePlan1?.setOrganization) - { - await - FeePlan1. - setOrganization(relatedOrganization1); - } - - const relatedOrganization2 = await Organizations.findOne({ - offset: Math.floor(Math.random() * (await Organizations.count())), - }); - const FeePlan2 = await FeePlans.findOne({ - order: [['id', 'ASC']], - offset: 2 - }); - if (FeePlan2?.setOrganization) - { - await - FeePlan2. - setOrganization(relatedOrganization2); - } - - const relatedOrganization3 = await Organizations.findOne({ - offset: Math.floor(Math.random() * (await Organizations.count())), - }); - const FeePlan3 = await FeePlans.findOne({ - order: [['id', 'ASC']], - offset: 3 - }); - if (FeePlan3?.setOrganization) - { - await - FeePlan3. - setOrganization(relatedOrganization3); - } - - } - - - - - async function associateFeePlanWithAcademic_year() { - - const relatedAcademic_year0 = await AcademicYears.findOne({ - offset: Math.floor(Math.random() * (await AcademicYears.count())), - }); - const FeePlan0 = await FeePlans.findOne({ - order: [['id', 'ASC']], - offset: 0 - }); - if (FeePlan0?.setAcademic_year) - { - await - FeePlan0. - setAcademic_year(relatedAcademic_year0); - } - - const relatedAcademic_year1 = await AcademicYears.findOne({ - offset: Math.floor(Math.random() * (await AcademicYears.count())), - }); - const FeePlan1 = await FeePlans.findOne({ - order: [['id', 'ASC']], - offset: 1 - }); - if (FeePlan1?.setAcademic_year) - { - await - FeePlan1. - setAcademic_year(relatedAcademic_year1); - } - - const relatedAcademic_year2 = await AcademicYears.findOne({ - offset: Math.floor(Math.random() * (await AcademicYears.count())), - }); - const FeePlan2 = await FeePlans.findOne({ - order: [['id', 'ASC']], - offset: 2 - }); - if (FeePlan2?.setAcademic_year) - { - await - FeePlan2. - setAcademic_year(relatedAcademic_year2); - } - - const relatedAcademic_year3 = await AcademicYears.findOne({ - offset: Math.floor(Math.random() * (await AcademicYears.count())), - }); - const FeePlan3 = await FeePlans.findOne({ - order: [['id', 'ASC']], - offset: 3 - }); - if (FeePlan3?.setAcademic_year) - { - await - FeePlan3. - setAcademic_year(relatedAcademic_year3); - } - - } - - - - - async function associateFeePlanWithGrade() { - - const relatedGrade0 = await Grades.findOne({ - offset: Math.floor(Math.random() * (await Grades.count())), - }); - const FeePlan0 = await FeePlans.findOne({ - order: [['id', 'ASC']], - offset: 0 - }); - if (FeePlan0?.setGrade) - { - await - FeePlan0. - setGrade(relatedGrade0); - } - - const relatedGrade1 = await Grades.findOne({ - offset: Math.floor(Math.random() * (await Grades.count())), - }); - const FeePlan1 = await FeePlans.findOne({ - order: [['id', 'ASC']], - offset: 1 - }); - if (FeePlan1?.setGrade) - { - await - FeePlan1. - setGrade(relatedGrade1); - } - - const relatedGrade2 = await Grades.findOne({ - offset: Math.floor(Math.random() * (await Grades.count())), - }); - const FeePlan2 = await FeePlans.findOne({ - order: [['id', 'ASC']], - offset: 2 - }); - if (FeePlan2?.setGrade) - { - await - FeePlan2. - setGrade(relatedGrade2); - } - - const relatedGrade3 = await Grades.findOne({ - offset: Math.floor(Math.random() * (await Grades.count())), - }); - const FeePlan3 = await FeePlans.findOne({ - order: [['id', 'ASC']], - offset: 3 - }); - if (FeePlan3?.setGrade) - { - await - FeePlan3. - setGrade(relatedGrade3); - } - - } - - - - - - - - - - - - - - - - - - - async function associateInvoiceWithOrganization() { - - const relatedOrganization0 = await Organizations.findOne({ - offset: Math.floor(Math.random() * (await Organizations.count())), - }); - const Invoice0 = await Invoices.findOne({ - order: [['id', 'ASC']], - offset: 0 - }); - if (Invoice0?.setOrganization) - { - await - Invoice0. - setOrganization(relatedOrganization0); - } - - const relatedOrganization1 = await Organizations.findOne({ - offset: Math.floor(Math.random() * (await Organizations.count())), - }); - const Invoice1 = await Invoices.findOne({ - order: [['id', 'ASC']], - offset: 1 - }); - if (Invoice1?.setOrganization) - { - await - Invoice1. - setOrganization(relatedOrganization1); - } - - const relatedOrganization2 = await Organizations.findOne({ - offset: Math.floor(Math.random() * (await Organizations.count())), - }); - const Invoice2 = await Invoices.findOne({ - order: [['id', 'ASC']], - offset: 2 - }); - if (Invoice2?.setOrganization) - { - await - Invoice2. - setOrganization(relatedOrganization2); - } - - const relatedOrganization3 = await Organizations.findOne({ - offset: Math.floor(Math.random() * (await Organizations.count())), - }); - const Invoice3 = await Invoices.findOne({ - order: [['id', 'ASC']], - offset: 3 - }); - if (Invoice3?.setOrganization) - { - await - Invoice3. - setOrganization(relatedOrganization3); - } - - } - - - - - async function associateInvoiceWithCampu() { - - const relatedCampu0 = await Campuses.findOne({ - offset: Math.floor(Math.random() * (await Campuses.count())), - }); - const Invoice0 = await Invoices.findOne({ - order: [['id', 'ASC']], - offset: 0 - }); - if (Invoice0?.setCampu) - { - await - Invoice0. - setCampu(relatedCampu0); - } - - const relatedCampu1 = await Campuses.findOne({ - offset: Math.floor(Math.random() * (await Campuses.count())), - }); - const Invoice1 = await Invoices.findOne({ - order: [['id', 'ASC']], - offset: 1 - }); - if (Invoice1?.setCampu) - { - await - Invoice1. - setCampu(relatedCampu1); - } - - const relatedCampu2 = await Campuses.findOne({ - offset: Math.floor(Math.random() * (await Campuses.count())), - }); - const Invoice2 = await Invoices.findOne({ - order: [['id', 'ASC']], - offset: 2 - }); - if (Invoice2?.setCampu) - { - await - Invoice2. - setCampu(relatedCampu2); - } - - const relatedCampu3 = await Campuses.findOne({ - offset: Math.floor(Math.random() * (await Campuses.count())), - }); - const Invoice3 = await Invoices.findOne({ - order: [['id', 'ASC']], - offset: 3 - }); - if (Invoice3?.setCampu) - { - await - Invoice3. - setCampu(relatedCampu3); - } - - } - - - - - async function associateInvoiceWithStudent() { - - const relatedStudent0 = await Students.findOne({ - offset: Math.floor(Math.random() * (await Students.count())), - }); - const Invoice0 = await Invoices.findOne({ - order: [['id', 'ASC']], - offset: 0 - }); - if (Invoice0?.setStudent) - { - await - Invoice0. - setStudent(relatedStudent0); - } - - const relatedStudent1 = await Students.findOne({ - offset: Math.floor(Math.random() * (await Students.count())), - }); - const Invoice1 = await Invoices.findOne({ - order: [['id', 'ASC']], - offset: 1 - }); - if (Invoice1?.setStudent) - { - await - Invoice1. - setStudent(relatedStudent1); - } - - const relatedStudent2 = await Students.findOne({ - offset: Math.floor(Math.random() * (await Students.count())), - }); - const Invoice2 = await Invoices.findOne({ - order: [['id', 'ASC']], - offset: 2 - }); - if (Invoice2?.setStudent) - { - await - Invoice2. - setStudent(relatedStudent2); - } - - const relatedStudent3 = await Students.findOne({ - offset: Math.floor(Math.random() * (await Students.count())), - }); - const Invoice3 = await Invoices.findOne({ - order: [['id', 'ASC']], - offset: 3 - }); - if (Invoice3?.setStudent) - { - await - Invoice3. - setStudent(relatedStudent3); - } - - } - - - - - async function associateInvoiceWithFee_plan() { - - const relatedFee_plan0 = await FeePlans.findOne({ - offset: Math.floor(Math.random() * (await FeePlans.count())), - }); - const Invoice0 = await Invoices.findOne({ - order: [['id', 'ASC']], - offset: 0 - }); - if (Invoice0?.setFee_plan) - { - await - Invoice0. - setFee_plan(relatedFee_plan0); - } - - const relatedFee_plan1 = await FeePlans.findOne({ - offset: Math.floor(Math.random() * (await FeePlans.count())), - }); - const Invoice1 = await Invoices.findOne({ - order: [['id', 'ASC']], - offset: 1 - }); - if (Invoice1?.setFee_plan) - { - await - Invoice1. - setFee_plan(relatedFee_plan1); - } - - const relatedFee_plan2 = await FeePlans.findOne({ - offset: Math.floor(Math.random() * (await FeePlans.count())), - }); - const Invoice2 = await Invoices.findOne({ - order: [['id', 'ASC']], - offset: 2 - }); - if (Invoice2?.setFee_plan) - { - await - Invoice2. - setFee_plan(relatedFee_plan2); - } - - const relatedFee_plan3 = await FeePlans.findOne({ - offset: Math.floor(Math.random() * (await FeePlans.count())), - }); - const Invoice3 = await Invoices.findOne({ - order: [['id', 'ASC']], - offset: 3 - }); - if (Invoice3?.setFee_plan) - { - await - Invoice3. - setFee_plan(relatedFee_plan3); - } - - } - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - async function associatePaymentWithOrganization() { - - const relatedOrganization0 = await Organizations.findOne({ - offset: Math.floor(Math.random() * (await Organizations.count())), - }); - const Payment0 = await Payments.findOne({ - order: [['id', 'ASC']], - offset: 0 - }); - if (Payment0?.setOrganization) - { - await - Payment0. - setOrganization(relatedOrganization0); - } - - const relatedOrganization1 = await Organizations.findOne({ - offset: Math.floor(Math.random() * (await Organizations.count())), - }); - const Payment1 = await Payments.findOne({ - order: [['id', 'ASC']], - offset: 1 - }); - if (Payment1?.setOrganization) - { - await - Payment1. - setOrganization(relatedOrganization1); - } - - const relatedOrganization2 = await Organizations.findOne({ - offset: Math.floor(Math.random() * (await Organizations.count())), - }); - const Payment2 = await Payments.findOne({ - order: [['id', 'ASC']], - offset: 2 - }); - if (Payment2?.setOrganization) - { - await - Payment2. - setOrganization(relatedOrganization2); - } - - const relatedOrganization3 = await Organizations.findOne({ - offset: Math.floor(Math.random() * (await Organizations.count())), - }); - const Payment3 = await Payments.findOne({ - order: [['id', 'ASC']], - offset: 3 - }); - if (Payment3?.setOrganization) - { - await - Payment3. - setOrganization(relatedOrganization3); - } - - } - - - - - async function associatePaymentWithInvoice() { - - const relatedInvoice0 = await Invoices.findOne({ - offset: Math.floor(Math.random() * (await Invoices.count())), - }); - const Payment0 = await Payments.findOne({ - order: [['id', 'ASC']], - offset: 0 - }); - if (Payment0?.setInvoice) - { - await - Payment0. - setInvoice(relatedInvoice0); - } - - const relatedInvoice1 = await Invoices.findOne({ - offset: Math.floor(Math.random() * (await Invoices.count())), - }); - const Payment1 = await Payments.findOne({ - order: [['id', 'ASC']], - offset: 1 - }); - if (Payment1?.setInvoice) - { - await - Payment1. - setInvoice(relatedInvoice1); - } - - const relatedInvoice2 = await Invoices.findOne({ - offset: Math.floor(Math.random() * (await Invoices.count())), - }); - const Payment2 = await Payments.findOne({ - order: [['id', 'ASC']], - offset: 2 - }); - if (Payment2?.setInvoice) - { - await - Payment2. - setInvoice(relatedInvoice2); - } - - const relatedInvoice3 = await Invoices.findOne({ - offset: Math.floor(Math.random() * (await Invoices.count())), - }); - const Payment3 = await Payments.findOne({ - order: [['id', 'ASC']], - offset: 3 - }); - if (Payment3?.setInvoice) - { - await - Payment3. - setInvoice(relatedInvoice3); - } - - } - - - - - async function associatePaymentWithReceived_by() { - - const relatedReceived_by0 = await Staff.findOne({ - offset: Math.floor(Math.random() * (await Staff.count())), - }); - const Payment0 = await Payments.findOne({ - order: [['id', 'ASC']], - offset: 0 - }); - if (Payment0?.setReceived_by) - { - await - Payment0. - setReceived_by(relatedReceived_by0); - } - - const relatedReceived_by1 = await Staff.findOne({ - offset: Math.floor(Math.random() * (await Staff.count())), - }); - const Payment1 = await Payments.findOne({ - order: [['id', 'ASC']], - offset: 1 - }); - if (Payment1?.setReceived_by) - { - await - Payment1. - setReceived_by(relatedReceived_by1); - } - - const relatedReceived_by2 = await Staff.findOne({ - offset: Math.floor(Math.random() * (await Staff.count())), - }); - const Payment2 = await Payments.findOne({ - order: [['id', 'ASC']], - offset: 2 - }); - if (Payment2?.setReceived_by) - { - await - Payment2. - setReceived_by(relatedReceived_by2); - } - - const relatedReceived_by3 = await Staff.findOne({ - offset: Math.floor(Math.random() * (await Staff.count())), - }); - const Payment3 = await Payments.findOne({ - order: [['id', 'ASC']], - offset: 3 - }); - if (Payment3?.setReceived_by) - { - await - Payment3. - setReceived_by(relatedReceived_by3); - } - - } - - - - - - - - - - - - - - - - - - - - - - - async function associateAssessmentWithOrganization() { - - const relatedOrganization0 = await Organizations.findOne({ - offset: Math.floor(Math.random() * (await Organizations.count())), - }); - const Assessment0 = await Assessments.findOne({ - order: [['id', 'ASC']], - offset: 0 - }); - if (Assessment0?.setOrganization) - { - await - Assessment0. - setOrganization(relatedOrganization0); - } - - const relatedOrganization1 = await Organizations.findOne({ - offset: Math.floor(Math.random() * (await Organizations.count())), - }); - const Assessment1 = await Assessments.findOne({ - order: [['id', 'ASC']], - offset: 1 - }); - if (Assessment1?.setOrganization) - { - await - Assessment1. - setOrganization(relatedOrganization1); - } - - const relatedOrganization2 = await Organizations.findOne({ - offset: Math.floor(Math.random() * (await Organizations.count())), - }); - const Assessment2 = await Assessments.findOne({ - order: [['id', 'ASC']], - offset: 2 - }); - if (Assessment2?.setOrganization) - { - await - Assessment2. - setOrganization(relatedOrganization2); - } - - const relatedOrganization3 = await Organizations.findOne({ - offset: Math.floor(Math.random() * (await Organizations.count())), - }); - const Assessment3 = await Assessments.findOne({ - order: [['id', 'ASC']], - offset: 3 - }); - if (Assessment3?.setOrganization) - { - await - Assessment3. - setOrganization(relatedOrganization3); - } - - } - - - - - async function associateAssessmentWithClass_subject() { - - const relatedClass_subject0 = await ClassSubjects.findOne({ - offset: Math.floor(Math.random() * (await ClassSubjects.count())), - }); - const Assessment0 = await Assessments.findOne({ - order: [['id', 'ASC']], - offset: 0 - }); - if (Assessment0?.setClass_subject) - { - await - Assessment0. - setClass_subject(relatedClass_subject0); - } - - const relatedClass_subject1 = await ClassSubjects.findOne({ - offset: Math.floor(Math.random() * (await ClassSubjects.count())), - }); - const Assessment1 = await Assessments.findOne({ - order: [['id', 'ASC']], - offset: 1 - }); - if (Assessment1?.setClass_subject) - { - await - Assessment1. - setClass_subject(relatedClass_subject1); - } - - const relatedClass_subject2 = await ClassSubjects.findOne({ - offset: Math.floor(Math.random() * (await ClassSubjects.count())), - }); - const Assessment2 = await Assessments.findOne({ - order: [['id', 'ASC']], - offset: 2 - }); - if (Assessment2?.setClass_subject) - { - await - Assessment2. - setClass_subject(relatedClass_subject2); - } - - const relatedClass_subject3 = await ClassSubjects.findOne({ - offset: Math.floor(Math.random() * (await ClassSubjects.count())), - }); - const Assessment3 = await Assessments.findOne({ - order: [['id', 'ASC']], - offset: 3 - }); - if (Assessment3?.setClass_subject) - { - await - Assessment3. - setClass_subject(relatedClass_subject3); - } - - } - - - - - - - - - - - - - - - - - - - - - - - - - async function associateAssessmentResultWithOrganization() { - - const relatedOrganization0 = await Organizations.findOne({ - offset: Math.floor(Math.random() * (await Organizations.count())), - }); - const AssessmentResult0 = await AssessmentResults.findOne({ - order: [['id', 'ASC']], - offset: 0 - }); - if (AssessmentResult0?.setOrganization) - { - await - AssessmentResult0. - setOrganization(relatedOrganization0); - } - - const relatedOrganization1 = await Organizations.findOne({ - offset: Math.floor(Math.random() * (await Organizations.count())), - }); - const AssessmentResult1 = await AssessmentResults.findOne({ - order: [['id', 'ASC']], - offset: 1 - }); - if (AssessmentResult1?.setOrganization) - { - await - AssessmentResult1. - setOrganization(relatedOrganization1); - } - - const relatedOrganization2 = await Organizations.findOne({ - offset: Math.floor(Math.random() * (await Organizations.count())), - }); - const AssessmentResult2 = await AssessmentResults.findOne({ - order: [['id', 'ASC']], - offset: 2 - }); - if (AssessmentResult2?.setOrganization) - { - await - AssessmentResult2. - setOrganization(relatedOrganization2); - } - - const relatedOrganization3 = await Organizations.findOne({ - offset: Math.floor(Math.random() * (await Organizations.count())), - }); - const AssessmentResult3 = await AssessmentResults.findOne({ - order: [['id', 'ASC']], - offset: 3 - }); - if (AssessmentResult3?.setOrganization) - { - await - AssessmentResult3. - setOrganization(relatedOrganization3); - } - - } - - - - - async function associateAssessmentResultWithAssessment() { - - const relatedAssessment0 = await Assessments.findOne({ - offset: Math.floor(Math.random() * (await Assessments.count())), - }); - const AssessmentResult0 = await AssessmentResults.findOne({ - order: [['id', 'ASC']], - offset: 0 - }); - if (AssessmentResult0?.setAssessment) - { - await - AssessmentResult0. - setAssessment(relatedAssessment0); - } - - const relatedAssessment1 = await Assessments.findOne({ - offset: Math.floor(Math.random() * (await Assessments.count())), - }); - const AssessmentResult1 = await AssessmentResults.findOne({ - order: [['id', 'ASC']], - offset: 1 - }); - if (AssessmentResult1?.setAssessment) - { - await - AssessmentResult1. - setAssessment(relatedAssessment1); - } - - const relatedAssessment2 = await Assessments.findOne({ - offset: Math.floor(Math.random() * (await Assessments.count())), - }); - const AssessmentResult2 = await AssessmentResults.findOne({ - order: [['id', 'ASC']], - offset: 2 - }); - if (AssessmentResult2?.setAssessment) - { - await - AssessmentResult2. - setAssessment(relatedAssessment2); - } - - const relatedAssessment3 = await Assessments.findOne({ - offset: Math.floor(Math.random() * (await Assessments.count())), - }); - const AssessmentResult3 = await AssessmentResults.findOne({ - order: [['id', 'ASC']], - offset: 3 - }); - if (AssessmentResult3?.setAssessment) - { - await - AssessmentResult3. - setAssessment(relatedAssessment3); - } - - } - - - - - async function associateAssessmentResultWithStudent() { - - const relatedStudent0 = await Students.findOne({ - offset: Math.floor(Math.random() * (await Students.count())), - }); - const AssessmentResult0 = await AssessmentResults.findOne({ - order: [['id', 'ASC']], - offset: 0 - }); - if (AssessmentResult0?.setStudent) - { - await - AssessmentResult0. - setStudent(relatedStudent0); - } - - const relatedStudent1 = await Students.findOne({ - offset: Math.floor(Math.random() * (await Students.count())), - }); - const AssessmentResult1 = await AssessmentResults.findOne({ - order: [['id', 'ASC']], - offset: 1 - }); - if (AssessmentResult1?.setStudent) - { - await - AssessmentResult1. - setStudent(relatedStudent1); - } - - const relatedStudent2 = await Students.findOne({ - offset: Math.floor(Math.random() * (await Students.count())), - }); - const AssessmentResult2 = await AssessmentResults.findOne({ - order: [['id', 'ASC']], - offset: 2 - }); - if (AssessmentResult2?.setStudent) - { - await - AssessmentResult2. - setStudent(relatedStudent2); - } - - const relatedStudent3 = await Students.findOne({ - offset: Math.floor(Math.random() * (await Students.count())), - }); - const AssessmentResult3 = await AssessmentResults.findOne({ - order: [['id', 'ASC']], - offset: 3 - }); - if (AssessmentResult3?.setStudent) - { - await - AssessmentResult3. - setStudent(relatedStudent3); - } - - } - - - - - - - - - - - - - - - async function associateMessageWithOrganization() { - - const relatedOrganization0 = await Organizations.findOne({ - offset: Math.floor(Math.random() * (await Organizations.count())), - }); - const Message0 = await Messages.findOne({ - order: [['id', 'ASC']], - offset: 0 - }); - if (Message0?.setOrganization) - { - await - Message0. - setOrganization(relatedOrganization0); - } - - const relatedOrganization1 = await Organizations.findOne({ - offset: Math.floor(Math.random() * (await Organizations.count())), - }); - const Message1 = await Messages.findOne({ - order: [['id', 'ASC']], - offset: 1 - }); - if (Message1?.setOrganization) - { - await - Message1. - setOrganization(relatedOrganization1); - } - - const relatedOrganization2 = await Organizations.findOne({ - offset: Math.floor(Math.random() * (await Organizations.count())), - }); - const Message2 = await Messages.findOne({ - order: [['id', 'ASC']], - offset: 2 - }); - if (Message2?.setOrganization) - { - await - Message2. - setOrganization(relatedOrganization2); - } - - const relatedOrganization3 = await Organizations.findOne({ - offset: Math.floor(Math.random() * (await Organizations.count())), - }); - const Message3 = await Messages.findOne({ - order: [['id', 'ASC']], - offset: 3 - }); - if (Message3?.setOrganization) - { - await - Message3. - setOrganization(relatedOrganization3); - } - - } - - - - - async function associateMessageWithCampu() { - - const relatedCampu0 = await Campuses.findOne({ - offset: Math.floor(Math.random() * (await Campuses.count())), - }); - const Message0 = await Messages.findOne({ - order: [['id', 'ASC']], - offset: 0 - }); - if (Message0?.setCampu) - { - await - Message0. - setCampu(relatedCampu0); - } - - const relatedCampu1 = await Campuses.findOne({ - offset: Math.floor(Math.random() * (await Campuses.count())), - }); - const Message1 = await Messages.findOne({ - order: [['id', 'ASC']], - offset: 1 - }); - if (Message1?.setCampu) - { - await - Message1. - setCampu(relatedCampu1); - } - - const relatedCampu2 = await Campuses.findOne({ - offset: Math.floor(Math.random() * (await Campuses.count())), - }); - const Message2 = await Messages.findOne({ - order: [['id', 'ASC']], - offset: 2 - }); - if (Message2?.setCampu) - { - await - Message2. - setCampu(relatedCampu2); - } - - const relatedCampu3 = await Campuses.findOne({ - offset: Math.floor(Math.random() * (await Campuses.count())), - }); - const Message3 = await Messages.findOne({ - order: [['id', 'ASC']], - offset: 3 - }); - if (Message3?.setCampu) - { - await - Message3. - setCampu(relatedCampu3); - } - - } - - - - - async function associateMessageWithSent_by() { - - const relatedSent_by0 = await Users.findOne({ - offset: Math.floor(Math.random() * (await Users.count())), - }); - const Message0 = await Messages.findOne({ - order: [['id', 'ASC']], - offset: 0 - }); - if (Message0?.setSent_by) - { - await - Message0. - setSent_by(relatedSent_by0); - } - - const relatedSent_by1 = await Users.findOne({ - offset: Math.floor(Math.random() * (await Users.count())), - }); - const Message1 = await Messages.findOne({ - order: [['id', 'ASC']], - offset: 1 - }); - if (Message1?.setSent_by) - { - await - Message1. - setSent_by(relatedSent_by1); - } - - const relatedSent_by2 = await Users.findOne({ - offset: Math.floor(Math.random() * (await Users.count())), - }); - const Message2 = await Messages.findOne({ - order: [['id', 'ASC']], - offset: 2 - }); - if (Message2?.setSent_by) - { - await - Message2. - setSent_by(relatedSent_by2); - } - - const relatedSent_by3 = await Users.findOne({ - offset: Math.floor(Math.random() * (await Users.count())), - }); - const Message3 = await Messages.findOne({ - order: [['id', 'ASC']], - offset: 3 - }); - if (Message3?.setSent_by) - { - await - Message3. - setSent_by(relatedSent_by3); - } - - } - - - - - - - - - - - - - - - - - - - - - - - async function associateMessageRecipientWithOrganization() { - - const relatedOrganization0 = await Organizations.findOne({ - offset: Math.floor(Math.random() * (await Organizations.count())), - }); - const MessageRecipient0 = await MessageRecipients.findOne({ - order: [['id', 'ASC']], - offset: 0 - }); - if (MessageRecipient0?.setOrganization) - { - await - MessageRecipient0. - setOrganization(relatedOrganization0); - } - - const relatedOrganization1 = await Organizations.findOne({ - offset: Math.floor(Math.random() * (await Organizations.count())), - }); - const MessageRecipient1 = await MessageRecipients.findOne({ - order: [['id', 'ASC']], - offset: 1 - }); - if (MessageRecipient1?.setOrganization) - { - await - MessageRecipient1. - setOrganization(relatedOrganization1); - } - - const relatedOrganization2 = await Organizations.findOne({ - offset: Math.floor(Math.random() * (await Organizations.count())), - }); - const MessageRecipient2 = await MessageRecipients.findOne({ - order: [['id', 'ASC']], - offset: 2 - }); - if (MessageRecipient2?.setOrganization) - { - await - MessageRecipient2. - setOrganization(relatedOrganization2); - } - - const relatedOrganization3 = await Organizations.findOne({ - offset: Math.floor(Math.random() * (await Organizations.count())), - }); - const MessageRecipient3 = await MessageRecipients.findOne({ - order: [['id', 'ASC']], - offset: 3 - }); - if (MessageRecipient3?.setOrganization) - { - await - MessageRecipient3. - setOrganization(relatedOrganization3); - } - - } - - - - - async function associateMessageRecipientWithMessage() { - - const relatedMessage0 = await Messages.findOne({ - offset: Math.floor(Math.random() * (await Messages.count())), - }); - const MessageRecipient0 = await MessageRecipients.findOne({ - order: [['id', 'ASC']], - offset: 0 - }); - if (MessageRecipient0?.setMessage) - { - await - MessageRecipient0. - setMessage(relatedMessage0); - } - - const relatedMessage1 = await Messages.findOne({ - offset: Math.floor(Math.random() * (await Messages.count())), - }); - const MessageRecipient1 = await MessageRecipients.findOne({ - order: [['id', 'ASC']], - offset: 1 - }); - if (MessageRecipient1?.setMessage) - { - await - MessageRecipient1. - setMessage(relatedMessage1); - } - - const relatedMessage2 = await Messages.findOne({ - offset: Math.floor(Math.random() * (await Messages.count())), - }); - const MessageRecipient2 = await MessageRecipients.findOne({ - order: [['id', 'ASC']], - offset: 2 - }); - if (MessageRecipient2?.setMessage) - { - await - MessageRecipient2. - setMessage(relatedMessage2); - } - - const relatedMessage3 = await Messages.findOne({ - offset: Math.floor(Math.random() * (await Messages.count())), - }); - const MessageRecipient3 = await MessageRecipients.findOne({ - order: [['id', 'ASC']], - offset: 3 - }); - if (MessageRecipient3?.setMessage) - { - await - MessageRecipient3. - setMessage(relatedMessage3); - } - - } - - - - - - - - - - - - - - - - - - - - - async function associateDocumentWithOrganization() { - - const relatedOrganization0 = await Organizations.findOne({ - offset: Math.floor(Math.random() * (await Organizations.count())), - }); - const Document0 = await Documents.findOne({ - order: [['id', 'ASC']], - offset: 0 - }); - if (Document0?.setOrganization) - { - await - Document0. - setOrganization(relatedOrganization0); - } - - const relatedOrganization1 = await Organizations.findOne({ - offset: Math.floor(Math.random() * (await Organizations.count())), - }); - const Document1 = await Documents.findOne({ - order: [['id', 'ASC']], - offset: 1 - }); - if (Document1?.setOrganization) - { - await - Document1. - setOrganization(relatedOrganization1); - } - - const relatedOrganization2 = await Organizations.findOne({ - offset: Math.floor(Math.random() * (await Organizations.count())), - }); - const Document2 = await Documents.findOne({ - order: [['id', 'ASC']], - offset: 2 - }); - if (Document2?.setOrganization) - { - await - Document2. - setOrganization(relatedOrganization2); - } - - const relatedOrganization3 = await Organizations.findOne({ - offset: Math.floor(Math.random() * (await Organizations.count())), - }); - const Document3 = await Documents.findOne({ - order: [['id', 'ASC']], - offset: 3 - }); - if (Document3?.setOrganization) - { - await - Document3. - setOrganization(relatedOrganization3); - } - - } - - - - - async function associateDocumentWithCampu() { - - const relatedCampu0 = await Campuses.findOne({ - offset: Math.floor(Math.random() * (await Campuses.count())), - }); - const Document0 = await Documents.findOne({ - order: [['id', 'ASC']], - offset: 0 - }); - if (Document0?.setCampu) - { - await - Document0. - setCampu(relatedCampu0); - } - - const relatedCampu1 = await Campuses.findOne({ - offset: Math.floor(Math.random() * (await Campuses.count())), - }); - const Document1 = await Documents.findOne({ - order: [['id', 'ASC']], - offset: 1 - }); - if (Document1?.setCampu) - { - await - Document1. - setCampu(relatedCampu1); - } - - const relatedCampu2 = await Campuses.findOne({ - offset: Math.floor(Math.random() * (await Campuses.count())), - }); - const Document2 = await Documents.findOne({ - order: [['id', 'ASC']], - offset: 2 - }); - if (Document2?.setCampu) - { - await - Document2. - setCampu(relatedCampu2); - } - - const relatedCampu3 = await Campuses.findOne({ - offset: Math.floor(Math.random() * (await Campuses.count())), - }); - const Document3 = await Documents.findOne({ - order: [['id', 'ASC']], - offset: 3 - }); - if (Document3?.setCampu) - { - await - Document3. - setCampu(relatedCampu3); - } - - } - - - - - - - - - - - - - - - - - - -module.exports = { - up: async (queryInterface, Sequelize) => { - - - - - - - - await Organizations.bulkCreate(OrganizationsData); - - - - - await Campuses.bulkCreate(CampusesData); - - - - - await AcademicYears.bulkCreate(AcademicYearsData); - - - - - await Grades.bulkCreate(GradesData); - - - - - await Subjects.bulkCreate(SubjectsData); - - - - - await Students.bulkCreate(StudentsData); - - - - - await Guardians.bulkCreate(GuardiansData); - - - - - await Staff.bulkCreate(StaffData); - - - - - await Classes.bulkCreate(ClassesData); - - - - - await ClassEnrollments.bulkCreate(ClassEnrollmentsData); - - - - - await ClassSubjects.bulkCreate(ClassSubjectsData); - - - - - await Timetables.bulkCreate(TimetablesData); - - - - - await TimetablePeriods.bulkCreate(TimetablePeriodsData); - - - - - await AttendanceSessions.bulkCreate(AttendanceSessionsData); - - - - - await AttendanceRecords.bulkCreate(AttendanceRecordsData); - - - - - await FeePlans.bulkCreate(FeePlansData); - - - - - await Invoices.bulkCreate(InvoicesData); - - - - - await Payments.bulkCreate(PaymentsData); - - - - - await Assessments.bulkCreate(AssessmentsData); - - - - - await AssessmentResults.bulkCreate(AssessmentResultsData); - - - - - await Messages.bulkCreate(MessagesData); - - - - - await MessageRecipients.bulkCreate(MessageRecipientsData); - - - - - await Documents.bulkCreate(DocumentsData); - - - await Promise.all([ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - // Similar logic for "relation_many" - - - - - await associateUserWithOrganization(), - - - - - - - - - - - - - - - await associateCampusWithOrganization(), - - - - - - - - - - - - - - - - - - - - await associateAcademicYearWithOrganization(), - - - - - - - - - - - - - - - - await associateGradeWithOrganization(), - - - - - - - - - - - - - - - - await associateSubjectWithOrganization(), - - - - - - - - - - - - - - await associateStudentWithOrganization(), - - - - - await associateStudentWithCampu(), - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - await associateGuardianWithOrganization(), - - - - - await associateGuardianWithStudent(), - - - - - - - - - - - - - - - - - - - - await associateStaffWithOrganization(), - - - - - await associateStaffWithCampu(), - - - - - await associateStaffWithUser(), - - - - - - - - - - - - - - - - - - - - await associateClassWithOrganization(), - - - - - await associateClassWithCampu(), - - - - - await associateClassWithAcademic_year(), - - - - - await associateClassWithGrade(), - - - - - - - - - await associateClassWithHomeroom_teacher(), - - - - - - - - - - - - await associateClassEnrollmentWithOrganization(), - - - - - await associateClassEnrollmentWithClas(), - - - - - await associateClassEnrollmentWithStudent(), - - - - - - - - - - - - - - await associateClassSubjectWithOrganization(), - - - - - await associateClassSubjectWithClas(), - - - - - await associateClassSubjectWithSubject(), - - - - - await associateClassSubjectWithTeacher(), - - - - - - - - - - await associateTimetableWithOrganization(), - - - - - await associateTimetableWithCampu(), - - - - - await associateTimetableWithAcademic_year(), - - - - - - - - - - - - - - - - await associateTimetablePeriodWithOrganization(), - - - - - await associateTimetablePeriodWithTimetable(), - - - - - await associateTimetablePeriodWithClass_subject(), - - - - - - - - - - - - - - - - await associateAttendanceSessionWithOrganization(), - - - - - await associateAttendanceSessionWithCampu(), - - - - - await associateAttendanceSessionWithClas(), - - - - - await associateAttendanceSessionWithClass_subject(), - - - - - await associateAttendanceSessionWithTaken_by(), - - - - - - - - - - - - - - await associateAttendanceRecordWithOrganization(), - - - - - await associateAttendanceRecordWithAttendance_session(), - - - - - await associateAttendanceRecordWithStudent(), - - - - - - - - - - - - - - await associateFeePlanWithOrganization(), - - - - - await associateFeePlanWithAcademic_year(), - - - - - await associateFeePlanWithGrade(), - - - - - - - - - - - - - - - - - - await associateInvoiceWithOrganization(), - - - - - await associateInvoiceWithCampu(), - - - - - await associateInvoiceWithStudent(), - - - - - await associateInvoiceWithFee_plan(), - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - await associatePaymentWithOrganization(), - - - - - await associatePaymentWithInvoice(), - - - - - await associatePaymentWithReceived_by(), - - - - - - - - - - - - - - - - - - - - - - await associateAssessmentWithOrganization(), - - - - - await associateAssessmentWithClass_subject(), - - - - - - - - - - - - - - - - - - - - - - - - await associateAssessmentResultWithOrganization(), - - - - - await associateAssessmentResultWithAssessment(), - - - - - await associateAssessmentResultWithStudent(), - - - - - - - - - - - - - - await associateMessageWithOrganization(), - - - - - await associateMessageWithCampu(), - - - - - await associateMessageWithSent_by(), - - - - - - - - - - - - - - - - - - - - - - await associateMessageRecipientWithOrganization(), - - - - - await associateMessageRecipientWithMessage(), - - - - - - - - - - - - - - - - - - - - await associateDocumentWithOrganization(), - - - - - await associateDocumentWithCampu(), - - - - - - - - - - - - - - - - - - ]); - - }, - - down: async (queryInterface, Sequelize) => { - - - - - - - await queryInterface.bulkDelete('organizations', null, {}); - - - await queryInterface.bulkDelete('campuses', null, {}); - - - await queryInterface.bulkDelete('academic_years', null, {}); - - - await queryInterface.bulkDelete('grades', null, {}); - - - await queryInterface.bulkDelete('subjects', null, {}); - - - await queryInterface.bulkDelete('students', null, {}); - - - await queryInterface.bulkDelete('guardians', null, {}); - - - await queryInterface.bulkDelete('staff', null, {}); - - - await queryInterface.bulkDelete('classes', null, {}); - - - await queryInterface.bulkDelete('class_enrollments', null, {}); - - - await queryInterface.bulkDelete('class_subjects', null, {}); - - - await queryInterface.bulkDelete('timetables', null, {}); - - - await queryInterface.bulkDelete('timetable_periods', null, {}); - - - await queryInterface.bulkDelete('attendance_sessions', null, {}); - - - await queryInterface.bulkDelete('attendance_records', null, {}); - - - await queryInterface.bulkDelete('fee_plans', null, {}); - - - await queryInterface.bulkDelete('invoices', null, {}); - - - await queryInterface.bulkDelete('payments', null, {}); - - - await queryInterface.bulkDelete('assessments', null, {}); - - - await queryInterface.bulkDelete('assessment_results', null, {}); - - - await queryInterface.bulkDelete('messages', null, {}); - - - await queryInterface.bulkDelete('message_recipients', null, {}); - - - await queryInterface.bulkDelete('documents', null, {}); - - - }, -}; \ No newline at end of file diff --git a/backend/src/db/seeders/20260608100000-product-campuses.js b/backend/src/db/seeders/20260608100000-product-campuses.js new file mode 100644 index 0000000..5f34705 --- /dev/null +++ b/backend/src/db/seeders/20260608100000-product-campuses.js @@ -0,0 +1,24 @@ +'use strict'; + +const { PRODUCT_CAMPUS_SEED_ROWS } = require('../../constants/campuses'); + +module.exports = { + up: async (queryInterface) => { + const createdAt = new Date(); + const updatedAt = new Date(); + + await queryInterface.bulkInsert('campuses', PRODUCT_CAMPUS_SEED_ROWS.map((campus) => ({ + ...campus, + createdAt, + updatedAt, + }))); + }, + + down: async (queryInterface, Sequelize) => { + await queryInterface.bulkDelete('campuses', { + id: { + [Sequelize.Op.in]: PRODUCT_CAMPUS_SEED_ROWS.map((campus) => campus.id), + }, + }); + }, +}; diff --git a/backend/src/db/seeders/20260608103000-content-catalog.js b/backend/src/db/seeders/20260608103000-content-catalog.js new file mode 100644 index 0000000..61e9d07 --- /dev/null +++ b/backend/src/db/seeders/20260608103000-content-catalog.js @@ -0,0 +1,63 @@ +'use strict'; + +const { CONTENT_CATALOG_SEED_PAYLOADS } = require('./content-catalog-data/content-catalog-seed-payloads'); + +const CONTENT_CATALOG_SEED_ROWS = Object.freeze([ + { content_type: 'classroom-strategies', payload: CONTENT_CATALOG_SEED_PAYLOADS.classroomStrategies }, + { content_type: 'safety-qbs-quiz', payload: CONTENT_CATALOG_SEED_PAYLOADS.safetyQbsQuiz }, + { content_type: 'sign-language-items', payload: CONTENT_CATALOG_SEED_PAYLOADS.signLanguageItems }, + { content_type: 'sign-language-page-content', payload: CONTENT_CATALOG_SEED_PAYLOADS.signLanguagePageContent }, + { content_type: 'regulation-zones', payload: CONTENT_CATALOG_SEED_PAYLOADS.regulationZones }, + { content_type: 'zones-of-regulation-page-content', payload: CONTENT_CATALOG_SEED_PAYLOADS.zonesOfRegulationPageContent }, + { content_type: 'dashboard-teacher-images', payload: CONTENT_CATALOG_SEED_PAYLOADS.dashboardTeacherImages }, + { content_type: 'dashboard-encouraging-quotes', payload: CONTENT_CATALOG_SEED_PAYLOADS.dashboardEncouragingQuotes }, + { content_type: 'dashboard-compliance-items', payload: CONTENT_CATALOG_SEED_PAYLOADS.dashboardComplianceItems }, + { content_type: 'dashboard-sign-of-week', payload: CONTENT_CATALOG_SEED_PAYLOADS.dashboardSignOfWeek }, + { content_type: 'parent-message-templates', payload: CONTENT_CATALOG_SEED_PAYLOADS.parentMessageTemplates }, + { content_type: 'community-organizations', payload: CONTENT_CATALOG_SEED_PAYLOADS.communityOrganizations }, + { content_type: 'vocational-opportunities', payload: CONTENT_CATALOG_SEED_PAYLOADS.vocationalOpportunities }, + { content_type: 'emotional-intelligence-assessment-questions', payload: CONTENT_CATALOG_SEED_PAYLOADS.emotionalIntelligenceAssessmentQuestions }, + { content_type: 'emotional-intelligence-weekly-topics', payload: CONTENT_CATALOG_SEED_PAYLOADS.emotionalIntelligenceWeeklyTopics }, + { content_type: 'emotional-intelligence-growth-tips', payload: CONTENT_CATALOG_SEED_PAYLOADS.emotionalIntelligenceGrowthTips }, + { content_type: 'emotional-intelligence-team-wellness-metrics', payload: CONTENT_CATALOG_SEED_PAYLOADS.emotionalIntelligenceTeamWellnessMetrics }, + { content_type: 'personality-quiz-questions', payload: CONTENT_CATALOG_SEED_PAYLOADS.personalityQuizQuestions }, + { content_type: 'personality-types', payload: CONTENT_CATALOG_SEED_PAYLOADS.personalityTypes }, + { content_type: 'esa-funding-content', payload: CONTENT_CATALOG_SEED_PAYLOADS.esaFundingContent }, + { content_type: 'safety-protocols', payload: CONTENT_CATALOG_SEED_PAYLOADS.safetyProtocols }, + { content_type: 'classroom-timer-backgrounds', payload: CONTENT_CATALOG_SEED_PAYLOADS.classroomTimerBackgrounds }, + { content_type: 'classroom-timer-sounds', payload: CONTENT_CATALOG_SEED_PAYLOADS.classroomTimerSounds }, + { content_type: 'classroom-timer-presets', payload: CONTENT_CATALOG_SEED_PAYLOADS.classroomTimerPresets }, + { content_type: 'classroom-timer-tips', payload: CONTENT_CATALOG_SEED_PAYLOADS.classroomTimerTips }, + { content_type: 'personality-quiz-features', payload: CONTENT_CATALOG_SEED_PAYLOADS.personalityQuizFeatures }, + { content_type: 'emotional-intelligence-weekly-focus', payload: CONTENT_CATALOG_SEED_PAYLOADS.emotionalIntelligenceWeeklyFocus }, + { content_type: 'personality-workplace-content', payload: CONTENT_CATALOG_SEED_PAYLOADS.personalityWorkplaceContent }, +]); + +module.exports = { + up: async (queryInterface, Sequelize) => { + const now = new Date(); + const contentTypes = CONTENT_CATALOG_SEED_ROWS.map((row) => row.content_type); + + await queryInterface.bulkDelete('content_catalog', { + content_type: { + [Sequelize.Op.in]: contentTypes, + }, + }); + + await queryInterface.bulkInsert('content_catalog', CONTENT_CATALOG_SEED_ROWS.map((row) => ({ + ...row, + active: true, + importHash: 'content-catalog-' + row.content_type, + createdAt: now, + updatedAt: now, + }))); + }, + + down: async (queryInterface, Sequelize) => { + await queryInterface.bulkDelete('content_catalog', { + content_type: { + [Sequelize.Op.in]: CONTENT_CATALOG_SEED_ROWS.map((row) => row.content_type), + }, + }); + }, +}; diff --git a/backend/src/db/seeders/content-catalog-data/content-catalog-seed-payloads.js b/backend/src/db/seeders/content-catalog-data/content-catalog-seed-payloads.js new file mode 100644 index 0000000..b613f56 --- /dev/null +++ b/backend/src/db/seeders/content-catalog-data/content-catalog-seed-payloads.js @@ -0,0 +1,1502 @@ +'use strict'; + +const CLASSROOM_STRATEGY_IMPLEMENTATION_TIP = + 'Start with one student or one transition period. Once comfortable, expand to the full classroom. Consistency is key: use the same visual and verbal cues each time.'; + +const CONTENT_CATALOG_SEED_PAYLOADS = Object.freeze({ + classroomStrategies: [ + { + id: '1', title: 'Visual Schedule Boards', description: 'Use picture-based schedules to help students predict and prepare for daily activities. Place at eye level and reference frequently.', + category: 'visual-support', ageGroup: 'All', zone: 'green', + image: 'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1770658833077_9b6461a9.jpg', + implementationTip: CLASSROOM_STRATEGY_IMPLEMENTATION_TIP + }, + { + id: '2', title: 'First/Then Board', description: 'Show students what they need to do first, then what preferred activity follows. Builds motivation and reduces anxiety about tasks.', + category: 'visual-support', ageGroup: 'K-2', zone: 'green', + image: 'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1770658868373_09b2170b.jpg', + implementationTip: CLASSROOM_STRATEGY_IMPLEMENTATION_TIP + }, + { + id: '3', title: 'Sensory Break Station', description: 'Designate a calm area with fidgets, weighted items, noise-canceling headphones, and dim lighting for self-regulation.', + category: 'sensory', ageGroup: 'All', zone: 'yellow', + image: 'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1770658913969_250b3efa.jpg', + implementationTip: CLASSROOM_STRATEGY_IMPLEMENTATION_TIP + }, + { + id: '4', title: 'Transition Countdown Timer', description: 'Use a visual timer (sand timer or digital) to give 5-3-1 minute warnings before activity changes. Reduces transition anxiety.', + category: 'transition', ageGroup: 'All', zone: 'yellow', + image: 'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1770658981340_d834abc1.png', + implementationTip: CLASSROOM_STRATEGY_IMPLEMENTATION_TIP + }, + { + id: '5', title: 'Social Story Cards', description: 'Short, illustrated stories that explain social situations, expected behaviors, and appropriate responses in specific scenarios.', + category: 'social', ageGroup: '3-5', zone: 'green', + image: 'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1770658995178_40196c48.jpg', + implementationTip: CLASSROOM_STRATEGY_IMPLEMENTATION_TIP + }, + { + id: '6', title: 'Token Economy System', description: 'Reward system using tokens earned for desired behaviors, exchangeable for preferred activities or items. Keep it simple and visual.', + category: 'behavior', ageGroup: 'K-2', zone: 'green', + image: 'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1770659011984_838f549a.jpg', + implementationTip: CLASSROOM_STRATEGY_IMPLEMENTATION_TIP + }, + { + id: '7', title: 'Choice Board', description: 'Offer 2-3 visual choices for activities, reducing demand avoidance and increasing autonomy. Great for non-verbal students.', + category: 'communication', ageGroup: 'All', zone: 'green', + image: 'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1770659031644_b34871e1.jpg', + implementationTip: CLASSROOM_STRATEGY_IMPLEMENTATION_TIP + }, + { + id: '8', title: 'Movement Break Protocol', description: 'Scheduled 2-3 minute movement breaks every 20 minutes. Include jumping, stretching, or wall push-ups to regulate energy.', + category: 'sensory', ageGroup: 'All', zone: 'yellow', + image: 'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1770659048406_2603ea14.jpg', + implementationTip: CLASSROOM_STRATEGY_IMPLEMENTATION_TIP + }, + { + id: '9', title: 'Calm Down Kit', description: 'Portable kit with stress ball, breathing card, favorite texture item, and visual coping steps. Personalize for each student.', + category: 'sensory', ageGroup: 'K-2', zone: 'red', + image: 'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1770659068729_0f0aeb1d.png', + implementationTip: CLASSROOM_STRATEGY_IMPLEMENTATION_TIP + }, + { + id: '10', title: 'Visual Boundary Markers', description: 'Use tape, mats, or colored zones on the floor to define personal space and activity areas. Helps with spatial awareness.', + category: 'visual-support', ageGroup: 'All', zone: 'green', + image: 'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1770658996427_bb199e79.jpg', + implementationTip: CLASSROOM_STRATEGY_IMPLEMENTATION_TIP + }, + { + id: '11', title: 'Peer Buddy System', description: 'Pair students for structured activities. Train buddies on patience, simple prompts, and when to get adult help.', + category: 'social', ageGroup: '3-5', zone: 'green', + image: 'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1770659033459_04837bc4.jpg', + implementationTip: CLASSROOM_STRATEGY_IMPLEMENTATION_TIP + }, + { + id: '12', title: 'De-escalation Voice Protocol', description: 'Use low, slow, calm voice. Reduce words. Give space. Avoid questions during escalation. Wait for the student to reach yellow zone before processing.', + category: 'behavior', ageGroup: 'All', zone: 'red', + image: 'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1770659012324_2e0b2c92.jpg', + implementationTip: CLASSROOM_STRATEGY_IMPLEMENTATION_TIP + }, +], + safetyQbsQuiz: { + id: '1', + title: 'De-Escalation Techniques Review', + focus: 'de-escalation', + weeklyFocus: { + title: 'This Week\'s Focus: De-Escalation Techniques', + description: 'Remember: Reduce demands first. Use low, slow, calm voice. Minimal words. Give space. Do NOT process during crisis.', + }, + keyReminders: [ + 'Physical management is a LAST resort', + 'Always reduce demands before escalation', + 'Use low, slow, calm voice', + 'Do NOT process during crisis', + 'Document within 24 hours', + ], + questions: [ + { + id: 'q1', + question: 'What is the FIRST step when a student begins showing signs of escalation?', + options: ['Physically redirect the student', 'Assess the environment and reduce demands', 'Call for backup immediately', 'Begin documentation'], + correctIndex: 1, + explanation: 'Reducing environmental demands and assessing triggers is always the first response before any physical intervention.' + }, + { + id: 'q2', + question: 'During a crisis, which voice technique is most effective?', + options: ['Firm, authoritative tone', 'Low, slow, and calm with minimal words', 'Matching the student\'s volume to be heard', 'Silent treatment until they calm down'], + correctIndex: 1, + explanation: 'A low, slow, calm voice with minimal words reduces stimulation and models regulation.' + }, + { + id: 'q3', + question: 'When is physical management appropriate according to de-escalation protocols?', + options: ['When someone refuses to follow directions', 'Only when there is imminent danger of harm', 'Whenever verbal de-escalation fails', 'During any aggressive behavior'], + correctIndex: 1, + explanation: 'Physical management is a last resort, used ONLY when there is imminent danger of harm to the individual or others.' + + }, + { + id: 'q4', + question: 'What should you do AFTER a crisis has de-escalated?', + options: ['Immediately discuss what happened with the student', 'Allow recovery time, then document and debrief', 'Send the student to the office', 'Resume normal activities immediately'], + correctIndex: 1, + explanation: 'Recovery time is essential. Document the incident, debrief with your team, and allow the student to return to baseline.' + }, + { + id: 'q5', + question: 'Which Zone of Regulation typically indicates a student is approaching crisis?', + options: ['Blue Zone', 'Green Zone', 'Yellow Zone', 'Red Zone'], + correctIndex: 2, + explanation: 'Yellow Zone is the warning zone — intervening here with de-escalation strategies can prevent reaching Red Zone crisis.' + }, + ] +}, + signLanguageItems: [ + { + id: '1', word: 'Help', category: 'basic-needs', + description: 'Flat hand on top of fist, lift both up together', + image: 'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1774037791874_e080660e.png', + tip: 'Teach this sign first — it reduces frustration-based behaviors immediately.', + videoUrl: 'https://www.youtube.com/embed/Euz1g9E-Mrw', + gifUrl: 'https://www.lifeprint.com/asl101/gifs/h/help.gif', + videoSteps: [ + { step: 1, instruction: 'Make a fist with your non-dominant hand and hold it at chest level', duration: 3 }, + { step: 2, instruction: 'Place your dominant hand flat on top of the fist, palm facing up', duration: 3 }, + { step: 3, instruction: 'Lift both hands upward together in one smooth motion', duration: 3 }, + { step: 4, instruction: 'Repeat the upward motion to emphasize the sign', duration: 3 }, + ] + }, + { + id: '2', word: 'More', category: 'basic-needs', + description: 'Fingertips of both hands touch together repeatedly', + image: 'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1774037809931_bf2125b1.jpg', + tip: 'Accept any approximation. Respond immediately to reinforce communication.', + videoUrl: 'https://www.youtube.com/embed/fVkzBbQ-whs', + gifUrl: 'https://www.lifeprint.com/asl101/gifs/m/more.gif', + videoSteps: [ + { step: 1, instruction: 'Hold both hands in front of you with fingers pinched together', duration: 3 }, + { step: 2, instruction: 'Bring the fingertips of both hands together so they touch', duration: 3 }, + { step: 3, instruction: 'Separate your hands slightly, then tap fingertips together again', duration: 3 }, + { step: 4, instruction: 'Repeat the tapping motion 2-3 times', duration: 3 }, + ] + }, + { + id: '3', word: 'All Done', category: 'basic-needs', + description: 'Both hands open, palms facing you, flip outward', + image: 'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1774037826231_d4a6656b.jpg', + tip: 'Pair with visual "finished" card. Great for ending non-preferred activities.', + videoUrl: 'https://www.youtube.com/embed/7xQE2N0z7gM', + gifUrl: 'https://www.lifeprint.com/asl101/gifs/f/finish.gif', + videoSteps: [ + { step: 1, instruction: 'Hold both hands up at chest level with palms facing toward you', duration: 3 }, + { step: 2, instruction: 'Spread your fingers wide open', duration: 3 }, + { step: 3, instruction: 'Rotate both hands outward so palms face away from you', duration: 3 }, + { step: 4, instruction: 'Complete the flip in one smooth motion — this means "all done"', duration: 3 }, + ] + }, + { + id: '4', word: 'Break', category: 'basic-needs', + description: 'Both hands together, pull apart like breaking a stick', + image: 'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1774037847928_f8f28226.png', + tip: 'Teaching "break" proactively prevents escalation. Honor the request when possible.', + videoUrl: 'https://www.youtube.com/embed/q6LuW4Sp_XM', + gifUrl: 'https://www.lifeprint.com/asl101/images-signs/break.gif', + videoSteps: [ + { step: 1, instruction: 'Hold both fists together in front of your chest, touching', duration: 3 }, + { step: 2, instruction: 'Grip as if holding a stick or twig between both hands', duration: 3 }, + { step: 3, instruction: 'Twist and pull your hands apart as if snapping a stick', duration: 3 }, + { step: 4, instruction: 'End with hands separated — the breaking motion signals "break"', duration: 3 }, + ] + }, + { + id: '5', word: 'Stop', category: 'emotional', + description: 'One flat hand strikes the palm of the other hand', + image: 'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1774037865100_963f8d4a.jpg', + tip: 'Use in Red Zone situations. Pair with visual stop sign card.', + videoUrl: 'https://www.youtube.com/embed/RhQvlq-mZtA', + gifUrl: 'https://www.lifeprint.com/asl101/gifs/s/stop.gif', + videoSteps: [ + { step: 1, instruction: 'Hold your non-dominant hand out flat, palm facing up', duration: 3 }, + { step: 2, instruction: 'Raise your dominant hand with fingers together, palm facing down', duration: 3 }, + { step: 3, instruction: 'Bring your dominant hand down firmly onto the open palm', duration: 3 }, + { step: 4, instruction: 'Make the motion crisp and decisive to clearly communicate "stop"', duration: 3 }, + ] + }, + { + id: '6', word: 'Calm', category: 'emotional', + description: 'Both hands move down slowly from chest level', + image: 'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1774037891102_4977dae4.png', + tip: 'Model this sign while taking deep breaths. Others mirror the regulation.', + videoUrl: 'https://www.youtube.com/embed/RhQvlq-mZtA', + gifUrl: 'https://www.lifeprint.com/asl101/images-signs/calm.gif', + videoSteps: [ + { step: 1, instruction: 'Hold both hands at chest level, palms facing downward', duration: 3 }, + { step: 2, instruction: 'Take a slow, deep breath in as you hold position', duration: 3 }, + { step: 3, instruction: 'Slowly push both hands downward while exhaling', duration: 3 }, + { step: 4, instruction: 'Repeat the slow downward motion — model calm breathing with it', duration: 3 }, + ] + }, + { + id: '7', word: 'Eat', category: 'basic-needs', + description: 'Fingertips to mouth, tapping gently', + image: 'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1774037923119_35eee41c.png', + tip: 'Use before meals and snacks to build routine communication.', + videoUrl: 'https://www.youtube.com/embed/q6LuW4Sp_XM', + gifUrl: 'https://www.lifeprint.com/asl101/gifs/e/eat.gif', + videoSteps: [ + { step: 1, instruction: 'Bring your dominant hand up near your mouth', duration: 3 }, + { step: 2, instruction: 'Pinch your fingertips together as if holding small food', duration: 3 }, + { step: 3, instruction: 'Tap your fingertips gently against your lips', duration: 3 }, + { step: 4, instruction: 'Repeat the tapping motion 2-3 times to sign "eat"', duration: 3 }, + ] + }, + { + id: '8', word: 'Drink', category: 'basic-needs', + description: 'Thumb to mouth, tilting like drinking from a cup', + image: 'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1774037939928_1882c801.jpg', + tip: 'Pair with actual cup or water visual for stronger association.', + videoUrl: 'https://www.youtube.com/embed/q6LuW4Sp_XM', + gifUrl: 'https://www.lifeprint.com/asl101/gifs/d/drink-c.gif', + videoSteps: [ + { step: 1, instruction: 'Cup your dominant hand as if holding an invisible cup', duration: 3 }, + { step: 2, instruction: 'Bring the cupped hand up to your mouth', duration: 3 }, + { step: 3, instruction: 'Tilt your hand as if pouring a drink into your mouth', duration: 3 }, + { step: 4, instruction: 'Lower your hand and repeat — the tilting motion means "drink"', duration: 3 }, + ] + }, + { + id: '9', word: 'Wait', category: 'classroom', + description: 'Both hands up, fingers wiggling slightly', + image: 'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1774037963758_057baa79.png', + tip: 'Use with visual timer. "Wait" is hard — pair with a clear end signal.', + videoUrl: 'https://www.youtube.com/embed/YfwgS9ZsBVw', + gifUrl: 'https://www.lifeprint.com/asl101/gifs/w/wait.gif', + videoSteps: [ + { step: 1, instruction: 'Hold both hands up in front of you, palms facing outward', duration: 3 }, + { step: 2, instruction: 'Spread your fingers apart', duration: 3 }, + { step: 3, instruction: 'Wiggle your fingers gently in a wave-like motion', duration: 3 }, + { step: 4, instruction: 'Hold the position briefly — the wiggling fingers signal "wait"', duration: 3 }, + ] + }, + { + id: '10', word: 'Sit', category: 'classroom', + description: 'Two fingers of one hand sit on two fingers of the other', + image: 'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1774037980919_43f7a0c8.jpg', + tip: 'Model while pointing to the chair. Keep it simple and consistent.', + videoUrl: 'https://www.youtube.com/embed/ZvzKTn4qWfA', + gifUrl: 'https://www.lifeprint.com/asl101/gifs/s/sit.gif', + videoSteps: [ + { step: 1, instruction: 'Extend your index and middle fingers on your non-dominant hand (like a flat surface)', duration: 3 }, + { step: 2, instruction: 'Extend your index and middle fingers on your dominant hand (like legs)', duration: 3 }, + { step: 3, instruction: 'Place the "legs" fingers on top of the "surface" fingers, like sitting', duration: 3 }, + { step: 4, instruction: 'Tap down gently once or twice to emphasize "sit"', duration: 3 }, + ] + }, + { + id: '11', word: 'Listen', category: 'classroom', + description: 'Cup hand behind ear', + image: 'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1774037998820_245a2c77.jpg', + tip: 'Pair with visual "ears listening" icon on the board.', + videoUrl: 'https://www.youtube.com/embed/RhQvlq-mZtA', + gifUrl: 'https://www.lifeprint.com/asl101/images-signs/listen.gif', + videoSteps: [ + { step: 1, instruction: 'Raise your dominant hand up to the side of your head', duration: 3 }, + { step: 2, instruction: 'Cup your hand with fingers together, like catching sound', duration: 3 }, + { step: 3, instruction: 'Place your cupped hand just behind your ear', duration: 3 }, + { step: 4, instruction: 'Tilt your head slightly toward the hand — this means "listen"', duration: 3 }, + ] + }, + { + id: '12', word: 'Happy', category: 'emotional', + description: 'Flat hand circles over chest, moving upward', + image: 'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1774038017484_9fa3ff9d.jpg', + tip: 'Use during zone check-ins. "Are you happy? Show me the sign."', + videoUrl: 'https://www.youtube.com/embed/ZXHHO_DY6_A', + gifUrl: 'https://www.lifeprint.com/asl101/gifs/h/happy.gif', + videoSteps: [ + { step: 1, instruction: 'Place your flat, open hand on your chest', duration: 3 }, + { step: 2, instruction: 'Begin moving your hand in a circular motion on your chest', duration: 3 }, + { step: 3, instruction: 'Brush upward and outward repeatedly in circular strokes', duration: 3 }, + { step: 4, instruction: 'Smile while signing — the upward motion represents happiness rising', duration: 3 }, + ] + }, +], + signLanguagePageContent: { + rememberTitle: 'Remember', + rememberDescription: 'Accept attempts. Respond immediately. Pair sign with verbal language. Approximation over perfection. Consistency over fluency.', + }, + regulationZones: [ + { + color: 'blue', + name: 'Blue Zone', + description: 'Low energy states — feeling drained, unmotivated, fatigued, or disconnected', + behaviors: ['Feeling withdrawn or disengaged', 'Low motivation or energy', 'Difficulty starting tasks', 'Feeling emotionally flat or numb', 'Avoiding interactions with colleagues'], + strategies: ['Take a short walk or get fresh air', 'Have a warm drink or water', 'Check in with a trusted colleague', 'Start with a small, manageable task', 'Practice a brief mindfulness exercise'], + signs: ['Help', 'Break', 'Tired'], + bgClass: 'bg-blue-100', + textClass: 'text-blue-700', + borderClass: 'border-blue-300', + }, + { + color: 'green', + name: 'Green Zone', + description: 'Calm, focused, and productive — feeling balanced, content, and in control', + behaviors: ['Engaged and productive at work', 'Communicating effectively', 'Responding calmly to challenges', 'Maintaining healthy boundaries', 'Collaborating well with team members'], + strategies: ['Maintain your current routine', 'Acknowledge your positive state', 'Use this energy to tackle challenging tasks', 'Support a colleague who may be struggling', 'Reflect on what is keeping you in this zone'], + signs: ['Happy', 'More', 'Listen'], + bgClass: 'bg-green-100', + textClass: 'text-green-700', + borderClass: 'border-green-300', + }, + { + color: 'yellow', + name: 'Yellow Zone', + description: 'Heightened energy — feeling stressed, anxious, frustrated, or overwhelmed', + behaviors: ['Feeling irritable or short-tempered', 'Racing thoughts or difficulty concentrating', 'Talking faster or louder than usual', 'Feeling overwhelmed by workload', 'Becoming impatient with others'], + strategies: ['Step away for a 5-minute break', 'Practice deep breathing (4-7-8 method)', 'Prioritize tasks — focus on one thing at a time', 'Talk to a colleague or supervisor', 'Use grounding techniques (5 senses exercise)'], + signs: ['Break', 'Wait', 'Calm'], + bgClass: 'bg-yellow-100', + textClass: 'text-yellow-700', + borderClass: 'border-yellow-300', + }, + { + color: 'red', + name: 'Red Zone', + description: 'Extreme energy — feeling overwhelmed, angry, panicked, or at a breaking point', + behaviors: ['Intense frustration or anger', 'Feeling unable to cope', 'Wanting to walk out or shut down', 'Difficulty controlling emotional responses', 'Physical tension (clenched jaw, tight shoulders)'], + strategies: ['Remove yourself from the situation immediately', 'Find a quiet space to decompress', 'Use slow, deep breathing until your heart rate lowers', 'Do NOT make major decisions in this state', 'Reach out to a supervisor or support person'], + signs: ['Stop', 'Help', 'All Done'], + bgClass: 'bg-red-100', + textClass: 'text-red-700', + borderClass: 'border-red-300', + }, +], + zonesOfRegulationPageContent: { + safetyConnections: [ + { + zoneColor: 'yellow', + title: 'QBS Safety Connection', + description: 'Yellow Zone is the intervention window. Use de-escalation strategies now to prevent reaching Red Zone. Reduce demands, offer choices, and use matching signs.', + }, + { + zoneColor: 'red', + title: 'QBS Safety Connection', + description: 'Red Zone requires safety protocols. Follow QBS guidelines: ensure safety first, clear the area if needed, use minimal words, and maintain a calm presence. Do not process during crisis.', + }, + ], + quickDeEscalationFlowTitle: 'Quick De-Escalation Flow', + quickDeEscalationFlow: [ + { step: '1', label: 'Notice the Zone', description: 'Identify current zone', colorClass: 'bg-teal-100 text-teal-700 border-teal-200' }, + { step: '2', label: 'Reduce Demands', description: 'Lower expectations immediately', colorClass: 'bg-blue-100 text-blue-700 border-blue-200' }, + { step: '3', label: 'Offer Support', description: 'Signs, choices, or space', colorClass: 'bg-amber-100 text-amber-700 border-amber-200' }, + { step: '4', label: 'Wait & Monitor', description: 'Give processing time', colorClass: 'bg-violet-100 text-violet-700 border-violet-200' }, + { step: '5', label: 'Reconnect', description: 'Return to Green Zone', colorClass: 'bg-emerald-100 text-emerald-700 border-emerald-200' }, + ], + }, + dashboardTeacherImages: [ + 'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1770656488581_0a5ecf7e.jpg', + 'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1770656507012_d07cff23.png', + 'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1770656551954_522c70b8.png', + 'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1770656493138_78bf55a9.jpg', +], + dashboardEncouragingQuotes: [ + { quote: "Every child deserves a champion — an adult who will never give up on them.", author: "Rita Pierson" }, + { quote: "The greatest sign of success for a teacher is to be able to say, 'The children are now working as if I did not exist.'", author: "Maria Montessori" }, + { quote: "In a world where you can be anything, be kind.", author: "Jennifer Dukes Lee" }, + { quote: "It is not our differences that divide us. It is our inability to recognize, accept, and celebrate those differences.", author: "Audre Lorde" }, + { quote: "Inclusion is not a strategy to help people fit into the systems and structures which exist in our societies; it is about transforming those systems and structures.", author: "Diane Richler" }, + { quote: "The best teachers are those who show you where to look but don't tell you what to see.", author: "Alexandra K. Trenfor" }, + { quote: "Patience is not the ability to wait, but the ability to keep a good attitude while waiting.", author: "Joyce Meyer" }, + { quote: "You have been assigned this mountain to show others it can be moved.", author: "Mel Robbins" }, + { quote: "What makes you different is what makes you beautiful.", author: "Unknown" }, + { quote: "Behind every young child who believes in themselves is a parent or teacher who believed first.", author: "Matthew Jacobson" }, + { quote: "The only way to do great work is to love what you do.", author: "Steve Jobs" }, + { quote: "Small progress is still progress. Celebrate every step forward.", author: "Unknown" }, + { quote: "When we focus on our gratitude, the tide of disappointment goes out and the tide of love rushes in.", author: "Kristin Armstrong" }, + { quote: "Your calm is contagious. Your patience is powerful. Your presence matters.", author: "Unknown" }, + { quote: "Difficult roads often lead to beautiful destinations.", author: "Zig Ziglar" }, + { quote: "The influence of a good teacher can never be erased.", author: "Unknown" }, + { quote: "Not all superheroes wear capes. Some carry lesson plans.", author: "Unknown" }, + { quote: "Be the reason someone smiles today.", author: "Unknown" }, + { quote: "Teaching kids to count is fine, but teaching them what counts is best.", author: "Bob Talbert" }, + { quote: "You are enough. You do enough. Breathe extra deep, let go, and just live right now in this moment.", author: "Unknown" }, + { quote: "Believe you can and you're halfway there.", author: "Theodore Roosevelt" }, + { quote: "The beautiful thing about learning is that no one can take it away from you.", author: "B.B. King" }, + { quote: "Connection is the key that unlocks a child's potential.", author: "Unknown" }, + { quote: "When you change the way you look at things, the things you look at change.", author: "Wayne Dyer" }, + { quote: "A child's life is like a piece of paper on which every person leaves a mark.", author: "Chinese Proverb" }, + { quote: "You don't have to be perfect to be amazing.", author: "Unknown" }, + { quote: "Today is a new day. A fresh start. A chance to make a difference.", author: "Unknown" }, + { quote: "The greatest glory in living lies not in never falling, but in rising every time we fall.", author: "Nelson Mandela" }, + { quote: "What lies behind us and what lies before us are tiny matters compared to what lies within us.", author: "Ralph Waldo Emerson" }, + { quote: "Your work is going to fill a large part of your life, and the only way to be truly satisfied is to do what you believe is great work.", author: "Steve Jobs" }, + { quote: "Act as if what you do makes a difference. It does.", author: "William James" }, +], + dashboardComplianceItems: [ + { + label: 'De-escalation Quiz', + status: 'Pending', + color: 'text-blue-400', + bar: 'bg-blue-500', + width: 'w-0', + }, + { + label: 'EI Assessment', + status: 'In Progress', + color: 'text-pink-400', + bar: 'bg-pink-500', + width: 'w-1/2', + }, + { + label: 'Safety Acknowledgment', + status: 'Complete', + color: 'text-emerald-400', + bar: 'bg-emerald-500', + width: 'w-full', + }, +], + dashboardSignOfWeek: { + word: 'Help', + image: 'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1770656618075_ea1c15a9.png', + alt: 'Help sign', + description: 'Flat hand on top of fist, lift both up together', +}, + parentMessageTemplates: [ + { + id: '1', + template: 'Your child had a wonderful day today! They showed great progress in [specific area]. Keep encouraging them at home!', + category: 'progress', + }, + { + id: '2', + template: 'Just a reminder that [event name] is coming up on [date]. Please [specific action needed]. Let us know if you have questions!', + category: 'event', + }, + { + id: '3', + template: "Today we noticed [specific behavior]. We used [strategy] to help. At home, you might try [home suggestion]. We're working together!", + category: 'behavior', + }, + { + id: '4', + template: "We wanted to share that your child successfully [achievement] today! This is a big step and we're so proud of their effort.", + category: 'progress', + }, + { + id: '5', + template: 'This week we are focusing on [skill/sign]. You can practice at home by [specific activity]. Consistency helps so much!', + category: 'general', + }, + { + id: '6', + template: 'Your child is learning the sign for "[sign word]" this week. Here\'s how to practice: [description]. Any attempt counts - celebrate it!', + category: 'general', + }, +], + communityOrganizations: [ + { + id: '1', + name: 'Sunshine Food Bank', + category: 'Food & Nutrition', + description: 'Local food bank providing meals and groceries to families in need. Students can help sort donations, pack meal kits, and assist with distribution events.', + address: '1420 Community Blvd, Phoenix, AZ 85001', + phone: '(602) 555-0142', + email: 'volunteer@sunshinefoodbank.org', + website: 'sunshinefoodbank.org', + distance: '2.3 mi', + opportunities: ['Food sorting & packing', 'Distribution day volunteers', 'Holiday meal drive', 'Garden maintenance'], + partnershipType: 'both', + ageGroups: ['K-2', '3-5', '6-8'], + rating: 5, + featured: true, + }, + { + id: '2', + name: 'Desert Bloom Animal Shelter', + category: 'Animal Welfare', + description: 'No-kill animal shelter that welcomes student volunteers for socialization programs. Great sensory-friendly environment for students with autism.', + address: '890 Paw Print Lane, Phoenix, AZ 85003', + phone: '(602) 555-0198', + email: 'education@desertbloom.org', + website: 'desertbloomshelter.org', + distance: '3.1 mi', + opportunities: ['Animal socialization visits', 'Pet supply drives', 'Kennel decoration projects', 'Reading to animals program'], + partnershipType: 'both', + ageGroups: ['3-5', '6-8'], + rating: 5, + featured: true, + }, + { + id: '3', + name: 'Valley Senior Living Center', + category: 'Senior Services', + description: 'Assisted living facility offering intergenerational programs. Students visit weekly to share crafts, music, and conversation with residents.', + address: '2100 Golden Years Dr, Phoenix, AZ 85004', + phone: '(602) 555-0167', + email: 'activities@valleysenior.com', + website: 'valleyseniorliving.com', + distance: '1.8 mi', + opportunities: ['Weekly craft sessions', 'Music & performance visits', 'Holiday card making', 'Garden buddies program'], + partnershipType: 'community-service', + ageGroups: ['K-2', '3-5', '6-8'], + rating: 4, + featured: false, + }, + { + id: '4', + name: 'Phoenix Public Library - East Branch', + category: 'Education & Literacy', + description: 'Local library branch with dedicated programs for special needs students. Offers sensory-friendly story times and adaptive technology workshops.', + address: '3500 E McDowell Rd, Phoenix, AZ 85008', + phone: '(602) 555-0134', + email: 'eastbranch@phoenixlib.org', + website: 'phoenixpubliclibrary.org', + distance: '4.2 mi', + opportunities: ['Book drive coordination', 'Reading buddy program', 'Library shelf organization', 'Story time assistants'], + partnershipType: 'school-partnership', + ageGroups: ['K-2', '3-5'], + rating: 4, + featured: false, + }, + { + id: '5', + name: 'Habitat for Humanity - Phoenix Chapter', + category: 'Housing & Construction', + description: 'Building homes for families in need. Age-appropriate volunteer tasks available including painting, landscaping, and supply organization.', + address: '780 Builder Way, Phoenix, AZ 85006', + phone: '(602) 555-0189', + email: 'volunteer@habitatphx.org', + website: 'habitatphoenix.org', + distance: '5.6 mi', + opportunities: ['Supply sorting', 'Painting projects', 'Landscaping days', 'Fundraiser events'], + partnershipType: 'community-service', + ageGroups: ['6-8'], + rating: 5, + featured: true, + }, + { + id: '6', + name: 'Desert Botanical Garden', + category: 'Environment & Nature', + description: 'Beautiful botanical garden offering educational partnerships and volunteer opportunities focused on desert ecology and conservation.', + address: '1201 N Galvin Pkwy, Phoenix, AZ 85008', + phone: '(602) 555-0156', + email: 'education@dbg.org', + website: 'dbg.org', + distance: '6.1 mi', + opportunities: ['Trail cleanup days', 'Seed planting workshops', 'Nature journaling', 'Butterfly garden maintenance'], + partnershipType: 'both', + ageGroups: ['K-2', '3-5', '6-8'], + rating: 5, + featured: false, + }, + { + id: '7', + name: 'Special Olympics Arizona', + category: 'Sports & Recreation', + description: 'Year-round sports training and athletic competition for individuals with intellectual disabilities. Partnership opportunities for unified sports.', + address: '2100 S 75th Ave, Phoenix, AZ 85043', + phone: '(602) 555-0145', + email: 'programs@soaz.org', + website: 'specialolympicsarizona.org', + distance: '8.4 mi', + opportunities: ['Unified sports events', 'Cheer squad support', 'Event setup volunteers', 'Athlete buddy program'], + partnershipType: 'school-partnership', + ageGroups: ['3-5', '6-8'], + rating: 5, + featured: true, + }, + { + id: '8', + name: 'Community Arts Center', + category: 'Arts & Culture', + description: 'Inclusive arts center offering adaptive art classes and exhibition opportunities. Students can participate in collaborative murals and gallery shows.', + address: '456 Creative Ave, Phoenix, AZ 85007', + phone: '(602) 555-0178', + email: 'info@communityarts.org', + website: 'communityartsphx.org', + distance: '3.7 mi', + opportunities: ['Collaborative mural projects', 'Art supply drives', 'Gallery exhibition setup', 'Adaptive art workshops'], + partnershipType: 'both', + ageGroups: ['K-2', '3-5', '6-8'], + rating: 4, + featured: false, + }, + { + id: '9', + name: 'Ronald McDonald House', + category: 'Family Support', + description: 'Provides housing and support for families with children receiving medical treatment. Students can help with meal preparation and activity kits.', + address: '501 E Thomas Rd, Phoenix, AZ 85012', + phone: '(602) 555-0123', + email: 'volunteer@rmhcphx.org', + website: 'rmhcphoenix.org', + distance: '4.9 mi', + opportunities: ['Meal preparation', 'Activity kit assembly', 'Holiday decoration', 'Card writing campaigns'], + partnershipType: 'community-service', + ageGroups: ['3-5', '6-8'], + rating: 5, + featured: false, + }, + { + id: '10', + name: 'Neighborhood Cleanup Coalition', + category: 'Environment & Nature', + description: 'Organizes monthly neighborhood beautification projects. Great for teaching environmental responsibility and community pride.', + address: 'Various locations, Phoenix, AZ', + phone: '(602) 555-0190', + email: 'join@cleanupcoalition.org', + website: 'phxcleanup.org', + distance: '1.0 mi', + opportunities: ['Monthly park cleanups', 'Recycling drives', 'Community garden planting', 'Mural painting'], + partnershipType: 'community-service', + ageGroups: ['K-2', '3-5', '6-8'], + rating: 4, + featured: false, + }, +], + vocationalOpportunities: [ + { + id: '1', + company: 'Green Thumb Nursery', + title: 'Horticulture Assistant', + category: 'Agriculture & Gardening', + description: 'Hands-on plant care, watering, potting, and greenhouse maintenance. Structured tasks with visual checklists. Calm, sensory-friendly outdoor environment.', + address: '1200 Garden Way, Phoenix, AZ 85001', + zipCode: '85001', + phone: '(602) 555-0211', + email: 'jobs@greenthumb.com', + website: 'greenthumbphx.com', + distance: '1.5 mi', + schedule: 'Mon-Wed, 9:00 AM - 12:00 PM', + compensation: 'Stipend + School Credit', + skills: ['Plant identification', 'Watering schedules', 'Soil preparation', 'Tool handling'], + requirements: ['Interest in plants/nature', 'Ability to follow visual schedules', 'Comfortable outdoors'], + accommodations: ['Visual task boards', 'Noise-canceling headphones available', 'Flexible break schedule', 'Job coach welcome'], + ageGroup: '14-18', + spots: 4, + featured: true, + }, + { + id: '2', + company: 'Sunrise Bakery & Cafe', + title: 'Kitchen Prep Assistant', + category: 'Food Service', + description: 'Learn food preparation basics including measuring, mixing, and packaging. Work alongside experienced bakers in a structured, supportive environment.', + address: '890 Main St, Phoenix, AZ 85003', + zipCode: '85003', + phone: '(602) 555-0234', + email: 'hiring@sunrisebakery.com', + website: 'sunrisebakerycafe.com', + distance: '2.8 mi', + schedule: 'Tue-Thu, 8:00 AM - 11:00 AM', + compensation: 'Minimum Wage', + skills: ['Food safety basics', 'Measuring ingredients', 'Following recipes', 'Kitchen cleanliness'], + requirements: ['Food handler card (training provided)', 'Comfortable in kitchen environment', 'Basic hygiene practices'], + accommodations: ['Step-by-step visual recipes', 'Sensory-friendly uniform options', 'Quiet break room', 'Structured task rotation'], + ageGroup: '16-22', + spots: 3, + featured: true, + }, + { + id: '3', + company: 'PetSmart - East Phoenix', + title: 'Pet Care Associate Trainee', + category: 'Animal Care', + description: 'Assist with pet care tasks including feeding, habitat cleaning, and customer greeting. Great for animal lovers who thrive with routine tasks.', + address: '3400 E Thomas Rd, Phoenix, AZ 85018', + zipCode: '85018', + phone: '(602) 555-0267', + email: 'careers@petsmart-eastphx.com', + website: 'petsmart.com', + distance: '4.1 mi', + schedule: 'Mon/Wed/Fri, 10:00 AM - 1:00 PM', + compensation: 'Minimum Wage', + skills: ['Animal handling basics', 'Habitat maintenance', 'Inventory stocking', 'Customer interaction'], + requirements: ['Comfort around animals', 'Ability to follow cleaning protocols', 'Basic communication skills'], + accommodations: ['Structured daily routine', 'Visual checklists for all tasks', 'Designated quiet area', 'Gradual customer interaction exposure'], + ageGroup: '16-22', + spots: 2, + featured: false, + }, + { + id: '4', + company: 'Goodwill Industries - Phoenix', + title: 'Retail & Sorting Associate', + category: 'Retail', + description: 'Sort donated items, organize shelves, and assist with store operations. Highly structured environment with clear task expectations.', + address: '2100 W Camelback Rd, Phoenix, AZ 85015', + zipCode: '85015', + phone: '(602) 555-0289', + email: 'employment@goodwillphx.org', + website: 'goodwillaz.org', + distance: '5.3 mi', + schedule: 'Mon-Fri, 9:00 AM - 12:00 PM (flexible)', + compensation: 'Minimum Wage + Benefits Training', + skills: ['Sorting & categorizing', 'Shelf organization', 'Price tagging', 'Customer service basics'], + requirements: ['Ability to stand for moderate periods', 'Basic sorting skills', 'Willingness to learn'], + accommodations: ['Job coach on-site', 'Visual work stations', 'Flexible scheduling', 'Gradual responsibility increase', 'Sensory break room'], + ageGroup: '16-22', + spots: 6, + featured: true, + }, + { + id: '5', + company: 'Desert Auto Detail', + title: 'Auto Detailing Trainee', + category: 'Automotive', + description: 'Learn vehicle cleaning and detailing skills including washing, vacuuming, and interior cleaning. Repetitive, structured tasks ideal for routine-oriented workers.', + address: '780 S 16th St, Phoenix, AZ 85034', + zipCode: '85034', + phone: '(602) 555-0301', + email: 'train@desertautodetail.com', + website: 'desertautodetail.com', + distance: '3.6 mi', + schedule: 'Tue/Thu, 9:00 AM - 12:00 PM', + compensation: 'Stipend', + skills: ['Vehicle washing techniques', 'Interior cleaning', 'Attention to detail', 'Tool maintenance'], + requirements: ['Comfortable with water/cleaning products', 'Ability to follow step-by-step process', 'Physical stamina for standing'], + accommodations: ['Visual process charts', 'Noise protection provided', 'Structured break times', 'One-on-one training period'], + ageGroup: '16-22', + spots: 3, + featured: false, + }, + { + id: '6', + company: 'Phoenix Public Library System', + title: 'Library Assistant Intern', + category: 'Office & Administrative', + description: 'Shelve books, organize materials, assist with program setup, and learn basic library operations. Quiet, structured environment perfect for detail-oriented individuals.', + address: '1221 N Central Ave, Phoenix, AZ 85004', + zipCode: '85004', + phone: '(602) 555-0312', + email: 'internships@phoenixlib.org', + website: 'phoenixpubliclibrary.org', + distance: '4.8 mi', + schedule: 'Mon/Wed, 1:00 PM - 4:00 PM', + compensation: 'School Credit + Stipend', + skills: ['Alphabetical/numerical sorting', 'Shelf organization', 'Computer basics', 'Quiet customer service'], + requirements: ['Comfort in quiet environments', 'Basic reading skills', 'Attention to detail'], + accommodations: ['Predictable daily schedule', 'Written task instructions', 'Low-stimulation environment', 'Flexible pacing'], + ageGroup: '14-22', + spots: 4, + featured: false, + }, + { + id: '7', + company: 'Home Depot - Tempe', + title: 'Garden Center Associate Trainee', + category: 'Retail', + description: 'Water plants, organize garden supplies, assist customers with plant selection. Combines outdoor work with retail skills in a supportive team environment.', + address: '1800 E Baseline Rd, Tempe, AZ 85283', + zipCode: '85283', + phone: '(480) 555-0178', + email: 'careers@homedepot-tempe.com', + website: 'homedepot.com', + distance: '7.2 mi', + schedule: 'Sat/Sun, 8:00 AM - 12:00 PM', + compensation: 'Minimum Wage', + skills: ['Plant watering', 'Inventory organization', 'Basic customer interaction', 'Cart management'], + requirements: ['Comfortable outdoors', 'Ability to lift 20 lbs', 'Basic communication'], + accommodations: ['Buddy system with experienced associate', 'Visual task cards', 'Scheduled breaks', 'Gradual customer exposure'], + ageGroup: '16-22', + spots: 2, + featured: false, + }, + { + id: '8', + company: 'Creative Sparks Art Studio', + title: 'Studio Assistant', + category: 'Arts & Creative', + description: 'Help organize art supplies, prepare workstations, and assist with class setup. Creative environment that values different perspectives and abilities.', + address: '456 E Roosevelt St, Phoenix, AZ 85004', + zipCode: '85004', + phone: '(602) 555-0345', + email: 'studio@creativesparks.com', + website: 'creativesparksphx.com', + distance: '3.9 mi', + schedule: 'Wed/Fri, 10:00 AM - 1:00 PM', + compensation: 'Stipend + Free Classes', + skills: ['Supply organization', 'Color sorting', 'Workspace preparation', 'Basic art techniques'], + requirements: ['Interest in art/creativity', 'Ability to follow setup procedures', 'Comfortable around groups'], + accommodations: ['Sensory-friendly workspace', 'Visual organization systems', 'Flexible creative expression', 'Quiet prep time available'], + ageGroup: '14-22', + spots: 3, + featured: true, + }, + { + id: '9', + company: 'FedEx Ground - Phoenix Hub', + title: 'Package Handler Trainee', + category: 'Warehouse & Logistics', + description: 'Sort and organize packages in a structured warehouse environment. Repetitive, physical tasks with clear expectations and team support.', + address: '4700 E Cotton Center Blvd, Phoenix, AZ 85040', + zipCode: '85040', + phone: '(602) 555-0378', + email: 'jobs@fedexphx.com', + website: 'fedex.com/careers', + distance: '9.1 mi', + schedule: 'Mon-Fri, 6:00 AM - 10:00 AM', + compensation: 'Above Minimum Wage + Benefits', + skills: ['Package sorting', 'Label reading', 'Physical stamina', 'Team coordination'], + requirements: ['Ability to lift 35 lbs', 'Reliable attendance', 'Comfortable in warehouse setting'], + accommodations: ['Noise protection provided', 'Visual sorting guides', 'Structured break schedule', 'Job coach allowed on-site'], + ageGroup: '18-22', + spots: 5, + featured: false, + }, + { + id: '10', + company: 'Valley Tech Recycling', + title: 'Electronics Recycling Technician Trainee', + category: 'Technology', + description: 'Learn to disassemble, sort, and process electronic devices for recycling. Detail-oriented work perfect for individuals who enjoy taking things apart.', + address: '1500 W Buckeye Rd, Phoenix, AZ 85007', + zipCode: '85007', + phone: '(602) 555-0390', + email: 'careers@valleytechrecycle.com', + website: 'valleytechrecycling.com', + distance: '4.5 mi', + schedule: 'Tue/Thu, 9:00 AM - 12:00 PM', + compensation: 'Stipend + Certification', + skills: ['Component identification', 'Basic tool use', 'Sorting & categorizing', 'Safety protocols'], + requirements: ['Interest in technology', 'Fine motor skills', 'Ability to follow safety procedures'], + accommodations: ['Structured workstation', 'Visual disassembly guides', 'Noise-controlled environment', 'Self-paced work'], + ageGroup: '16-22', + spots: 4, + featured: true, + }, +], + emotionalIntelligenceAssessmentQuestions: [ + { q: 'When a student escalates, my first internal reaction is usually:', options: ['Frustration or irritation', 'Anxiety or nervousness', 'Calm assessment', 'Desire to fix it immediately'], scores: [1, 2, 4, 2] }, + { q: 'When I receive critical feedback from a supervisor, I tend to:', options: ['Feel defensive', 'Take it personally', 'Listen and reflect', 'Ask for specific examples'], scores: [1, 1, 4, 3] }, + { q: 'I can usually identify what emotion I\'m feeling in the moment:', options: ['Rarely', 'Sometimes', 'Often', 'Almost always'], scores: [1, 2, 3, 4] }, + { q: 'When a colleague is visibly stressed, I typically:', options: ['Avoid them', 'Feel stressed too', 'Check in briefly', 'Offer specific support'], scores: [1, 2, 3, 4] }, + { q: 'My stress regulation strategy at work is:', options: ['I don\'t have one', 'I push through it', 'I take brief breaks', 'I have multiple strategies I rotate'], scores: [1, 2, 3, 4] }, + { q: 'When there\'s a conflict between colleagues, I usually:', options: ['Stay out of it completely', 'Pick a side', 'Try to understand both perspectives', 'Help facilitate resolution'], scores: [2, 1, 3, 4] }, + { q: 'I recognize early signs of burnout in myself:', options: ['Only when it\'s severe', 'Sometimes too late', 'Usually in time to adjust', 'I proactively monitor my wellbeing'], scores: [1, 2, 3, 4] }, + { q: 'When communicating difficult information to parents, I:', options: ['Avoid it when possible', 'Get anxious beforehand', 'Prepare and stay factual', 'Balance empathy with clarity'], scores: [1, 2, 3, 4] }, +], + emotionalIntelligenceWeeklyTopics: [ + { title: 'Stress Regulation', desc: 'Identify your stress triggers and build a personal regulation toolkit', iconId: 'shield', color: 'bg-blue-500/10 text-blue-400 border-blue-500/20' }, + { title: 'Conflict Response', desc: 'Move from reactive to responsive in difficult conversations', iconId: 'brain', color: 'bg-violet-500/10 text-violet-400 border-violet-500/20' }, + { title: 'Burnout Prevention', desc: 'Recognize early warning signs and take proactive steps', iconId: 'heart', color: 'bg-rose-500/10 text-rose-400 border-rose-500/20' }, + { title: 'Empathy in Communication', desc: 'Listen to understand, not to respond', iconId: 'eye', color: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20' }, +], + emotionalIntelligenceGrowthTips: [ + 'Pause for 3 breaths before responding to any escalation', + 'Name your emotion silently: "I notice I\'m feeling frustrated"', + 'Check your zone before entering a student\'s space', + 'End each day with one thing you handled well', + 'Ask a colleague "How are you really doing?" this week', +], + emotionalIntelligenceTeamWellnessMetrics: [ + { label: 'Participation Rate', value: '78%', color: 'text-emerald-400', bar: 'bg-emerald-500', width: '78%' }, + { label: 'Average EI Score', value: '72%', color: 'text-blue-400', bar: 'bg-blue-500', width: '72%' }, + { label: 'Growth Trend', value: '+8%', color: 'text-pink-400', bar: 'bg-pink-500', width: '65%' }, +], + personalityQuizQuestions: [ + // E vs I (3 questions) + { + id: 1, + dimension: 'EI', + question: 'During a staff meeting, you feel most energized when:', + optionA: { text: 'Brainstorming ideas out loud with the group and building on others\' suggestions', value: 'E' }, + optionB: { text: 'Listening carefully, then sharing a well-thought-out idea you\'ve been developing internally', value: 'I' }, + }, + { + id: 2, + dimension: 'EI', + question: 'After a long, intense day with students, you recharge by:', + optionA: { text: 'Chatting with colleagues, grabbing coffee together, or calling a friend', value: 'E' }, + optionB: { text: 'Having quiet time alone — reading, walking, or just decompressing in silence', value: 'I' }, + }, + { + id: 3, + dimension: 'EI', + question: 'When working on a new classroom strategy, you prefer to:', + optionA: { text: 'Talk it through with a colleague or team to get immediate feedback', value: 'E' }, + optionB: { text: 'Research and reflect on your own first before discussing with others', value: 'I' }, + }, + // S vs N (3 questions) + { + id: 4, + dimension: 'SN', + question: 'When planning a lesson or activity, you tend to focus on:', + optionA: { text: 'Concrete, step-by-step instructions and proven methods that have worked before', value: 'S' }, + optionB: { text: 'The big picture concept and creative ways to make it engaging and meaningful', value: 'N' }, + }, + { + id: 5, + dimension: 'SN', + question: 'When a new policy or procedure is introduced, you first want to know:', + optionA: { text: 'The specific details — what exactly changes, when, and how it affects your daily routine', value: 'S' }, + optionB: { text: 'The reasoning behind it — why the change matters and what the long-term vision is', value: 'N' }, + }, + { + id: 6, + dimension: 'SN', + question: 'When observing a student\'s behavior, you naturally notice:', + optionA: { text: 'Specific, observable details — what they said, did, and the exact sequence of events', value: 'S' }, + optionB: { text: 'Patterns and underlying themes — what might be driving the behavior emotionally or socially', value: 'N' }, + }, + // T vs F (3 questions) + { + id: 7, + dimension: 'TF', + question: 'When a colleague disagrees with your approach to a student situation, you:', + optionA: { text: 'Present logical evidence and data to support why your approach is effective', value: 'T' }, + optionB: { text: 'Consider their perspective empathetically and look for a solution that honors both viewpoints', value: 'F' }, + }, + { + id: 8, + dimension: 'TF', + question: 'When making a decision about a student\'s behavior plan, you prioritize:', + optionA: { text: 'Consistency, fairness, and what the data shows is most effective', value: 'T' }, + optionB: { text: 'The student\'s emotional needs and how the plan will affect their sense of belonging', value: 'F' }, + }, + { + id: 9, + dimension: 'TF', + question: 'When giving feedback to a colleague, you tend to:', + optionA: { text: 'Be direct and specific about what needs improvement, focusing on outcomes', value: 'T' }, + optionB: { text: 'Start with what\'s going well, then gently suggest areas for growth with encouragement', value: 'F' }, + }, + // J vs P (3 questions) + { + id: 10, + dimension: 'JP', + question: 'Your ideal classroom or workspace is:', + optionA: { text: 'Organized with clear systems, schedules posted, and materials in designated spots', value: 'J' }, + optionB: { text: 'Flexible and adaptable — you know where things are even if it looks a bit creative', value: 'P' }, + }, + { + id: 11, + dimension: 'JP', + question: 'When an unexpected schedule change happens during the school day, you:', + optionA: { text: 'Feel a bit stressed and quickly create a new plan to stay on track', value: 'J' }, + optionB: { text: 'Roll with it naturally and see it as an opportunity to be spontaneous', value: 'P' }, + }, + { + id: 12, + dimension: 'JP', + question: 'When preparing for the next school week, you typically:', + optionA: { text: 'Plan everything in advance — lessons, materials, and contingencies are all mapped out', value: 'J' }, + optionB: { text: 'Have a general idea but prefer to stay flexible and adjust based on how the week unfolds', value: 'P' }, + }, +], + personalityTypes: [ + { + code: 'INTJ', + name: 'The Architect', + nickname: 'Strategic Visionary', + description: 'INTJs are strategic, independent thinkers who see the big picture and create long-term plans. In education, they excel at designing systems, analyzing data, and finding innovative solutions to complex challenges. They bring a calm, analytical presence to their teams.', + strengths: ['Strategic planning', 'Systems thinking', 'Independent problem-solving', 'Long-range vision'], + workRelationships: 'INTJs value competence and intellectual depth in their colleagues. They prefer working with people who are self-sufficient, logical, and open to constructive critique. They thrive in partnerships where ideas are debated respectfully, and they appreciate colleagues who come prepared and follow through on commitments. They may need encouragement to share their ideas more openly, as they tend to process internally before speaking.', + workplaceLanguage: 'INTJs communicate with precision and directness. They prefer concise, well-organized conversations that get to the point quickly. Their language tends to be analytical — they use phrases like "Based on the data..." or "The most efficient approach would be..." They may come across as blunt, but their intent is clarity, not coldness. They appreciate written communication (emails, documents) where they can organize their thoughts carefully.', + idealWorkEnvironment: 'Quiet, structured environments with autonomy and minimal micromanagement', + communicationStyle: 'Direct, analytical, and solution-focused', + color: 'from-indigo-500 to-purple-600', + bgColor: 'bg-indigo-500/10', + borderColor: 'border-indigo-500/20', + icon: 'architect', + }, + { + code: 'INTP', + name: 'The Logician', + nickname: 'Analytical Innovator', + description: 'INTPs are curious, analytical minds who love exploring ideas and understanding how things work. In education, they bring creative problem-solving and a unique perspective to challenges. They question assumptions and find unconventional solutions that others might miss.', + strengths: ['Analytical thinking', 'Creative problem-solving', 'Objective analysis', 'Intellectual curiosity'], + workRelationships: 'INTPs value intellectual stimulation and autonomy in their work relationships. They connect best with colleagues who enjoy exploring ideas and don\'t take disagreements personally. They prefer working with people who are open-minded and can engage in thoughtful debate. They may struggle with highly emotional or politically charged workplace dynamics and need space to think independently.', + workplaceLanguage: 'INTPs communicate through ideas and possibilities. They often use phrases like "What if we tried..." or "Have you considered..." Their language is exploratory and sometimes abstract. They may start multiple trains of thought before landing on their main point. They value accuracy over diplomacy and may need to be reminded to acknowledge others\' contributions before diving into analysis.', + idealWorkEnvironment: 'Intellectually stimulating spaces with freedom to explore and experiment', + communicationStyle: 'Exploratory, idea-driven, and questioning', + color: 'from-cyan-500 to-blue-600', + bgColor: 'bg-cyan-500/10', + borderColor: 'border-cyan-500/20', + icon: 'logician', + }, + { + code: 'ENTJ', + name: 'The Commander', + nickname: 'Decisive Leader', + description: 'ENTJs are natural leaders who organize people and resources to achieve goals efficiently. In education, they excel at driving initiatives, setting high standards, and motivating teams to reach their potential. They bring energy, confidence, and a results-oriented mindset.', + strengths: ['Leadership', 'Strategic execution', 'Team motivation', 'Goal achievement'], + workRelationships: 'ENTJs value efficiency and competence in their colleagues. They prefer working with people who are proactive, reliable, and willing to take ownership of their responsibilities. They build strong professional relationships through shared goals and mutual respect. They appreciate directness and may become frustrated with indecisiveness or lack of follow-through. They need to remember to balance their drive with patience for different working styles.', + workplaceLanguage: 'ENTJs communicate with authority and clarity. They use decisive language — "Here\'s the plan," "We need to," "The priority is..." Their communication is goal-oriented and action-focused. They naturally take charge in conversations and meetings. They should be mindful of leaving space for others to contribute and softening their delivery when working with more sensitive colleagues.', + idealWorkEnvironment: 'Dynamic, goal-driven environments where they can lead and make an impact', + communicationStyle: 'Commanding, clear, and action-oriented', + color: 'from-red-500 to-orange-600', + bgColor: 'bg-red-500/10', + borderColor: 'border-red-500/20', + icon: 'commander', + }, + { + code: 'ENTP', + name: 'The Debater', + nickname: 'Creative Challenger', + description: 'ENTPs are energetic innovators who love challenging the status quo and exploring new possibilities. In education, they bring fresh perspectives, creative solutions, and an infectious enthusiasm for trying new approaches. They keep teams thinking and growing.', + strengths: ['Innovation', 'Adaptability', 'Persuasion', 'Creative thinking'], + workRelationships: 'ENTPs thrive in relationships with colleagues who enjoy intellectual sparring and aren\'t afraid of new ideas. They connect through humor, debate, and shared enthusiasm for innovation. They value colleagues who can keep up with their rapid-fire ideas and help them refine their best ones. They may need to be more sensitive to colleagues who prefer stability and routine.', + workplaceLanguage: 'ENTPs communicate with enthusiasm and wit. They use phrases like "Why don\'t we try..." or "What if we flipped this..." Their language is persuasive and often playful. They enjoy devil\'s advocate positions and may challenge ideas not because they disagree, but because they want to strengthen the thinking. They should be aware that their debating style can feel confrontational to some colleagues.', + idealWorkEnvironment: 'Fast-paced, flexible environments that welcome experimentation and debate', + communicationStyle: 'Enthusiastic, persuasive, and intellectually challenging', + color: 'from-amber-500 to-yellow-600', + bgColor: 'bg-amber-500/10', + borderColor: 'border-amber-500/20', + icon: 'debater', + }, + { + code: 'INFJ', + name: 'The Advocate', + nickname: 'Insightful Guide', + description: 'INFJs are deeply empathetic visionaries who are driven by their values and desire to help others grow. In education, they excel at understanding students\' deeper needs, creating meaningful connections, and inspiring positive change. They bring wisdom, compassion, and quiet determination.', + strengths: ['Deep empathy', 'Visionary thinking', 'Meaningful connections', 'Values-driven leadership'], + workRelationships: 'INFJs seek authentic, meaningful connections with their colleagues. They value trust, mutual respect, and shared purpose. They prefer working with people who are genuine, compassionate, and committed to making a difference. They are excellent listeners and often become the person colleagues confide in. They may need to set boundaries to avoid emotional exhaustion and should seek out colleagues who reciprocate their depth of care.', + workplaceLanguage: 'INFJs communicate with warmth, depth, and purpose. They use phrases like "I feel like this matters because..." or "What I\'m sensing is..." Their language is values-driven and often metaphorical. They speak with conviction about things they believe in and can be surprisingly persuasive. They prefer one-on-one conversations over large group discussions and may need encouragement to share their insights in team settings.', + idealWorkEnvironment: 'Purposeful, harmonious environments where their work has meaningful impact', + communicationStyle: 'Warm, insightful, and values-driven', + color: 'from-emerald-500 to-teal-600', + bgColor: 'bg-emerald-500/10', + borderColor: 'border-emerald-500/20', + icon: 'advocate', + }, + { + code: 'INFP', + name: 'The Mediator', + nickname: 'Compassionate Idealist', + description: 'INFPs are creative, empathetic individuals guided by strong inner values. In education, they bring authenticity, creativity, and a deep understanding of each student\'s unique needs. They create safe, nurturing environments where students feel truly seen and valued.', + strengths: ['Creativity', 'Authentic empathy', 'Individual attention', 'Values alignment'], + workRelationships: 'INFPs value authenticity and kindness in their work relationships. They connect deeply with colleagues who share their values and passion for helping students. They prefer collaborative environments over competitive ones and thrive when they feel their unique contributions are appreciated. They may avoid conflict, so they benefit from colleagues who create safe spaces for honest dialogue.', + workplaceLanguage: 'INFPs communicate with sincerity and creativity. They use phrases like "I really believe..." or "What matters most here is..." Their language is personal and heartfelt. They may express ideas through stories, analogies, or creative examples rather than bullet points. They are excellent written communicators and may prefer email or notes over spontaneous verbal exchanges when discussing important topics.', + idealWorkEnvironment: 'Creative, supportive environments that honor individuality and personal growth', + communicationStyle: 'Sincere, creative, and personally meaningful', + color: 'from-pink-500 to-rose-600', + bgColor: 'bg-pink-500/10', + borderColor: 'border-pink-500/20', + icon: 'mediator', + }, + { + code: 'ENFJ', + name: 'The Protagonist', + nickname: 'Inspiring Mentor', + description: 'ENFJs are charismatic, empathetic leaders who inspire others to reach their potential. In education, they naturally build strong teams, create positive cultures, and advocate passionately for their students and colleagues. They bring warmth, vision, and infectious optimism.', + strengths: ['Inspirational leadership', 'Team building', 'Empathetic communication', 'Cultural development'], + workRelationships: 'ENFJs are natural relationship builders who invest deeply in their colleagues\' growth and wellbeing. They create inclusive, supportive team dynamics and often serve as the emotional glue that holds teams together. They value harmony and work hard to resolve conflicts. They connect best with colleagues who are genuine, growth-oriented, and willing to contribute to a positive team culture. They should be mindful of not overextending themselves for others.', + workplaceLanguage: 'ENFJs communicate with warmth, enthusiasm, and encouragement. They use phrases like "I believe in you," "We can do this together," and "Let me help you with that." Their language is inclusive and motivating. They naturally affirm others and create psychological safety in conversations. They should be aware that their desire for harmony may sometimes prevent them from addressing difficult issues directly.', + idealWorkEnvironment: 'Collaborative, people-centered environments where they can mentor and inspire', + communicationStyle: 'Warm, encouraging, and inclusive', + color: 'from-orange-500 to-amber-600', + bgColor: 'bg-orange-500/10', + borderColor: 'border-orange-500/20', + icon: 'protagonist', + }, + { + code: 'ENFP', + name: 'The Campaigner', + nickname: 'Enthusiastic Connector', + description: 'ENFPs are creative, enthusiastic individuals who see potential everywhere and in everyone. In education, they bring energy, innovation, and an ability to connect with students on a personal level. They make learning exciting and help others discover their passions.', + strengths: ['Enthusiasm', 'Creative connections', 'Adaptability', 'Inspiring others'], + workRelationships: 'ENFPs build warm, energetic relationships with their colleagues. They are natural connectors who bring people together and create a fun, positive atmosphere. They value authenticity and freedom in their work relationships and connect best with colleagues who are open-minded, creative, and supportive. They may struggle with overly rigid or critical colleagues and need encouragement to follow through on their many ideas.', + workplaceLanguage: 'ENFPs communicate with energy, creativity, and personal warmth. They use phrases like "I have an amazing idea!" or "Imagine if we could..." Their language is expressive, enthusiastic, and often peppered with personal anecdotes. They think out loud and may jump between topics as connections spark in their mind. They should practice focusing their communication and following up on commitments they make in the excitement of the moment.', + idealWorkEnvironment: 'Flexible, creative environments that celebrate individuality and new ideas', + communicationStyle: 'Energetic, expressive, and personally engaging', + color: 'from-yellow-500 to-orange-500', + bgColor: 'bg-yellow-500/10', + borderColor: 'border-yellow-500/20', + icon: 'campaigner', + }, + { + code: 'ISTJ', + name: 'The Logistician', + nickname: 'Reliable Organizer', + description: 'ISTJs are dependable, thorough professionals who value tradition, responsibility, and doing things right. In education, they bring structure, consistency, and meticulous attention to detail. They are the backbone of any well-run classroom or school.', + strengths: ['Reliability', 'Attention to detail', 'Organizational skills', 'Consistency'], + workRelationships: 'ISTJs value reliability and professionalism in their colleagues. They build trust through consistent actions rather than words and prefer working with people who follow through on their commitments. They are loyal team members who can always be counted on. They connect best with colleagues who respect established procedures and communicate clearly. They may need to be more flexible with colleagues who have different working styles.', + workplaceLanguage: 'ISTJs communicate with clarity, precision, and factual accuracy. They use phrases like "According to the procedure..." or "The data shows..." Their language is straightforward and practical. They prefer clear, organized communication and may become frustrated with vague or overly emotional discussions. They are excellent at documenting processes and creating clear written instructions.', + idealWorkEnvironment: 'Structured, organized environments with clear expectations and procedures', + communicationStyle: 'Clear, factual, and procedure-oriented', + color: 'from-slate-500 to-gray-600', + bgColor: 'bg-slate-500/10', + borderColor: 'border-slate-500/20', + icon: 'logistician', + }, + { + code: 'ISFJ', + name: 'The Defender', + nickname: 'Nurturing Protector', + description: 'ISFJs are warm, dedicated individuals who quietly ensure everything runs smoothly and everyone is cared for. In education, they create stable, nurturing environments and go above and beyond for their students and colleagues. They are the unsung heroes of every school.', + strengths: ['Dedication', 'Nurturing care', 'Practical support', 'Attention to individual needs'], + workRelationships: 'ISFJs are loyal, supportive colleagues who remember the little things — birthdays, preferences, and personal challenges. They build relationships through acts of service and consistent care. They value harmony and work hard to maintain positive team dynamics. They connect best with colleagues who appreciate their contributions and reciprocate their thoughtfulness. They may need to practice saying no and setting boundaries to avoid burnout.', + workplaceLanguage: 'ISFJs communicate with warmth, practicality, and consideration. They use phrases like "How can I help?" or "I noticed you might need..." Their language is supportive and detail-oriented. They prefer gentle, respectful communication and may be hurt by blunt or critical feedback. They express care through actions more than words and may need encouragement to voice their own needs and opinions in team settings.', + idealWorkEnvironment: 'Stable, appreciative environments where their contributions are recognized', + communicationStyle: 'Warm, supportive, and detail-conscious', + color: 'from-teal-500 to-emerald-600', + bgColor: 'bg-teal-500/10', + borderColor: 'border-teal-500/20', + icon: 'defender', + }, + { + code: 'ESTJ', + name: 'The Executive', + nickname: 'Efficient Organizer', + description: 'ESTJs are organized, decisive leaders who value order, tradition, and getting things done. In education, they excel at managing operations, enforcing standards, and creating efficient systems. They bring structure and accountability to every team they join.', + strengths: ['Organization', 'Decisive action', 'Standards enforcement', 'Operational efficiency'], + workRelationships: 'ESTJs value competence, punctuality, and professionalism in their colleagues. They build respect through hard work and expect the same from others. They are natural organizers who take charge of projects and ensure deadlines are met. They connect best with colleagues who are responsible, direct, and committed to excellence. They should practice patience with colleagues who work at a different pace or have a more flexible approach.', + workplaceLanguage: 'ESTJs communicate with authority, clarity, and directness. They use phrases like "Here\'s what needs to happen," "The deadline is," and "Let\'s stay focused." Their language is task-oriented and efficient. They value clear agendas, action items, and follow-up. They may come across as bossy or inflexible, so they should practice acknowledging others\' input and being open to alternative approaches.', + idealWorkEnvironment: 'Well-organized environments with clear hierarchies and measurable goals', + communicationStyle: 'Direct, organized, and results-driven', + color: 'from-blue-600 to-indigo-700', + bgColor: 'bg-blue-600/10', + borderColor: 'border-blue-600/20', + icon: 'executive', + }, + { + code: 'ESFJ', + name: 'The Consul', + nickname: 'Caring Coordinator', + description: 'ESFJs are warm, social individuals who create harmony and ensure everyone feels included. In education, they excel at building community, coordinating events, and maintaining positive relationships with students, families, and colleagues. They are the heart of school culture.', + strengths: ['Community building', 'Social coordination', 'Inclusive leadership', 'Relationship maintenance'], + workRelationships: 'ESFJs are the social connectors of any team. They remember everyone\'s names, organize team celebrations, and make sure no one feels left out. They build relationships through genuine care and consistent follow-through. They value loyalty, cooperation, and positive team spirit. They connect best with colleagues who are appreciative, collaborative, and socially engaged. They may take criticism personally and need reassurance that they are valued.', + workplaceLanguage: 'ESFJs communicate with warmth, enthusiasm, and social awareness. They use phrases like "How is everyone feeling about this?" or "Let\'s make sure we include..." Their language is inclusive, encouraging, and relationship-focused. They are excellent at reading the room and adjusting their communication style to match their audience. They should practice being comfortable with constructive disagreement and not interpreting it as personal rejection.', + idealWorkEnvironment: 'Social, cooperative environments with strong team bonds and shared traditions', + communicationStyle: 'Warm, inclusive, and socially attuned', + color: 'from-rose-500 to-pink-600', + bgColor: 'bg-rose-500/10', + borderColor: 'border-rose-500/20', + icon: 'consul', + }, + { + code: 'ISTP', + name: 'The Virtuoso', + nickname: 'Practical Problem-Solver', + description: 'ISTPs are hands-on, adaptable individuals who excel at troubleshooting and finding practical solutions. In education, they bring a calm, resourceful presence and an ability to handle unexpected situations with ease. They are the go-to person when something needs fixing — literally or figuratively.', + strengths: ['Practical problem-solving', 'Crisis management', 'Hands-on skills', 'Calm under pressure'], + workRelationships: 'ISTPs value independence and mutual respect in their work relationships. They prefer colleagues who are competent, low-drama, and action-oriented. They build trust through demonstrated skill rather than social niceties. They are reliable in a crisis and appreciate colleagues who don\'t panic. They may seem reserved or detached, but they show care through practical actions — fixing things, solving problems, and stepping up when it matters.', + workplaceLanguage: 'ISTPs communicate with brevity and practicality. They use phrases like "Let me take a look at that," "Here\'s what I\'d do," or simply demonstrate solutions through action. Their language is minimal and to-the-point. They prefer showing over telling and may become impatient with lengthy discussions that don\'t lead to action. They should practice sharing their reasoning more openly so colleagues understand their thought process.', + idealWorkEnvironment: 'Hands-on environments with variety, autonomy, and real problems to solve', + communicationStyle: 'Brief, practical, and action-oriented', + color: 'from-zinc-500 to-stone-600', + bgColor: 'bg-zinc-500/10', + borderColor: 'border-zinc-500/20', + icon: 'virtuoso', + }, + { + code: 'ISFP', + name: 'The Adventurer', + nickname: 'Gentle Creative', + description: 'ISFPs are gentle, creative individuals who bring beauty and authenticity to everything they do. In education, they connect with students through creative expression, patience, and a genuine acceptance of each person\'s uniqueness. They create calm, aesthetically pleasing environments that promote learning.', + strengths: ['Creative expression', 'Gentle patience', 'Authentic connections', 'Aesthetic awareness'], + workRelationships: 'ISFPs value authenticity, kindness, and creative freedom in their work relationships. They connect best with colleagues who are genuine, non-judgmental, and respectful of personal space. They show care through thoughtful gestures and creative contributions rather than verbal expressions. They may avoid confrontation and need colleagues who create safe spaces for honest communication. They thrive when their unique creative contributions are noticed and appreciated.', + workplaceLanguage: 'ISFPs communicate with gentleness and sincerity. They use phrases like "I feel like..." or "What if we tried something different..." Their language is personal, understated, and often expressed through creative means — visual displays, handwritten notes, or carefully chosen words. They prefer one-on-one conversations and may become quiet in large group settings. They should be encouraged to share their creative ideas more openly.', + idealWorkEnvironment: 'Calm, aesthetically pleasing environments with creative freedom and personal space', + communicationStyle: 'Gentle, sincere, and creatively expressive', + color: 'from-violet-500 to-purple-600', + bgColor: 'bg-violet-500/10', + borderColor: 'border-violet-500/20', + icon: 'adventurer', + }, + { + code: 'ESTP', + name: 'The Entrepreneur', + nickname: 'Dynamic Doer', + description: 'ESTPs are energetic, action-oriented individuals who thrive in the moment. In education, they bring excitement, adaptability, and a hands-on approach that engages even the most reluctant learners. They are excellent at reading situations and responding quickly.', + strengths: ['Quick action', 'Situational awareness', 'Engaging energy', 'Practical adaptability'], + workRelationships: 'ESTPs build relationships through shared experiences and humor. They value colleagues who are fun, competent, and ready to jump into action. They prefer working with people who don\'t overthink things and can keep up with their fast pace. They are generous with their time and energy when they see a need. They may struggle with colleagues who are overly cautious or process-heavy and should practice patience with different working styles.', + workplaceLanguage: 'ESTPs communicate with energy, humor, and directness. They use phrases like "Let\'s just do it," "Watch this," or "I\'ve got an idea — follow me." Their language is action-oriented and often accompanied by physical demonstration. They are natural storytellers who use humor to connect and persuade. They should practice slowing down to listen fully before jumping to solutions and being more sensitive in their delivery of feedback.', + idealWorkEnvironment: 'Active, dynamic environments with variety and opportunities for hands-on engagement', + communicationStyle: 'Energetic, direct, and action-driven', + color: 'from-orange-600 to-red-600', + bgColor: 'bg-orange-600/10', + borderColor: 'border-orange-600/20', + icon: 'entrepreneur', + }, + { + code: 'ESFP', + name: 'The Entertainer', + nickname: 'Joyful Energizer', + description: 'ESFPs are vibrant, spontaneous individuals who bring joy and energy to every room they enter. In education, they create fun, engaging learning experiences and have a natural ability to connect with students through warmth, humor, and genuine enthusiasm.', + strengths: ['Joyful energy', 'Student engagement', 'Spontaneous creativity', 'Warm connections'], + workRelationships: 'ESFPs are the life of the staff room. They build relationships through shared laughter, spontaneous adventures, and genuine warmth. They value colleagues who are positive, fun-loving, and supportive. They create an atmosphere where people feel comfortable being themselves. They connect best with colleagues who appreciate their energy and don\'t try to dim their light. They may need help staying focused on long-term goals and following through on administrative tasks.', + workplaceLanguage: 'ESFPs communicate with enthusiasm, warmth, and expressiveness. They use phrases like "This is going to be so fun!" or "I love that idea!" Their language is animated, personal, and often accompanied by expressive gestures and facial expressions. They are natural entertainers who use humor and storytelling to engage their audience. They should practice being more concise in professional settings and balancing fun with focus during important discussions.', + idealWorkEnvironment: 'Fun, social environments with variety, teamwork, and opportunities to perform', + communicationStyle: 'Enthusiastic, expressive, and warmly engaging', + color: 'from-fuchsia-500 to-pink-600', + bgColor: 'bg-fuchsia-500/10', + borderColor: 'border-fuchsia-500/20', + icon: 'entertainer', + }, +], + esaFundingContent: { + approvedUses: [ + { iconId: 'school', title: 'Private School Tuition', description: 'Full or partial tuition at approved private schools, including autism-focused programs', color: 'from-violet-500 to-violet-600' }, + { iconId: 'heart', title: 'Specialized Therapies', description: 'Speech therapy, occupational therapy, ABA, physical therapy, and counseling', color: 'from-pink-500 to-rose-600' }, + { iconId: 'book', title: 'Tutoring Services', description: 'One-on-one or small group tutoring from approved educational providers', color: 'from-blue-500 to-blue-600' }, + { iconId: 'puzzle', title: 'Curriculum & Materials', description: 'Textbooks, workbooks, educational software, and approved learning materials', color: 'from-amber-500 to-orange-600' }, + { iconId: 'graduation', title: 'Online Learning', description: 'Approved online courses, virtual tutoring, and digital learning platforms', color: 'from-emerald-500 to-green-600' }, + { iconId: 'users', title: 'Educational Services', description: 'Social skills groups, life skills training, vocational preparation, and more', color: 'from-cyan-500 to-teal-600' }, + ], + keyPoints: [ + { iconId: 'arrow', label: 'Education money that follows the child' }, + { iconId: 'school', label: 'Instead of staying with one school' }, + { iconId: 'users', label: 'Parents choose the best fit for their child' }, + { iconId: 'check', label: 'Covers tuition, therapies, tutoring & more' }, + { iconId: 'star', label: 'More flexibility and control for families' }, + { iconId: 'heart', label: 'Especially impactful for students with disabilities' }, + ], + stateChecklist: [ + 'Whether your state offers an ESA or similar school choice program', + 'What the specific eligibility requirements are in your state', + 'What expenses and services ESA funds can be applied toward in your state', + 'The application process and deadlines for your state\'s program', + 'Any reporting or documentation requirements unique to your state', + ], + schoolImpactItems: [ + 'ESA makes specialized autism-focused programs accessible to more families', + 'Families can use ESA funds to cover tuition at approved schools', + 'Therapeutic services such as speech, OT, and ABA may be ESA-eligible', + 'It can remove financial barriers for families seeking the best fit for their child', + 'More enrolled students can support program expansion and staffing', + ], + staffRoleItems: [ + { title: 'Be Informed', description: 'Understand the basics of ESA so you can answer general questions from parents' }, + { title: 'Direct to Office', description: 'For detailed ESA questions, guide families to the office team for personalized support' }, + { title: 'Document Accurately', description: 'Ensure student services and progress are documented properly for ESA reporting' }, + { title: 'Stay Focused', description: 'Continue providing excellent, individualized education — ESA is about access, not changing what staff do' }, + ], + parentConversationScript: '"ESA stands for Empowerment Scholarship Account. It is state funding set aside for your child\'s education. Instead of the money only going to one school, it follows your child, so you can use it to pay for the school, therapies, tutoring, and services that work best for them. Our office team can walk you through the application process and help you understand what is covered."', + resources: [ + { title: 'AZ ESA Program', description: 'Arizona Empowerment Scholarship Account official page', url: '#' }, + { title: 'ESA Eligible Expenses', description: 'Full list of approved uses for ESA funds', url: '#' }, + { title: 'Family Application Guide', description: 'Step-by-step guide for families applying', url: '#' }, + { title: 'Provider Registration', description: 'How schools register as ESA providers', url: '#' }, + { title: 'ESA FAQ (State)', description: 'Official state FAQ for families and schools', url: '#' }, + { title: 'Internal ESA Procedures', description: 'School ESA documentation process', url: '#' }, + ], +}, + safetyProtocols: [ + { + id: 'fire', + title: 'Fire Drill Procedures', + iconId: 'fire', + color: 'from-red-400 to-orange-500', + steps: [ + 'Hear alarm — stop all activities immediately', + 'Grab class roster and emergency kit', + 'Line students up at classroom door', + 'Pre-teach Yellow Zone strategies for anxious students', + 'Walk, do not run, to designated assembly point', + 'Take attendance at assembly point', + 'Wait for all-clear signal from administration', + 'Return to classroom in orderly fashion', + ], + autismConsiderations: [ + 'Use visual fire drill card to prepare students in advance', + 'Provide noise-canceling headphones for sound-sensitive students', + 'Assign a buddy for students who may elope', + 'Use Stop and Wait signs during the drill', + 'Allow extra processing time at each step', + ], + }, + { + id: 'lockdown', + title: 'Lockdown Drill Steps', + iconId: 'shield', + color: 'from-blue-500 to-indigo-600', + steps: [ + 'Hear announcement — initiate lockdown immediately', + 'Lock classroom door and cover window', + 'Move students away from doors and windows', + 'Turn off lights and silence all devices', + 'Take attendance silently', + 'Maintain silence until all-clear', + 'Do not open door for anyone except verified admin', + 'Resume normal activities after debriefing', + ], + autismConsiderations: [ + 'Pre-teach lockdown with social story and visuals', + 'Have comfort items accessible', + 'Use Calm and Wait signs throughout', + 'Designate a quiet corner for students in distress', + 'Practice in low-stakes settings first', + ], + }, + { + id: 'medical', + title: 'Medical Emergency Response', + iconId: 'heart', + color: 'from-emerald-400 to-teal-500', + steps: [ + 'Assess the situation — is the student conscious and breathing?', + 'Call for help immediately', + 'Do not move the student unless in immediate danger', + 'Clear the area of other students', + 'Stay with the student and provide reassurance', + 'Document everything after the event', + ], + autismConsiderations: [ + 'Some students may not verbally report pain', + 'Watch for behavioral changes as pain indicators', + 'Use visual pain scale if available', + 'Communicate clearly and simply', + ], + }, +], + classroomTimerBackgrounds: [ + { + id: 'ocean', + name: 'Ocean Calm', + iconId: 'waves', + image: 'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1770662475079_dabe421c.png', + overlay: 'from-blue-950/40 via-blue-900/20 to-cyan-950/40', + textColor: 'text-cyan-100', + accentColor: 'text-cyan-300', + ringColor: 'stroke-cyan-400', + trackColor: 'stroke-cyan-900/50', + }, + { + id: 'aurora', + name: 'Aurora Borealis', + iconId: 'sparkles', + image: 'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1770662490787_3aabfbd4.jpg', + overlay: 'from-purple-950/40 via-emerald-950/20 to-indigo-950/40', + textColor: 'text-emerald-100', + accentColor: 'text-emerald-300', + ringColor: 'stroke-emerald-400', + trackColor: 'stroke-emerald-900/50', + }, + { + id: 'lava', + name: 'Lava Lamp', + iconId: 'sun', + image: 'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1770662521371_be5368c4.png', + overlay: 'from-orange-950/40 via-red-950/20 to-purple-950/40', + textColor: 'text-orange-100', + accentColor: 'text-orange-300', + ringColor: 'stroke-orange-400', + trackColor: 'stroke-orange-900/50', + }, + { + id: 'galaxy', + name: 'Deep Galaxy', + iconId: 'moon', + image: 'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1770662574948_03baf94b.png', + overlay: 'from-indigo-950/40 via-purple-950/20 to-blue-950/40', + textColor: 'text-purple-100', + accentColor: 'text-purple-300', + ringColor: 'stroke-purple-400', + trackColor: 'stroke-purple-900/50', + }, + { + id: 'forest', + name: 'Enchanted Forest', + iconId: 'tree-pine', + image: 'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1770662588347_123efdd7.jpg', + overlay: 'from-green-950/40 via-emerald-950/20 to-green-950/40', + textColor: 'text-green-100', + accentColor: 'text-green-300', + ringColor: 'stroke-green-400', + trackColor: 'stroke-green-900/50', + }, + { + id: 'rain', + name: 'Rainy Day', + iconId: 'cloud-rain', + image: 'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1770662620449_93a5fbcd.png', + overlay: 'from-slate-950/40 via-blue-950/20 to-slate-950/40', + textColor: 'text-blue-100', + accentColor: 'text-blue-300', + ringColor: 'stroke-blue-400', + trackColor: 'stroke-blue-900/50', + }, + { + id: 'coral', + name: 'Coral Reef', + iconId: 'fish', + image: 'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1770662661000_e74a933f.png', + overlay: 'from-teal-950/40 via-cyan-950/20 to-teal-950/40', + textColor: 'text-teal-100', + accentColor: 'text-teal-300', + ringColor: 'stroke-teal-400', + trackColor: 'stroke-teal-900/50', + }, + { + id: 'sunset', + name: 'Sunset Sky', + iconId: 'mountain', + image: 'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1770662744948_e27daa4c.png', + overlay: 'from-rose-950/40 via-amber-950/20 to-purple-950/40', + textColor: 'text-rose-100', + accentColor: 'text-rose-300', + ringColor: 'stroke-rose-400', + trackColor: 'stroke-rose-900/50', + }, +], + classroomTimerSounds: [ + { id: 'gentle-chime', name: 'Gentle Chime', icon: '🔔', frequency: 523.25 }, + { id: 'soft-bell', name: 'Soft Bell', icon: '🛎', frequency: 440 }, + { id: 'xylophone', name: 'Xylophone', icon: '🎵', frequency: 659.25 }, + { id: 'singing-bowl', name: 'Singing Bowl', icon: '🎶', frequency: 256 }, + { id: 'nature-birds', name: 'Nature Birds', icon: '🐦', frequency: 880 }, + { id: 'ocean-wave', name: 'Ocean Wave', icon: '🌊', frequency: 200 }, + { id: 'rain-stick', name: 'Rain Stick', icon: '🌧', frequency: 300 }, + { id: 'harp-gliss', name: 'Harp Glissando', icon: '🎵', frequency: 392 }, +], + classroomTimerPresets: [ + { label: '30s', seconds: 30 }, + { label: '1 min', seconds: 60 }, + { label: '2 min', seconds: 120 }, + { label: '3 min', seconds: 180 }, + { label: '5 min', seconds: 300 }, + { label: '10 min', seconds: 600 }, + { label: '15 min', seconds: 900 }, + { label: '20 min', seconds: 1200 }, + { label: '25 min', seconds: 1500 }, + { label: '30 min', seconds: 1800 }, +], + classroomTimerTips: [ + { + title: 'Transitions', + body: 'Use 5-3-1 minute warnings before activity changes. The visual countdown reduces anxiety about unexpected transitions.', + }, + { + title: 'Sensory Backgrounds', + body: 'Project calming backgrounds during work time or sensory breaks. Ocean and forest themes are great for de-escalation.', + }, + { + title: 'Sound Choices', + body: 'Choose gentle sounds - avoid startling tones. Singing bowl and gentle chime work well for students sensitive to sudden sounds.', + }, +], + personalityQuizFeatures: [ + { + id: 'questions', + label: '12 Questions', + description: 'Thoughtfully designed for educators', + toneClass: 'text-violet-400 bg-violet-500/10 border-violet-500/20', + }, + { + id: 'relationships', + label: 'Work Relationships', + description: 'How you connect with colleagues', + toneClass: 'text-blue-400 bg-blue-500/10 border-blue-500/20', + }, + { + id: 'language', + label: 'Workplace Language', + description: 'Your communication patterns', + toneClass: 'text-emerald-400 bg-emerald-500/10 border-emerald-500/20', + }, + { + id: 'growth', + label: 'Professional Growth', + description: 'Leverage your natural strengths', + toneClass: 'text-amber-400 bg-amber-500/10 border-amber-500/20', + }, +], + emotionalIntelligenceWeeklyFocus: { + title: 'Stress Regulation', + description: 'Emotional regulation during escalations - pause, breathe, respond instead of react. Notice your own zone before intervening with a student.', +}, + personalityWorkplaceContent: { + mbtiDescription: 'The Myers-Briggs Type Indicator identifies personality preferences across four dimensions, creating 16 unique personality types. Each type has distinct strengths in the workplace.', + dimensions: [ + { dim: 'E / I', label: 'Extraversion vs. Introversion', desc: 'Where you get your energy' }, + { dim: 'S / N', label: 'Sensing vs. Intuition', desc: 'How you gather information' }, + { dim: 'T / F', label: 'Thinking vs. Feeling', desc: 'How you make decisions' }, + { dim: 'J / P', label: 'Judging vs. Perceiving', desc: 'How you structure your work' }, + ], + workplaceTips: [ + 'Understand why some colleagues prefer email while others prefer face-to-face', + 'Recognize that different communication styles are not personal - they are personality-driven', + 'Build stronger teams by leveraging diverse personality strengths', + 'Reduce workplace conflict by understanding different decision-making approaches', + 'Improve your own self-awareness and professional growth', + ], +}, +}); + +module.exports = { + CONTENT_CATALOG_SEED_PAYLOADS, +}; diff --git a/backend/src/helpers.js b/backend/src/helpers.js index c38440d..46e19bf 100644 --- a/backend/src/helpers.js +++ b/backend/src/helpers.js @@ -1,5 +1,6 @@ const jwt = require('jsonwebtoken'); const config = require('./config'); +const { JWT_EXPIRES_IN } = require('./constants/auth'); module.exports = class Helpers { static wrapAsync(fn) { @@ -18,6 +19,6 @@ module.exports = class Helpers { } static jwtSign(data) { - return jwt.sign(data, config.secret_key, {expiresIn: '6h'}); + return jwt.sign(data, config.secret_key, {expiresIn: JWT_EXPIRES_IN}); }; }; diff --git a/backend/src/index.js b/backend/src/index.js index 649ffef..deaad8d 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -6,8 +6,9 @@ const passport = require('passport'); const path = require('path'); const fs = require('fs'); const bodyParser = require('body-parser'); -const db = require('./db/models'); const config = require('./config'); +const csrfOrigin = require('./middlewares/csrf-origin'); +const ForbiddenError = require('./services/notifications/errors/forbidden'); const swaggerUI = require('swagger-ui-express'); const swaggerJsDoc = require('swagger-jsdoc'); @@ -16,6 +17,9 @@ const fileRoutes = require('./routes/file'); const searchRoutes = require('./routes/search'); const sqlRoutes = require('./routes/sql'); const pexelsRoutes = require('./routes/pexels'); +const publicCampusesRoutes = require('./routes/public_campuses'); +const publicContentCatalogRoutes = require('./routes/public_content_catalog'); +const contentCatalogRoutes = require('./routes/content_catalog'); const organizationForAuthRoutes = require('./routes/organizationLogin'); @@ -74,6 +78,14 @@ const messagesRoutes = require('./routes/messages'); const message_recipientsRoutes = require('./routes/message_recipients'); const documentsRoutes = require('./routes/documents'); +const frameEntriesRoutes = require('./routes/frame_entries'); +const userProgressRoutes = require('./routes/user_progress'); +const safetyQuizResultsRoutes = require('./routes/safety_quiz_results'); +const walkthroughCheckinsRoutes = require('./routes/walkthrough_checkins'); +const communicationsRoutes = require('./routes/communications'); +const personalityQuizResultsRoutes = require('./routes/personality_quiz_results'); +const campusAttendanceRoutes = require('./routes/campus_attendance'); +const staffAttendanceRoutes = require('./routes/staff_attendance'); const getBaseUrl = (url) => { @@ -122,14 +134,27 @@ app.use('/api-docs', function (req, res, next) { next() }, swaggerUI.serve, swaggerUI.setup(specs)) -app.use(cors({origin: true})); +app.use(cors({ + credentials: true, + origin(origin, callback) { + if (!origin || config.auth.allowedOrigins.includes(origin)) { + callback(null, origin || true); + return; + } + + callback(new ForbiddenError()); + }, +})); require('./auth/auth'); app.use(bodyParser.json()); +app.use('/api', csrfOrigin); app.use('/api/auth', authRoutes); app.use('/api/file', fileRoutes); app.use('/api/pexels', pexelsRoutes); +app.use('/api/public/campuses', publicCampusesRoutes); +app.use('/api/public/content-catalog', publicContentCatalogRoutes); app.enable('trust proxy'); @@ -185,6 +210,19 @@ app.use('/api/message_recipients', passport.authenticate('jwt', {session: false} app.use('/api/documents', passport.authenticate('jwt', {session: false}), documentsRoutes); +app.use('/api/frame_entries', passport.authenticate('jwt', {session: false}), frameEntriesRoutes); + +app.use('/api/user_progress', passport.authenticate('jwt', {session: false}), userProgressRoutes); + +app.use('/api/safety_quiz_results', passport.authenticate('jwt', {session: false}), safetyQuizResultsRoutes); + +app.use('/api/walkthrough_checkins', passport.authenticate('jwt', {session: false}), walkthroughCheckinsRoutes); +app.use('/api/communications', passport.authenticate('jwt', {session: false}), communicationsRoutes); +app.use('/api/personality_quiz_results', passport.authenticate('jwt', {session: false}), personalityQuizResultsRoutes); +app.use('/api/campus_attendance', passport.authenticate('jwt', {session: false}), campusAttendanceRoutes); +app.use('/api/staff_attendance', passport.authenticate('jwt', {session: false}), staffAttendanceRoutes); +app.use('/api/content-catalog', passport.authenticate('jwt', {session: false}), contentCatalogRoutes); + app.use( '/api/openai', passport.authenticate('jwt', { session: false }), @@ -226,7 +264,7 @@ if (fs.existsSync(publicDir)) { }); } -const PORT = process.env.NODE_ENV === 'dev_stage' ? 3000 : 8080; +const PORT = config.serverPort; app.listen(PORT, () => { console.log(`Listening on port ${PORT}`); diff --git a/backend/src/middlewares/csrf-origin.js b/backend/src/middlewares/csrf-origin.js new file mode 100644 index 0000000..b79aaea --- /dev/null +++ b/backend/src/middlewares/csrf-origin.js @@ -0,0 +1,34 @@ +const config = require('../config'); +const { UNSAFE_HTTP_METHODS } = require('../constants/auth'); +const ForbiddenError = require('../services/notifications/errors/forbidden'); + +function getOriginFromHeader(value) { + if (!value) { + return null; + } + + try { + return new URL(value).origin; + } catch { + return null; + } +} + +function csrfOrigin(req, res, next) { + if (!UNSAFE_HTTP_METHODS.includes(req.method)) { + next(); + return; + } + + const sourceOrigin = getOriginFromHeader(req.headers.origin) + || getOriginFromHeader(req.headers.referer); + + if (sourceOrigin && config.auth.allowedOrigins.includes(sourceOrigin)) { + next(); + return; + } + + next(new ForbiddenError()); +} + +module.exports = csrfOrigin; diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js index 31d62cb..4b67e79 100644 --- a/backend/src/routes/auth.js +++ b/backend/src/routes/auth.js @@ -6,6 +6,11 @@ const AuthService = require('../services/auth'); const ForbiddenError = require('../services/notifications/errors/forbidden'); const EmailSender = require('../services/email'); const wrapAsync = require('../helpers').wrapAsync; +const { + clearSessionCookies, + extractRefreshCookie, + setSessionCookies, +} = require('../auth/cookies'); const router = express.Router(); @@ -58,10 +63,29 @@ const router = express.Router(); */ router.post('/signin/local', wrapAsync(async (req, res) => { - const payload = await AuthService.signin(req.body.email, req.body.password, req,); + const result = await AuthService.signin(req.body.email, req.body.password); + const session = await AuthService.createSession(result.user, req); + setSessionCookies(res, session); + const payload = await AuthService.currentUserProfile(result.user); res.status(200).send(payload); })); +router.post('/refresh', wrapAsync(async (req, res) => { + const session = await AuthService.refreshSession( + extractRefreshCookie(req), + req, + ); + setSessionCookies(res, session); + const payload = await AuthService.currentUserProfile(session.user); + res.status(200).send(payload); +})); + +router.post('/signout', wrapAsync(async (req, res) => { + await AuthService.revokeSession(extractRefreshCookie(req)); + clearSessionCookies(res); + res.status(204).send(); +})); + /** * @swagger * /api/auth/me: @@ -79,15 +103,14 @@ router.post('/signin/local', wrapAsync(async (req, res) => { * x-codegen-request-body-name: body */ -router.get('/me', passport.authenticate('jwt', {session: false}), (req, res) => { +router.get('/me', passport.authenticate('jwt', {session: false}), wrapAsync(async (req, res) => { if (!req.currentUser || !req.currentUser.id) { throw new ForbiddenError(); } - const payload = req.currentUser; - delete payload.password; + const payload = await AuthService.currentUserProfile(req.currentUser); res.status(200).send(payload); -}); +})); router.put('/password-reset', wrapAsync(async (req, res) => { const payload = await AuthService.passwordReset(req.body.token, req.body.password, req,); @@ -141,7 +164,7 @@ router.post('/send-password-reset-email', wrapAsync(async (req, res) => { router.post('/signup', wrapAsync(async (req, res) => { const link = new URL(req.headers.referer); - const payload = await AuthService.signup( + const result = await AuthService.signup( req.body.email, req.body.password, @@ -150,6 +173,9 @@ router.post('/signup', wrapAsync(async (req, res) => { req, link.host, ) + const session = await AuthService.createSession(result.user, req); + setSessionCookies(res, session); + const payload = await AuthService.currentUserProfile(result.user); res.status(200).send(payload); })); @@ -179,10 +205,10 @@ router.get('/signin/google', (req, res, next) => { router.get('/signin/google/callback', passport.authenticate("google", {failureRedirect: "/login", session: false}), - function (req, res) { - socialRedirect(res, req.query.state, req.user.token, config); + wrapAsync(async (req, res) => { + await socialRedirect(req, res, req.user.user, config); } -); +)); router.get('/signin/microsoft', (req, res, next) => { passport.authenticate("microsoft", { @@ -195,15 +221,17 @@ router.get('/signin/microsoft/callback', passport.authenticate("microsoft", { failureRedirect: "/login", session: false }), - function (req, res) { - socialRedirect(res, req.query.state, req.user.token, config); + wrapAsync(async (req, res) => { + await socialRedirect(req, res, req.user.user, config); } -); +)); router.use('/', require('../helpers').commonErrorHandler); -function socialRedirect(res, state, token, config) { - res.redirect(config.uiUrl + "/login?token=" + token); +async function socialRedirect(req, res, user, config) { + const session = await AuthService.createSession(user, req); + setSessionCookies(res, session); + res.redirect(config.uiUrl); } module.exports = router; diff --git a/backend/src/routes/campus_attendance.js b/backend/src/routes/campus_attendance.js new file mode 100644 index 0000000..815c65d --- /dev/null +++ b/backend/src/routes/campus_attendance.js @@ -0,0 +1,34 @@ +const express = require('express'); +const CampusAttendanceService = require('../services/campus_attendance'); +const { wrapAsync } = require('../helpers'); + +const router = express.Router(); + +router.get('/configs', wrapAsync(async (req, res) => { + const payload = await CampusAttendanceService.listConfigs(req.query, req.currentUser); + res.status(200).send(payload); +})); + +router.put('/configs/:campusKey', wrapAsync(async (req, res) => { + const payload = await CampusAttendanceService.upsertConfig(req.params.campusKey, req.body.data, req.currentUser); + res.status(200).send(payload); +})); + +router.get('/summaries', wrapAsync(async (req, res) => { + const payload = await CampusAttendanceService.listSummaries(req.query, req.currentUser); + res.status(200).send(payload); +})); + +router.put('/summaries/:campusKey/:date', wrapAsync(async (req, res) => { + const payload = await CampusAttendanceService.upsertSummary( + req.params.campusKey, + req.params.date, + req.body.data, + req.currentUser, + ); + res.status(200).send(payload); +})); + +router.use('/', require('../helpers').commonErrorHandler); + +module.exports = router; diff --git a/backend/src/routes/communications.js b/backend/src/routes/communications.js new file mode 100644 index 0000000..3c49b14 --- /dev/null +++ b/backend/src/routes/communications.js @@ -0,0 +1,29 @@ +const express = require('express'); +const CommunicationsService = require('../services/communications'); +const { wrapAsync } = require('../helpers'); + +const router = express.Router(); + +router.get('/parent-messages', wrapAsync(async (req, res) => { + const payload = await CommunicationsService.listParentMessages(req.query, req.currentUser); + res.status(200).send(payload); +})); + +router.post('/parent-messages', wrapAsync(async (req, res) => { + const payload = await CommunicationsService.createParentMessage(req.body.data, req.currentUser); + res.status(201).send(payload); +})); + +router.get('/events', wrapAsync(async (req, res) => { + const payload = await CommunicationsService.listEvents(req.query, req.currentUser); + res.status(200).send(payload); +})); + +router.post('/events', wrapAsync(async (req, res) => { + const payload = await CommunicationsService.createEvent(req.body.data, req.currentUser); + res.status(201).send(payload); +})); + +router.use('/', require('../helpers').commonErrorHandler); + +module.exports = router; diff --git a/backend/src/routes/content_catalog.js b/backend/src/routes/content_catalog.js new file mode 100644 index 0000000..15d16e3 --- /dev/null +++ b/backend/src/routes/content_catalog.js @@ -0,0 +1,35 @@ +const express = require('express'); + +const ContentCatalogService = require('../services/content_catalog'); +const wrapAsync = require('../helpers').wrapAsync; + +const router = express.Router(); + +router.get('/', wrapAsync(async (req, res) => { + const payload = await ContentCatalogService.list(req.currentUser); + res.status(200).send(payload); +})); + +router.post('/', wrapAsync(async (req, res) => { + const payload = await ContentCatalogService.create(req.body.data, req.currentUser); + res.status(201).send(payload); +})); + +router.get('/:contentType', wrapAsync(async (req, res) => { + const payload = await ContentCatalogService.findManagedByType(req.params.contentType, req.currentUser); + res.status(200).send(payload); +})); + +router.put('/:contentType', wrapAsync(async (req, res) => { + const payload = await ContentCatalogService.update(req.params.contentType, req.body.data, req.currentUser); + res.status(200).send(payload); +})); + +router.delete('/:contentType', wrapAsync(async (req, res) => { + await ContentCatalogService.delete(req.params.contentType, req.currentUser); + res.status(204).send(); +})); + +router.use('/', require('../helpers').commonErrorHandler); + +module.exports = router; diff --git a/backend/src/routes/frame_entries.js b/backend/src/routes/frame_entries.js new file mode 100644 index 0000000..f78e5eb --- /dev/null +++ b/backend/src/routes/frame_entries.js @@ -0,0 +1,24 @@ +const express = require('express'); +const FrameEntriesService = require('../services/frame_entries'); +const { wrapAsync } = require('../helpers'); + +const router = express.Router(); + +router.get('/', wrapAsync(async (req, res) => { + const payload = await FrameEntriesService.list(req.currentUser); + res.status(200).send(payload); +})); + +router.post('/', wrapAsync(async (req, res) => { + const payload = await FrameEntriesService.create(req.body.data, req.currentUser); + res.status(201).send(payload); +})); + +router.put('/:id', wrapAsync(async (req, res) => { + const payload = await FrameEntriesService.update(req.params.id, req.body.data, req.currentUser); + res.status(200).send(payload); +})); + +router.use('/', require('../helpers').commonErrorHandler); + +module.exports = router; diff --git a/backend/src/routes/personality_quiz_results.js b/backend/src/routes/personality_quiz_results.js new file mode 100644 index 0000000..bbd3c6f --- /dev/null +++ b/backend/src/routes/personality_quiz_results.js @@ -0,0 +1,24 @@ +const express = require('express'); +const PersonalityQuizResultsService = require('../services/personality_quiz_results'); +const { wrapAsync } = require('../helpers'); + +const router = express.Router(); + +router.get('/me', wrapAsync(async (req, res) => { + const payload = await PersonalityQuizResultsService.getCurrentUserResult(req.currentUser); + res.status(200).send(payload); +})); + +router.put('/me', wrapAsync(async (req, res) => { + const payload = await PersonalityQuizResultsService.upsertCurrentUserResult(req.body.data, req.currentUser); + res.status(200).send(payload); +})); + +router.get('/distribution', wrapAsync(async (req, res) => { + const payload = await PersonalityQuizResultsService.distribution(req.query, req.currentUser); + res.status(200).send(payload); +})); + +router.use('/', require('../helpers').commonErrorHandler); + +module.exports = router; diff --git a/backend/src/routes/pexels.js b/backend/src/routes/pexels.js index 8298595..0623be0 100644 --- a/backend/src/routes/pexels.js +++ b/backend/src/routes/pexels.js @@ -1,18 +1,41 @@ const express = require('express'); const router = express.Router(); const { pexelsKey, pexelsQuery } = require('../config'); +const { + DEFAULT_PEXELS_PAGE, + DEFAULT_PEXELS_PER_PAGE, + PEXELS_FALLBACK_IMAGE, + PEXELS_IMAGE_ORIENTATION, + PEXELS_IMAGE_SEARCH_URL, + PEXELS_MULTIPLE_IMAGE_DEFAULT_QUERIES, + PEXELS_MULTIPLE_IMAGE_ORIENTATION, + PEXELS_VIDEO_ORIENTATION, + PEXELS_VIDEO_SEARCH_URL, + PICSUM_FALLBACK_URL, + PICSUM_FALLBACK_PHOTOGRAPHER, + UNKNOWN_PHOTOGRAPHER_LABEL, +} = require('../constants/app'); const fetch = require('node-fetch'); const KEY = pexelsKey; +function buildPexelsSearchUrl(baseUrl, query, orientation) { + const params = new URLSearchParams({ + query, + orientation, + per_page: String(DEFAULT_PEXELS_PER_PAGE), + page: String(DEFAULT_PEXELS_PAGE), + }); + + return `${baseUrl}?${params.toString()}`; +} + router.get('/image', async (req, res) => { const headers = { Authorization: `${KEY}`, }; - const query = pexelsQuery || 'nature'; - const orientation = 'portrait'; - const perPage = 1; - const url = `https://api.pexels.com/v1/search?query=${query}&orientation=${orientation}&per_page=${perPage}&page=1`; + const query = pexelsQuery; + const url = buildPexelsSearchUrl(PEXELS_IMAGE_SEARCH_URL, query, PEXELS_IMAGE_ORIENTATION); try { const response = await fetch(url, { headers }); @@ -27,10 +50,8 @@ router.get('/video', async (req, res) => { const headers = { Authorization: `${KEY}`, }; - const query = pexelsQuery || 'nature'; - const orientation = 'portrait'; - const perPage = 1; - const url = `https://api.pexels.com/videos/search?query=${query}&orientation=${orientation}&per_page=${perPage}&page=1`; + const query = pexelsQuery; + const url = buildPexelsSearchUrl(PEXELS_VIDEO_SEARCH_URL, query, PEXELS_VIDEO_ORIENTATION); try { const response = await fetch(url, { headers }); @@ -48,29 +69,22 @@ router.get('/multiple-images', async (req, res) => { const queries = req.query.queries ? req.query.queries.split(',') - : ['home', 'apple', 'pizza', 'mountains', 'cat']; - const orientation = 'square'; - const perPage = 1; + : PEXELS_MULTIPLE_IMAGE_DEFAULT_QUERIES; - const fallbackImage = { - src: 'https://images.pexels.com/photos/8199252/pexels-photo-8199252.jpeg', - photographer: 'Yan Krukau', - photographer_url: 'https://www.pexels.com/@yankrukov', - }; const fetchFallbackImage = async () => { try { - const response = await fetch('https://picsum.photos/600'); + const response = await fetch(PICSUM_FALLBACK_URL); return { src: response.url, - photographer: 'Random Picsum', - photographer_url: 'https://picsum.photos/', + photographer: PICSUM_FALLBACK_PHOTOGRAPHER, + photographer_url: PICSUM_FALLBACK_URL, }; } catch (error) { - return fallbackImage; + return PEXELS_FALLBACK_IMAGE; } }; const fetchImage = async (query) => { - const url = `https://api.pexels.com/v1/search?query=${query}&orientation=${orientation}&per_page=${perPage}&page=1`; + const url = buildPexelsSearchUrl(PEXELS_IMAGE_SEARCH_URL, query, PEXELS_MULTIPLE_IMAGE_ORIENTATION); const response = await fetch(url, { headers }); const data = await response.json(); return data.photos[0] || null; @@ -83,15 +97,15 @@ router.get('/multiple-images', async (req, res) => { if (result.status === 'fulfilled' && result.value) { const image = result.value; return { - src: image.src?.original || fallbackImage.src, - photographer: image.photographer || fallbackImage.photographer, - photographer_url: image.photographer_url || fallbackImage.photographer_url, + src: image.src?.original || PEXELS_FALLBACK_IMAGE.src, + photographer: image.photographer || PEXELS_FALLBACK_IMAGE.photographer, + photographer_url: image.photographer_url || PEXELS_FALLBACK_IMAGE.photographer_url, }; } else { const fallback = await fetchFallbackImage(); return { src: fallback.src || '', - photographer: fallback.photographer || 'Unknown', + photographer: fallback.photographer || UNKNOWN_PHOTOGRAPHER_LABEL, photographer_url: fallback.photographer_url || '', }; } diff --git a/backend/src/routes/public_campuses.js b/backend/src/routes/public_campuses.js new file mode 100644 index 0000000..6e9f86d --- /dev/null +++ b/backend/src/routes/public_campuses.js @@ -0,0 +1,13 @@ +const express = require('express'); + +const CampusCatalogService = require('../services/campus_catalog'); +const wrapAsync = require('../helpers').wrapAsync; + +const router = express.Router(); + +router.get('/', wrapAsync(async (req, res) => { + const payload = await CampusCatalogService.listActive(); + res.status(200).send(payload); +})); + +module.exports = router; diff --git a/backend/src/routes/public_content_catalog.js b/backend/src/routes/public_content_catalog.js new file mode 100644 index 0000000..78e90db --- /dev/null +++ b/backend/src/routes/public_content_catalog.js @@ -0,0 +1,13 @@ +const express = require('express'); + +const ContentCatalogService = require('../services/content_catalog'); +const wrapAsync = require('../helpers').wrapAsync; + +const router = express.Router(); + +router.get('/:contentType', wrapAsync(async (req, res) => { + const payload = await ContentCatalogService.findByType(req.params.contentType); + res.status(200).send(payload); +})); + +module.exports = router; diff --git a/backend/src/routes/safety_quiz_results.js b/backend/src/routes/safety_quiz_results.js new file mode 100644 index 0000000..4eddcad --- /dev/null +++ b/backend/src/routes/safety_quiz_results.js @@ -0,0 +1,19 @@ +const express = require('express'); +const SafetyQuizResultsService = require('../services/safety_quiz_results'); +const { wrapAsync } = require('../helpers'); + +const router = express.Router(); + +router.get('/', wrapAsync(async (req, res) => { + const payload = await SafetyQuizResultsService.list(req.query, req.currentUser); + res.status(200).send(payload); +})); + +router.post('/', wrapAsync(async (req, res) => { + const payload = await SafetyQuizResultsService.create(req.body.data, req.currentUser); + res.status(201).send(payload); +})); + +router.use('/', require('../helpers').commonErrorHandler); + +module.exports = router; diff --git a/backend/src/routes/staff_attendance.js b/backend/src/routes/staff_attendance.js new file mode 100644 index 0000000..28b5440 --- /dev/null +++ b/backend/src/routes/staff_attendance.js @@ -0,0 +1,19 @@ +const express = require('express'); +const StaffAttendanceService = require('../services/staff_attendance'); +const { wrapAsync } = require('../helpers'); + +const router = express.Router(); + +router.get('/records', wrapAsync(async (req, res) => { + const payload = await StaffAttendanceService.listRecords(req.query, req.currentUser); + res.status(200).send(payload); +})); + +router.get('/summary', wrapAsync(async (req, res) => { + const payload = await StaffAttendanceService.summary(req.query, req.currentUser); + res.status(200).send(payload); +})); + +router.use('/', require('../helpers').commonErrorHandler); + +module.exports = router; diff --git a/backend/src/routes/user_progress.js b/backend/src/routes/user_progress.js new file mode 100644 index 0000000..b619b39 --- /dev/null +++ b/backend/src/routes/user_progress.js @@ -0,0 +1,24 @@ +const express = require('express'); +const UserProgressService = require('../services/user_progress'); +const { wrapAsync } = require('../helpers'); + +const router = express.Router(); + +router.get('/', wrapAsync(async (req, res) => { + const payload = await UserProgressService.list(req.query, req.currentUser); + res.status(200).send(payload); +})); + +router.post('/', wrapAsync(async (req, res) => { + const payload = await UserProgressService.upsert(req.body.data, req.currentUser); + res.status(200).send(payload); +})); + +router.delete('/by-item', wrapAsync(async (req, res) => { + const payload = await UserProgressService.removeByItem(req.query, req.currentUser); + res.status(200).send(payload); +})); + +router.use('/', require('../helpers').commonErrorHandler); + +module.exports = router; diff --git a/backend/src/routes/walkthrough_checkins.js b/backend/src/routes/walkthrough_checkins.js new file mode 100644 index 0000000..7717fa1 --- /dev/null +++ b/backend/src/routes/walkthrough_checkins.js @@ -0,0 +1,24 @@ +const express = require('express'); +const WalkthroughCheckinsService = require('../services/walkthrough_checkins'); +const { wrapAsync } = require('../helpers'); + +const router = express.Router(); + +router.get('/', wrapAsync(async (req, res) => { + const payload = await WalkthroughCheckinsService.list(req.query, req.currentUser); + res.status(200).send(payload); +})); + +router.post('/', wrapAsync(async (req, res) => { + const payload = await WalkthroughCheckinsService.create(req.body.data, req.currentUser); + res.status(201).send(payload); +})); + +router.delete('/:id', wrapAsync(async (req, res) => { + const payload = await WalkthroughCheckinsService.remove(req.params.id, req.currentUser); + res.status(200).send(payload); +})); + +router.use('/', require('../helpers').commonErrorHandler); + +module.exports = router; diff --git a/backend/src/services/auth.js b/backend/src/services/auth.js index bcc3411..47d911b 100644 --- a/backend/src/services/auth.js +++ b/backend/src/services/auth.js @@ -1,15 +1,312 @@ const UsersDBApi = require('../db/api/users'); +const AuthRefreshTokensDBApi = require('../db/api/auth_refresh_tokens'); const ValidationError = require('./notifications/errors/validation'); const ForbiddenError = require('./notifications/errors/forbidden'); const bcrypt = require('bcrypt'); +const crypto = require('crypto'); const EmailAddressVerificationEmail = require('./email/list/addressVerification'); const InvitationEmail = require("./email/list/invitation"); const PasswordResetEmail = require('./email/list/passwordReset'); const EmailSender = require('./email'); const config = require('../config'); const helpers = require('../helpers'); +const db = require('../db/models'); +const { + GENERATED_ROLE_TO_PRODUCT_ROLE, + PRODUCT_ROLE_VALUES, + STAFF_TYPE_TO_PRODUCT_ROLE, +} = require('../constants/roles'); + +function toPlainRecord(record) { + if (!record) { + return null; + } + + if (typeof record.get === 'function') { + return record.get({plain: true}); + } + + return record; +} + +function toRoleDto(role) { + const plainRole = toPlainRecord(role); + + if (!plainRole) { + return null; + } + + return { + id: plainRole.id, + name: plainRole.name, + globalAccess: plainRole.globalAccess, + }; +} + +function toOrganizationDto(organization) { + const plainOrganization = toPlainRecord(organization); + + if (!plainOrganization) { + return null; + } + + return { + id: plainOrganization.id, + name: plainOrganization.name, + }; +} + +function toCampusDto(campus) { + const plainCampus = toPlainRecord(campus); + + if (!plainCampus) { + return null; + } + + return { + id: plainCampus.id, + name: plainCampus.name, + code: plainCampus.code, + }; +} + +function toStaffProfileDto(staffProfile) { + const plainStaffProfile = toPlainRecord(staffProfile); + + if (!plainStaffProfile) { + return null; + } + + return { + id: plainStaffProfile.id, + employee_number: plainStaffProfile.employee_number, + job_title: plainStaffProfile.job_title, + staff_type: plainStaffProfile.staff_type, + status: plainStaffProfile.status, + organizationId: plainStaffProfile.organizationId, + campusId: plainStaffProfile.campusId, + userId: plainStaffProfile.userId, + }; +} + +function getPermissionNames(user) { + const permissions = [ + ...(user.app_role_permissions || []), + ...(user.custom_permissions || []), + ]; + + return [...new Set( + permissions + .map((permission) => toPlainRecord(permission)) + .filter((permission) => permission && permission.name) + .map((permission) => permission.name), + )]; +} + +function getProductRole(role, staffProfile) { + const roleDto = toRoleDto(role); + const staffProfileDto = toStaffProfileDto(staffProfile); + + if (roleDto && GENERATED_ROLE_TO_PRODUCT_ROLE[roleDto.name]) { + return GENERATED_ROLE_TO_PRODUCT_ROLE[roleDto.name]; + } + + if ( + staffProfileDto + && staffProfileDto.staff_type + && STAFF_TYPE_TO_PRODUCT_ROLE[staffProfileDto.staff_type] + ) { + return STAFF_TYPE_TO_PRODUCT_ROLE[staffProfileDto.staff_type]; + } + + return PRODUCT_ROLE_VALUES.TEACHER; +} + +function getTokenPayload(user) { + return { + user: { + id: user.id, + email: user.email + } + }; +} + +function getRequestUserAgent(req) { + return req && req.headers ? req.headers['user-agent'] || null : null; +} + +function getRequestIp(req) { + return req ? req.ip || req.connection?.remoteAddress || null : null; +} + +function generateOpaqueRefreshToken() { + return crypto.randomBytes(config.auth.refreshTokenBytes).toString('base64url'); +} + +function hashRefreshToken(token) { + return crypto + .createHash(config.auth.refreshTokenHashAlgorithm) + .update(token) + .digest('hex'); +} + +function getRefreshTokenExpiry() { + return new Date(Date.now() + config.auth.refreshTokenMaxAgeMs); +} class Auth { + static async currentUserProfile(currentUser) { + if (!currentUser || !currentUser.id) { + throw new ForbiddenError(); + } + + const user = await UsersDBApi.findBy({id: currentUser.id}); + + if (!user) { + throw new ForbiddenError(); + } + + const staffProfile = Array.isArray(user.staff_user) && user.staff_user.length > 0 + ? user.staff_user[0] + : null; + const campus = staffProfile && typeof staffProfile.getCampus === 'function' + ? await staffProfile.getCampus() + : null; + const staffProfileDto = toStaffProfileDto(staffProfile); + const campusDto = toCampusDto(campus); + + return { + id: user.id, + email: user.email, + firstName: user.firstName, + lastName: user.lastName, + phoneNumber: user.phoneNumber, + organizationsId: user.organizationsId, + organizations: toOrganizationDto(user.organizations), + app_role: toRoleDto(user.app_role), + productRole: getProductRole(user.app_role, staffProfile), + staffProfile: staffProfileDto, + campus: campusDto, + campusId: campusDto ? campusDto.id : staffProfileDto?.campusId || null, + permissions: getPermissionNames(user), + }; + } + + static async createSession(user, req, options = {}) { + const refreshToken = generateOpaqueRefreshToken(); + const familyId = options.familyId || crypto.randomUUID(); + const tokenRecord = await AuthRefreshTokensDBApi.create( + { + userId: user.id, + organizationId: user.organizationsId || null, + tokenHash: hashRefreshToken(refreshToken), + familyId, + previousTokenId: options.previousTokenId || null, + userAgent: getRequestUserAgent(req), + ipAddress: getRequestIp(req), + expiresAt: getRefreshTokenExpiry(), + }, + options, + ); + + return { + accessToken: helpers.jwtSign(getTokenPayload(user)), + refreshToken, + refreshTokenRecord: tokenRecord, + user, + }; + } + + static async refreshSession(refreshToken, req) { + if (!refreshToken) { + throw new ForbiddenError(); + } + + const tokenHash = hashRefreshToken(refreshToken); + const transaction = await db.sequelize.transaction(); + + try { + const existingToken = await AuthRefreshTokensDBApi.findByHash( + tokenHash, + { transaction }, + ); + + if (!existingToken) { + throw new ForbiddenError(); + } + + if (existingToken.revokedAt) { + await AuthRefreshTokensDBApi.revokeFamily( + existingToken.familyId, + { transaction }, + ); + await transaction.commit(); + throw new ForbiddenError(); + } + + if (new Date(existingToken.expiresAt).getTime() <= Date.now()) { + await AuthRefreshTokensDBApi.revoke( + existingToken.id, + null, + { transaction }, + ); + await transaction.commit(); + throw new ForbiddenError(); + } + + const user = await UsersDBApi.findBy({id: existingToken.userId}); + + if (!user || user.disabled) { + await AuthRefreshTokensDBApi.revokeFamily( + existingToken.familyId, + { transaction }, + ); + await transaction.commit(); + throw new ForbiddenError(); + } + + const nextSession = await this.createSession( + user, + req, + { + familyId: existingToken.familyId, + previousTokenId: existingToken.id, + transaction, + }, + ); + + await AuthRefreshTokensDBApi.revoke( + existingToken.id, + nextSession.refreshTokenRecord.id, + { transaction }, + ); + + await transaction.commit(); + return nextSession; + } catch (error) { + if (!transaction.finished) { + await transaction.rollback(); + } + throw error; + } + } + + static async revokeSession(refreshToken) { + if (!refreshToken) { + return; + } + + const tokenRecord = await AuthRefreshTokensDBApi.findByHash( + hashRefreshToken(refreshToken), + ); + + if (!tokenRecord || tokenRecord.revokedAt) { + return; + } + + await AuthRefreshTokensDBApi.revoke(tokenRecord.id); + } + static async signup(email, password, organizationId, options = {}, host) { const user = await UsersDBApi.findBy({email}); @@ -44,14 +341,9 @@ class Auth { ); } - const data = { - user: { - id: user.id, - email: user.email - } + return { + user, }; - - return helpers.jwtSign(data); } const newUser = await UsersDBApi.createFromAuth( @@ -73,17 +365,12 @@ class Auth { ); } - const data = { - user: { - id: newUser.id, - email: newUser.email - } + return { + user: newUser, }; - - return helpers.jwtSign(data); } - static async signin(email, password, options = {}) { + static async signin(email, password) { const user = await UsersDBApi.findBy({email}); if (!user) { @@ -125,14 +412,9 @@ class Auth { ); } - const data = { - user: { - id: user.id, - email: user.email - } + return { + user, }; - - return helpers.jwtSign(data); } static async sendEmailAddressVerificationEmail( @@ -293,14 +575,10 @@ class Auth { {transaction}, ); - await UsersDBApi.update( - currentUser.id, - data, - { - currentUser, - transaction - }, - ); + await UsersDBApi.update(currentUser.id, data, null, { + currentUser, + transaction + }); await transaction.commit(); diff --git a/backend/src/services/campus_attendance.js b/backend/src/services/campus_attendance.js new file mode 100644 index 0000000..5546340 --- /dev/null +++ b/backend/src/services/campus_attendance.js @@ -0,0 +1,374 @@ +const db = require('../db/models'); +const ForbiddenError = require('./notifications/errors/forbidden'); +const ValidationError = require('./notifications/errors/validation'); +const { + CAMPUS_ATTENDANCE_DEFAULT_LIMIT, + CAMPUS_ATTENDANCE_MANAGER_ROLE_NAMES, + CAMPUS_ATTENDANCE_MANAGE_PRODUCT_ROLES, + CAMPUS_ATTENDANCE_MAX_LIMIT, + CAMPUS_ATTENDANCE_TENANT_WIDE_ROLE_NAMES, + getProductRole, + normalizeCampusKey, +} = require('../constants/campus-attendance'); + +function getOrganizationId(currentUser) { + return currentUser?.organizations?.id + || currentUser?.organization?.id + || currentUser?.organizationsId + || currentUser?.organizationId + || null; +} + +function getCampusId(currentUser) { + if (Array.isArray(currentUser?.staff_user) && currentUser.staff_user[0]?.campusId) { + return currentUser.staff_user[0].campusId; + } + + return currentUser?.campusId || null; +} + +function getCurrentUserCampusKey(currentUser) { + const staffProfile = Array.isArray(currentUser?.staff_user) ? currentUser.staff_user[0] : null; + return normalizeCampusKey(currentUser?.campus?.code) + || normalizeCampusKey(currentUser?.campus?.name) + || normalizeCampusKey(staffProfile?.campus?.code) + || normalizeCampusKey(staffProfile?.campus?.name) + || null; +} + +function getDisplayName(currentUser) { + const firstName = currentUser?.firstName || ''; + const lastName = currentUser?.lastName || ''; + const fullName = `${firstName} ${lastName}`.trim(); + + return fullName || currentUser?.email || 'Staff Member'; +} + +function getRoleName(currentUser) { + return currentUser?.app_role?.name; +} + +function hasTenantWideAccess(currentUser) { + return currentUser?.app_role?.globalAccess === true + || CAMPUS_ATTENDANCE_TENANT_WIDE_ROLE_NAMES.includes(getRoleName(currentUser)); +} + +function canManageCampusAttendance(currentUser) { + return currentUser?.app_role?.globalAccess === true + || CAMPUS_ATTENDANCE_MANAGER_ROLE_NAMES.includes(getRoleName(currentUser)) + || CAMPUS_ATTENDANCE_MANAGE_PRODUCT_ROLES.includes(getProductRole(currentUser)); +} + +function assertAuthenticatedTenantUser(currentUser) { + if (currentUser?.id && getOrganizationId(currentUser)) { + return; + } + + throw new ForbiddenError(); +} + +function assertCanManageCampusAttendance(currentUser) { + assertAuthenticatedTenantUser(currentUser); + + if (canManageCampusAttendance(currentUser)) { + return; + } + + throw new ForbiddenError(); +} + +function campusKeyFromRoute(value) { + const campusKey = normalizeCampusKey(value); + + if (!campusKey) { + throw new ValidationError(); + } + + return campusKey; +} + +function assertCanAccessCampusKey(campusKey, currentUser) { + if (hasTenantWideAccess(currentUser)) { + return; + } + + const currentCampusKey = getCurrentUserCampusKey(currentUser); + + if (currentCampusKey && currentCampusKey === campusKey) { + return; + } + + throw new ForbiddenError(); +} + +function applyCampusScope(where, filter, currentUser) { + const requestedCampusKey = normalizeCampusKey(filter?.campusKey); + + if (requestedCampusKey) { + assertCanAccessCampusKey(requestedCampusKey, currentUser); + where.campus_key = requestedCampusKey; + return; + } + + if (hasTenantWideAccess(currentUser)) { + return; + } + + const currentCampusKey = getCurrentUserCampusKey(currentUser); + + if (!currentCampusKey) { + throw new ForbiddenError(); + } + + where.campus_key = currentCampusKey; +} + +function parseLimit(value) { + if (value === undefined) { + return CAMPUS_ATTENDANCE_DEFAULT_LIMIT; + } + + const limit = Number(value); + + if (!Number.isInteger(limit) || limit <= 0) { + throw new ValidationError(); + } + + return Math.min(limit, CAMPUS_ATTENDANCE_MAX_LIMIT); +} + +function requiredNonNegativeInteger(value) { + if (!Number.isInteger(value) || value < 0) { + throw new ValidationError(); + } + + return value; +} + +function optionalText(value) { + if (typeof value !== 'string' || value.trim().length === 0) { + return null; + } + + return value.trim(); +} + +function requiredDate(value) { + if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/u.test(value)) { + throw new ValidationError(); + } + + return value; +} + +function validateSummary(data) { + if (!data || typeof data !== 'object' || Array.isArray(data)) { + throw new ValidationError(); + } + + const totalEnrolled = requiredNonNegativeInteger(data.total_enrolled); + const totalPresent = requiredNonNegativeInteger(data.total_present); + const totalAbsent = requiredNonNegativeInteger(data.total_absent); + const totalTardy = requiredNonNegativeInteger(data.total_tardy || 0); + + if (totalEnrolled <= 0 || totalPresent > totalEnrolled || totalAbsent > totalEnrolled || totalTardy > totalEnrolled) { + throw new ValidationError(); + } + + return { + total_enrolled: totalEnrolled, + total_present: totalPresent, + total_absent: totalAbsent, + total_tardy: totalTardy, + attendance_percentage: Number(((totalPresent / totalEnrolled) * 100).toFixed(2)), + notes: optionalText(data.notes), + }; +} + +function toConfigDto(record) { + const plainRecord = typeof record.get === 'function' + ? record.get({ plain: true }) + : record; + + return { + id: plainRecord.id, + campus_key: plainRecord.campus_key, + attendance_link: plainRecord.attendance_link, + updated_by_label: plainRecord.updated_by_label, + organizationId: plainRecord.organizationId, + campusId: plainRecord.campusId, + createdById: plainRecord.createdById, + updatedById: plainRecord.updatedById, + createdAt: plainRecord.createdAt, + updatedAt: plainRecord.updatedAt, + }; +} + +function toSummaryDto(record) { + const plainRecord = typeof record.get === 'function' + ? record.get({ plain: true }) + : record; + + return { + id: plainRecord.id, + campus_key: plainRecord.campus_key, + date: plainRecord.attendance_date, + total_enrolled: plainRecord.total_enrolled, + total_present: plainRecord.total_present, + total_absent: plainRecord.total_absent, + total_tardy: plainRecord.total_tardy, + attendance_percentage: Number(plainRecord.attendance_percentage), + recorded_by_label: plainRecord.recorded_by_label, + notes: plainRecord.notes, + organizationId: plainRecord.organizationId, + campusId: plainRecord.campusId, + createdById: plainRecord.createdById, + updatedById: plainRecord.updatedById, + createdAt: plainRecord.createdAt, + updatedAt: plainRecord.updatedAt, + }; +} + +module.exports = class CampusAttendanceService { + static async listConfigs(filter, currentUser) { + assertAuthenticatedTenantUser(currentUser); + + const where = { + organizationId: getOrganizationId(currentUser), + }; + applyCampusScope(where, filter, currentUser); + + const result = await db.campus_attendance_config.findAndCountAll({ + where, + order: [['campus_key', 'asc']], + }); + + return { + rows: result.rows.map(toConfigDto), + count: result.count, + }; + } + + static async upsertConfig(campusKeyParam, data, currentUser) { + assertCanManageCampusAttendance(currentUser); + + const campusKey = campusKeyFromRoute(campusKeyParam); + assertCanAccessCampusKey(campusKey, currentUser); + + if (!data || typeof data !== 'object' || Array.isArray(data)) { + throw new ValidationError(); + } + + const attendanceLink = optionalText(data.attendance_link); + const where = { + organizationId: getOrganizationId(currentUser), + campus_key: campusKey, + }; + const payload = { + campus_key: campusKey, + attendance_link: attendanceLink, + updated_by_label: getDisplayName(currentUser), + organizationId: getOrganizationId(currentUser), + campusId: getCampusId(currentUser), + updatedById: currentUser.id, + }; + const transaction = await db.sequelize.transaction(); + + try { + const existing = await db.campus_attendance_config.findOne({ where, transaction }); + const saved = existing + ? await existing.update(payload, { transaction }) + : await db.campus_attendance_config.create( + { + ...payload, + createdById: currentUser.id, + }, + { transaction }, + ); + + await transaction.commit(); + return toConfigDto(saved); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async listSummaries(filter, currentUser) { + assertAuthenticatedTenantUser(currentUser); + + const where = { + organizationId: getOrganizationId(currentUser), + }; + applyCampusScope(where, filter, currentUser); + + if (filter.startDate) { + where.attendance_date = { + ...(where.attendance_date || {}), + [db.Sequelize.Op.gte]: requiredDate(filter.startDate), + }; + } + + if (filter.endDate) { + where.attendance_date = { + ...(where.attendance_date || {}), + [db.Sequelize.Op.lte]: requiredDate(filter.endDate), + }; + } + + const result = await db.campus_attendance_summaries.findAndCountAll({ + where, + limit: parseLimit(filter.limit), + order: [['attendance_date', 'desc'], ['campus_key', 'asc']], + }); + + return { + rows: result.rows.map(toSummaryDto), + count: result.count, + }; + } + + static async upsertSummary(campusKeyParam, dateParam, data, currentUser) { + assertCanManageCampusAttendance(currentUser); + + const campusKey = campusKeyFromRoute(campusKeyParam); + const attendanceDate = requiredDate(dateParam); + assertCanAccessCampusKey(campusKey, currentUser); + + const validatedSummary = validateSummary(data); + const where = { + organizationId: getOrganizationId(currentUser), + campus_key: campusKey, + attendance_date: attendanceDate, + }; + const payload = { + ...validatedSummary, + campus_key: campusKey, + attendance_date: attendanceDate, + recorded_by_label: getDisplayName(currentUser), + organizationId: getOrganizationId(currentUser), + campusId: getCampusId(currentUser), + updatedById: currentUser.id, + }; + const transaction = await db.sequelize.transaction(); + + try { + const existing = await db.campus_attendance_summaries.findOne({ where, transaction }); + const saved = existing + ? await existing.update(payload, { transaction }) + : await db.campus_attendance_summaries.create( + { + ...payload, + createdById: currentUser.id, + }, + { transaction }, + ); + + await transaction.commit(); + return toSummaryDto(saved); + } catch (error) { + await transaction.rollback(); + throw error; + } + } +}; diff --git a/backend/src/services/campus_catalog.js b/backend/src/services/campus_catalog.js new file mode 100644 index 0000000..53761e9 --- /dev/null +++ b/backend/src/services/campus_catalog.js @@ -0,0 +1,48 @@ +const db = require('../db/models'); + +function toCampusCatalogDto(campus) { + const plainCampus = campus.get({ plain: true }); + + return { + id: plainCampus.id, + name: plainCampus.name, + code: plainCampus.code, + mascot: plainCampus.mascot, + color: plainCampus.color, + bgGradient: plainCampus.bgGradient, + borderColor: plainCampus.borderColor, + textColor: plainCampus.textColor, + bgLight: plainCampus.bgLight, + description: plainCampus.description, + isOnline: plainCampus.isOnline, + }; +} + +module.exports = class CampusCatalogService { + static async listActive() { + const rows = await db.campuses.findAll({ + attributes: [ + 'id', + 'name', + 'code', + 'mascot', + 'color', + 'bgGradient', + 'borderColor', + 'textColor', + 'bgLight', + 'description', + 'isOnline', + ], + where: { + active: true, + }, + order: [['name', 'ASC']], + }); + + return { + rows: rows.map(toCampusCatalogDto), + count: rows.length, + }; + } +}; diff --git a/backend/src/services/communications.js b/backend/src/services/communications.js new file mode 100644 index 0000000..0f28524 --- /dev/null +++ b/backend/src/services/communications.js @@ -0,0 +1,285 @@ +const db = require('../db/models'); +const ForbiddenError = require('./notifications/errors/forbidden'); +const ValidationError = require('./notifications/errors/validation'); +const { + COMMUNICATION_AUDIENCES, + COMMUNICATION_CHANNELS, + COMMUNICATION_EVENT_TYPES, + COMMUNICATION_MANAGER_ROLE_NAMES, + COMMUNICATION_RECIPIENT_TYPES, + COMMUNICATION_STATUSES, + COMMUNICATION_TENANT_WIDE_ROLE_NAMES, +} = require('../constants/communications'); + +const DEFAULT_EVENT_ROLES = Object.freeze(['teacher', 'para', 'office', 'director']); +const EVENT_TYPES = Object.freeze(Object.values(COMMUNICATION_EVENT_TYPES)); + +function getOrganizationId(currentUser) { + return currentUser?.organizations?.id + || currentUser?.organization?.id + || currentUser?.organizationsId + || currentUser?.organizationId + || null; +} + +function getCampusId(currentUser) { + if (Array.isArray(currentUser?.staff_user) && currentUser.staff_user[0]?.campusId) { + return currentUser.staff_user[0].campusId; + } + + return currentUser?.campusId || null; +} + +function getRoleName(currentUser) { + return currentUser?.app_role?.name; +} + +function hasTenantWideAccess(currentUser) { + return currentUser?.app_role?.globalAccess === true + || COMMUNICATION_TENANT_WIDE_ROLE_NAMES.includes(getRoleName(currentUser)); +} + +function assertAuthenticatedTenantUser(currentUser) { + if (currentUser?.id && getOrganizationId(currentUser)) { + return; + } + + throw new ForbiddenError(); +} + +function assertCanManageCommunications(currentUser) { + assertAuthenticatedTenantUser(currentUser); + + if ( + currentUser?.app_role?.globalAccess === true + || COMMUNICATION_MANAGER_ROLE_NAMES.includes(getRoleName(currentUser)) + ) { + return; + } + + throw new ForbiddenError(); +} + +function applyCampusScope(where, currentUser) { + if (hasTenantWideAccess(currentUser)) { + return; + } + + const campusId = getCampusId(currentUser); + + if (campusId) { + where.campusId = campusId; + } +} + +function nullableString(value) { + if (typeof value !== 'string' || value.trim().length === 0) { + return null; + } + + return value.trim(); +} + +function requiredString(value) { + if (typeof value !== 'string' || value.trim().length === 0) { + throw new ValidationError(); + } + + return value.trim(); +} + +function validateRoles(roles) { + if (!Array.isArray(roles) || roles.length === 0) { + return DEFAULT_EVENT_ROLES; + } + + if (!roles.every((role) => typeof role === 'string' && role.trim().length > 0)) { + throw new ValidationError(); + } + + return roles.map((role) => role.trim()); +} + +function toParentMessageDto(message) { + const plainMessage = typeof message.get === 'function' + ? message.get({ plain: true }) + : message; + const firstRecipient = plainMessage.message_recipients_message?.[0] || null; + + return { + id: plainMessage.id, + text: plainMessage.body, + to: firstRecipient?.recipient_label || '', + date: new Date(plainMessage.sent_at || plainMessage.createdAt).toLocaleString('en-US'), + category: plainMessage.subject, + sentAt: plainMessage.sent_at, + organizationId: plainMessage.organizationId, + campusId: plainMessage.campusId, + createdById: plainMessage.createdById, + updatedById: plainMessage.updatedById, + createdAt: plainMessage.createdAt, + updatedAt: plainMessage.updatedAt, + }; +} + +function toCommunicationEventDto(event) { + const plainEvent = typeof event.get === 'function' + ? event.get({ plain: true }) + : event; + + return { + id: plainEvent.id, + title: plainEvent.title, + date: plainEvent.event_date, + type: plainEvent.event_type, + roles: plainEvent.roles, + organizationId: plainEvent.organizationId, + campusId: plainEvent.campusId, + createdById: plainEvent.createdById, + updatedById: plainEvent.updatedById, + createdAt: plainEvent.createdAt, + updatedAt: plainEvent.updatedAt, + }; +} + +module.exports = class CommunicationsService { + static async listParentMessages(filter, currentUser) { + assertAuthenticatedTenantUser(currentUser); + + const where = { + organizationId: getOrganizationId(currentUser), + createdById: currentUser.id, + audience: COMMUNICATION_AUDIENCES.GUARDIANS, + }; + applyCampusScope(where, currentUser); + + if (filter.category) { + where.subject = filter.category; + } + + const result = await db.messages.findAndCountAll({ + where, + include: [ + { + model: db.message_recipients, + as: 'message_recipients_message', + }, + ], + distinct: true, + order: [['sent_at', 'desc'], ['createdAt', 'desc']], + }); + + return { + rows: result.rows.map(toParentMessageDto), + count: result.count, + }; + } + + static async createParentMessage(data, currentUser) { + assertAuthenticatedTenantUser(currentUser); + + const recipientName = requiredString(data?.recipientName); + const messageText = requiredString(data?.messageText); + const category = nullableString(data?.category) || 'general'; + const transaction = await db.sequelize.transaction(); + + try { + const createdMessage = await db.messages.create( + { + subject: category, + body: messageText, + channel: COMMUNICATION_CHANNELS.IN_APP, + audience: COMMUNICATION_AUDIENCES.GUARDIANS, + sent_at: new Date(), + status: COMMUNICATION_STATUSES.SENT, + organizationId: getOrganizationId(currentUser), + campusId: getCampusId(currentUser), + sent_byId: currentUser.id, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { transaction }, + ); + + await db.message_recipients.create( + { + recipient_type: COMMUNICATION_RECIPIENT_TYPES.GUARDIAN, + recipient_label: recipientName, + destination: null, + delivery_status: COMMUNICATION_STATUSES.SENT, + delivered_at: new Date(), + read_at: null, + organizationId: getOrganizationId(currentUser), + messageId: createdMessage.id, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { transaction }, + ); + + await transaction.commit(); + + const savedMessage = await db.messages.findByPk(createdMessage.id, { + include: [ + { + model: db.message_recipients, + as: 'message_recipients_message', + }, + ], + }); + + return toParentMessageDto(savedMessage); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async listEvents(filter, currentUser) { + assertAuthenticatedTenantUser(currentUser); + + const where = { + organizationId: getOrganizationId(currentUser), + }; + applyCampusScope(where, currentUser); + + if (filter.type) { + where.event_type = filter.type; + } + + const result = await db.communication_events.findAndCountAll({ + where, + order: [['event_date', 'asc'], ['createdAt', 'desc']], + }); + + return { + rows: result.rows.map(toCommunicationEventDto), + count: result.count, + }; + } + + static async createEvent(data, currentUser) { + assertCanManageCommunications(currentUser); + + const title = requiredString(data?.title); + const date = requiredString(data?.date); + const type = requiredString(data?.type); + + if (!EVENT_TYPES.includes(type)) { + throw new ValidationError(); + } + + const createdEvent = await db.communication_events.create({ + title, + event_date: date, + event_type: type, + roles: validateRoles(data?.roles), + organizationId: getOrganizationId(currentUser), + campusId: getCampusId(currentUser), + createdById: currentUser.id, + updatedById: currentUser.id, + }); + + return toCommunicationEventDto(createdEvent); + } +}; diff --git a/backend/src/services/content_catalog.js b/backend/src/services/content_catalog.js new file mode 100644 index 0000000..6effabd --- /dev/null +++ b/backend/src/services/content_catalog.js @@ -0,0 +1,172 @@ +const db = require('../db/models'); +const ForbiddenError = require('./notifications/errors/forbidden'); +const ValidationError = require('./notifications/errors/validation'); +const { CONTENT_CATALOG_MANAGER_ROLE_NAMES } = require('../constants/content-catalog'); + +function toContentCatalogDto(record) { + const plainRecord = record.get({ plain: true }); + + return { + id: plainRecord.id, + content_type: plainRecord.content_type, + payload: plainRecord.payload, + updatedAt: plainRecord.updatedAt, + }; +} + +function assertCanManageContentCatalog(currentUser) { + const roleName = currentUser?.app_role?.name; + + if (currentUser?.app_role?.globalAccess === true || CONTENT_CATALOG_MANAGER_ROLE_NAMES.includes(roleName)) { + return; + } + + throw new ForbiddenError(); +} + +function assertValidContentType(contentType) { + if (typeof contentType !== 'string' || contentType.trim().length === 0) { + throw new ValidationError(); + } + + return contentType.trim(); +} + +function assertValidPayload(payload) { + if (payload === undefined) { + throw new ValidationError(); + } + + return payload; +} + +module.exports = class ContentCatalogService { + static async list(currentUser) { + assertCanManageContentCatalog(currentUser); + + const result = await db.content_catalog.findAndCountAll({ + order: [['content_type', 'asc']], + }); + + return { + rows: result.rows.map(toContentCatalogDto), + count: result.count, + }; + } + + static async findByType(contentType) { + const record = await db.content_catalog.findOne({ + where: { + content_type: assertValidContentType(contentType), + active: true, + }, + }); + + if (!record) { + throw new ValidationError('contentCatalogNotFound'); + } + + return toContentCatalogDto(record); + } + + static async findManagedByType(contentType, currentUser) { + assertCanManageContentCatalog(currentUser); + + return this.findByType(contentType); + } + + static async create(data, currentUser) { + assertCanManageContentCatalog(currentUser); + + const contentType = assertValidContentType(data?.content_type); + const payload = assertValidPayload(data?.payload); + + const existingRecord = await db.content_catalog.findOne({ + where: { content_type: contentType }, + paranoid: false, + }); + + if (existingRecord && !existingRecord.deletedAt) { + throw new ValidationError(); + } + + const transaction = await db.sequelize.transaction(); + + try { + const record = existingRecord + ? await existingRecord.restore({ transaction }).then(() => existingRecord.update({ + payload, + active: data.active !== false, + importHash: data.importHash || null, + }, { transaction })) + : await db.content_catalog.create({ + content_type: contentType, + payload, + active: data.active !== false, + importHash: data.importHash || null, + }, { transaction }); + + await transaction.commit(); + return toContentCatalogDto(record); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async update(contentType, data, currentUser) { + assertCanManageContentCatalog(currentUser); + + const normalizedContentType = assertValidContentType(contentType); + const payload = assertValidPayload(data?.payload); + const transaction = await db.sequelize.transaction(); + + try { + const record = await db.content_catalog.findOne({ + where: { content_type: normalizedContentType }, + transaction, + }); + + if (!record) { + throw new ValidationError('contentCatalogNotFound'); + } + + await record.update({ + payload, + active: data.active !== false, + }, { transaction }); + + await transaction.commit(); + return toContentCatalogDto(record); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async delete(contentType, currentUser) { + assertCanManageContentCatalog(currentUser); + + const normalizedContentType = assertValidContentType(contentType); + const transaction = await db.sequelize.transaction(); + + try { + const record = await db.content_catalog.findOne({ + where: { content_type: normalizedContentType }, + transaction, + }); + + if (!record) { + throw new ValidationError('contentCatalogNotFound'); + } + + await record.update({ active: false }, { transaction }); + await record.destroy({ transaction }); + await transaction.commit(); + return true; + } catch (error) { + await transaction.rollback(); + throw error; + } + } +}; diff --git a/backend/src/services/frame_entries.js b/backend/src/services/frame_entries.js new file mode 100644 index 0000000..f403d5e --- /dev/null +++ b/backend/src/services/frame_entries.js @@ -0,0 +1,184 @@ +const db = require('../db/models'); +const ForbiddenError = require('./notifications/errors/forbidden'); +const ValidationError = require('./notifications/errors/validation'); +const { FRAME_EDITOR_ROLE_NAMES } = require('../constants/frame'); + +const REQUIRED_FIELDS = [ + 'week_of', + 'posted_date', + 'formal', + 'recognition', + 'application', + 'management', + 'emotional', + 'author', +]; + +function getOrganizationId(currentUser) { + return currentUser?.organizations?.id || currentUser?.organizationsId || null; +} + +function getCampusId(currentUser, data) { + if (data.campusId) { + return data.campusId; + } + + if (Array.isArray(currentUser?.staff_user) && currentUser.staff_user[0]?.campusId) { + return currentUser.staff_user[0].campusId; + } + + return null; +} + +function assertCanEdit(currentUser) { + const roleName = currentUser?.app_role?.name; + const hasGlobalAccess = currentUser?.app_role?.globalAccess === true; + + if (hasGlobalAccess || FRAME_EDITOR_ROLE_NAMES.includes(roleName)) { + return; + } + + throw new ForbiddenError(); +} + +function assertValidFrameEntry(data) { + if (!data || typeof data !== 'object' || Array.isArray(data)) { + throw new ValidationError(); + } + + const hasMissingField = REQUIRED_FIELDS.some((field) => { + const value = data[field]; + return typeof value !== 'string' || value.trim().length === 0; + }); + + if (hasMissingField) { + throw new ValidationError(); + } +} + +function toDto(entry) { + const plainEntry = typeof entry.get === 'function' + ? entry.get({ plain: true }) + : entry; + + return { + id: plainEntry.id, + week_of: plainEntry.week_of, + posted_date: plainEntry.posted_date, + formal: plainEntry.formal, + recognition: plainEntry.recognition, + application: plainEntry.application, + management: plainEntry.management, + emotional: plainEntry.emotional, + author: plainEntry.author, + organizationId: plainEntry.organizationId, + campusId: plainEntry.campusId, + createdAt: plainEntry.createdAt, + updatedAt: plainEntry.updatedAt, + }; +} + +module.exports = class FrameEntriesService { + static async list(currentUser) { + const organizationId = getOrganizationId(currentUser); + + if (!organizationId) { + throw new ForbiddenError(); + } + + const result = await db.frame_entries.findAndCountAll({ + where: { organizationId }, + order: [['createdAt', 'desc']], + }); + + return { + rows: result.rows.map(toDto), + count: result.count, + }; + } + + static async create(data, currentUser) { + assertCanEdit(currentUser); + assertValidFrameEntry(data); + + const organizationId = getOrganizationId(currentUser); + + if (!organizationId) { + throw new ForbiddenError(); + } + + const transaction = await db.sequelize.transaction(); + + try { + const entry = await db.frame_entries.create( + { + week_of: data.week_of.trim(), + posted_date: data.posted_date.trim(), + formal: data.formal.trim(), + recognition: data.recognition.trim(), + application: data.application.trim(), + management: data.management.trim(), + emotional: data.emotional.trim(), + author: data.author.trim(), + organizationId, + campusId: getCampusId(currentUser, data), + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { transaction }, + ); + + await transaction.commit(); + return toDto(entry); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async update(id, data, currentUser) { + assertCanEdit(currentUser); + assertValidFrameEntry(data); + + const organizationId = getOrganizationId(currentUser); + + if (!organizationId) { + throw new ForbiddenError(); + } + + const transaction = await db.sequelize.transaction(); + + try { + const entry = await db.frame_entries.findOne({ + where: { id, organizationId }, + transaction, + }); + + if (!entry) { + throw new ValidationError(); + } + + await entry.update( + { + week_of: data.week_of.trim(), + posted_date: data.posted_date.trim(), + formal: data.formal.trim(), + recognition: data.recognition.trim(), + application: data.application.trim(), + management: data.management.trim(), + emotional: data.emotional.trim(), + author: data.author.trim(), + campusId: getCampusId(currentUser, data), + updatedById: currentUser.id, + }, + { transaction }, + ); + + await transaction.commit(); + return toDto(entry); + } catch (error) { + await transaction.rollback(); + throw error; + } + } +}; diff --git a/backend/src/services/personality_quiz_results.js b/backend/src/services/personality_quiz_results.js new file mode 100644 index 0000000..d6b0f49 --- /dev/null +++ b/backend/src/services/personality_quiz_results.js @@ -0,0 +1,173 @@ +const db = require('../db/models'); +const ForbiddenError = require('./notifications/errors/forbidden'); +const ValidationError = require('./notifications/errors/validation'); +const { PERSONALITY_REPORT_ROLE_NAMES } = require('../constants/personality'); + +function getOrganizationId(currentUser) { + return currentUser?.organizations?.id + || currentUser?.organization?.id + || currentUser?.organizationsId + || currentUser?.organizationId + || null; +} + +function getCampusId(currentUser) { + if (Array.isArray(currentUser?.staff_user) && currentUser.staff_user[0]?.campusId) { + return currentUser.staff_user[0].campusId; + } + + return currentUser?.campusId || null; +} + +function assertAuthenticatedUser(currentUser) { + if (!currentUser?.id || !getOrganizationId(currentUser)) { + throw new ForbiddenError(); + } +} + +function canViewDistribution(currentUser) { + const roleName = currentUser?.app_role?.name; + return currentUser?.app_role?.globalAccess === true || PERSONALITY_REPORT_ROLE_NAMES.includes(roleName); +} + +function assertValidResult(data) { + if (!data || typeof data !== 'object' || Array.isArray(data)) { + throw new ValidationError(); + } + + if ( + typeof data.personality_type !== 'string' + || data.personality_type.trim().length === 0 + || !data.quiz_answers + || typeof data.quiz_answers !== 'object' + || Array.isArray(data.quiz_answers) + ) { + throw new ValidationError(); + } + + const answerValues = Object.values(data.quiz_answers); + if (!answerValues.every((value) => typeof value === 'string' && value.trim().length > 0)) { + throw new ValidationError(); + } +} + +function toDto(record) { + if (!record) { + return null; + } + + const plainRecord = typeof record.get === 'function' + ? record.get({ plain: true }) + : record; + + return { + id: plainRecord.id, + personality_type: plainRecord.personality_type, + quiz_answers: plainRecord.quiz_answers, + completed_at: plainRecord.completed_at, + organizationId: plainRecord.organizationId, + campusId: plainRecord.campusId, + userId: plainRecord.userId, + createdById: plainRecord.createdById, + updatedById: plainRecord.updatedById, + createdAt: plainRecord.createdAt, + updatedAt: plainRecord.updatedAt, + }; +} + +module.exports = class PersonalityQuizResultsService { + static async getCurrentUserResult(currentUser) { + assertAuthenticatedUser(currentUser); + + const record = await db.personality_quiz_results.findOne({ + where: { + organizationId: getOrganizationId(currentUser), + userId: currentUser.id, + }, + order: [['updatedAt', 'desc']], + }); + + return toDto(record); + } + + static async upsertCurrentUserResult(data, currentUser) { + assertAuthenticatedUser(currentUser); + assertValidResult(data); + + const where = { + organizationId: getOrganizationId(currentUser), + userId: currentUser.id, + }; + const transaction = await db.sequelize.transaction(); + + try { + const existing = await db.personality_quiz_results.findOne({ + where, + transaction, + }); + + const payload = { + personality_type: data.personality_type.trim().toUpperCase(), + quiz_answers: data.quiz_answers, + completed_at: new Date(), + organizationId: getOrganizationId(currentUser), + campusId: getCampusId(currentUser), + userId: currentUser.id, + updatedById: currentUser.id, + }; + + let saved; + if (existing) { + saved = await existing.update(payload, { transaction }); + } else { + saved = await db.personality_quiz_results.create( + { + ...payload, + createdById: currentUser.id, + }, + { transaction }, + ); + } + + await transaction.commit(); + return toDto(saved); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async distribution(filter, currentUser) { + assertAuthenticatedUser(currentUser); + + if (!canViewDistribution(currentUser)) { + throw new ForbiddenError(); + } + + const where = { + organizationId: getOrganizationId(currentUser), + }; + + if (filter.campusId) { + where.campusId = filter.campusId; + } + + const rows = await db.personality_quiz_results.findAll({ + attributes: [ + 'personality_type', + [db.sequelize.fn('COUNT', db.sequelize.col('id')), 'count'], + ], + where, + group: ['personality_type'], + order: [[db.sequelize.fn('COUNT', db.sequelize.col('id')), 'desc']], + }); + + return { + rows: rows.map((row) => ({ + type: row.get('personality_type'), + count: Number(row.get('count')), + })), + count: rows.length, + }; + } +}; diff --git a/backend/src/services/safety_quiz_results.js b/backend/src/services/safety_quiz_results.js new file mode 100644 index 0000000..a361adb --- /dev/null +++ b/backend/src/services/safety_quiz_results.js @@ -0,0 +1,155 @@ +const db = require('../db/models'); +const ForbiddenError = require('./notifications/errors/forbidden'); +const ValidationError = require('./notifications/errors/validation'); +const { GENERATED_ROLE_TO_PRODUCT_ROLE, PRODUCT_ROLE_VALUES } = require('../constants/roles'); +const { SAFETY_QUIZ_REPORT_ROLE_NAMES } = require('../constants/safety-quiz'); + +function getOrganizationId(currentUser) { + return currentUser?.organizations?.id || currentUser?.organizationsId || null; +} + +function getCampusId(currentUser) { + if (Array.isArray(currentUser?.staff_user) && currentUser.staff_user[0]?.campusId) { + return currentUser.staff_user[0].campusId; + } + + return null; +} + +function getDisplayName(currentUser) { + const firstName = currentUser?.firstName || ''; + const lastName = currentUser?.lastName || ''; + const fullName = `${firstName} ${lastName}`.trim(); + + return fullName || currentUser?.email || 'Staff Member'; +} + +function getProductRole(currentUser) { + const roleName = currentUser?.app_role?.name; + + if (roleName && GENERATED_ROLE_TO_PRODUCT_ROLE[roleName]) { + return GENERATED_ROLE_TO_PRODUCT_ROLE[roleName]; + } + + return PRODUCT_ROLE_VALUES.TEACHER; +} + +function canViewReports(currentUser) { + const roleName = currentUser?.app_role?.name; + return currentUser?.app_role?.globalAccess === true || SAFETY_QUIZ_REPORT_ROLE_NAMES.includes(roleName); +} + +function assertAuthenticatedUser(currentUser) { + if (!currentUser?.id || !getOrganizationId(currentUser)) { + throw new ForbiddenError(); + } +} + +function assertValidResult(data) { + if (!data || typeof data !== 'object' || Array.isArray(data)) { + throw new ValidationError(); + } + + const requiredStrings = ['quiz_id', 'quiz_title', 'week_of']; + const hasMissingString = requiredStrings.some((field) => { + const value = data[field]; + return typeof value !== 'string' || value.trim().length === 0; + }); + + if ( + hasMissingString + || !Number.isInteger(data.score) + || !Number.isInteger(data.total_questions) + || !Array.isArray(data.answers) + || !data.answers.every(Number.isInteger) + ) { + throw new ValidationError(); + } +} + +function toDto(record) { + const plainRecord = typeof record.get === 'function' + ? record.get({ plain: true }) + : record; + + return { + id: plainRecord.id, + quiz_id: plainRecord.quiz_id, + quiz_title: plainRecord.quiz_title, + week_of: plainRecord.week_of, + score: plainRecord.score, + total_questions: plainRecord.total_questions, + answers: plainRecord.answers, + user_name: plainRecord.user_name, + user_role: plainRecord.user_role, + completed_at: plainRecord.completed_at, + organizationId: plainRecord.organizationId, + campusId: plainRecord.campusId, + userId: plainRecord.userId, + createdAt: plainRecord.createdAt, + updatedAt: plainRecord.updatedAt, + }; +} + +module.exports = class SafetyQuizResultsService { + static async list(filter, currentUser) { + assertAuthenticatedUser(currentUser); + + const where = { + organizationId: getOrganizationId(currentUser), + }; + + if (!canViewReports(currentUser)) { + where.userId = currentUser.id; + } + + if (filter.week_of) { + where.week_of = filter.week_of; + } + + const result = await db.safety_quiz_results.findAndCountAll({ + where, + order: [['completed_at', 'desc']], + }); + + return { + rows: result.rows.map(toDto), + count: result.count, + }; + } + + static async create(data, currentUser) { + assertAuthenticatedUser(currentUser); + assertValidResult(data); + + const transaction = await db.sequelize.transaction(); + + try { + const created = await db.safety_quiz_results.create( + { + quiz_id: data.quiz_id.trim(), + quiz_title: data.quiz_title.trim(), + week_of: data.week_of.trim(), + score: data.score, + total_questions: data.total_questions, + answers: data.answers, + user_name: getDisplayName(currentUser), + user_role: getProductRole(currentUser), + completed_at: new Date(), + organizationId: getOrganizationId(currentUser), + campusId: getCampusId(currentUser), + userId: currentUser.id, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { transaction }, + ); + + await transaction.commit(); + return toDto(created); + } catch (error) { + await transaction.rollback(); + throw error; + } + } +}; diff --git a/backend/src/services/staff_attendance.js b/backend/src/services/staff_attendance.js new file mode 100644 index 0000000..96dc440 --- /dev/null +++ b/backend/src/services/staff_attendance.js @@ -0,0 +1,192 @@ +const db = require('../db/models'); +const ForbiddenError = require('./notifications/errors/forbidden'); +const ValidationError = require('./notifications/errors/validation'); +const { + STAFF_ATTENDANCE_DEFAULT_LIMIT, + STAFF_ATTENDANCE_MAX_LIMIT, + STAFF_ATTENDANCE_REPORT_ROLE_NAMES, + STAFF_ATTENDANCE_STATUSES, + STAFF_ATTENDANCE_TENANT_WIDE_ROLE_NAMES, +} = require('../constants/staff-attendance'); + +function getOrganizationId(currentUser) { + return currentUser?.organizations?.id + || currentUser?.organization?.id + || currentUser?.organizationsId + || currentUser?.organizationId + || null; +} + +function getCampusId(currentUser) { + if (Array.isArray(currentUser?.staff_user) && currentUser.staff_user[0]?.campusId) { + return currentUser.staff_user[0].campusId; + } + + return currentUser?.campusId || null; +} + +function getRoleName(currentUser) { + return currentUser?.app_role?.name; +} + +function assertAuthenticatedTenantUser(currentUser) { + if (currentUser?.id && getOrganizationId(currentUser)) { + return; + } + + throw new ForbiddenError(); +} + +function canViewReports(currentUser) { + return currentUser?.app_role?.globalAccess === true + || STAFF_ATTENDANCE_REPORT_ROLE_NAMES.includes(getRoleName(currentUser)); +} + +function hasTenantWideAccess(currentUser) { + return currentUser?.app_role?.globalAccess === true + || STAFF_ATTENDANCE_TENANT_WIDE_ROLE_NAMES.includes(getRoleName(currentUser)); +} + +function parseLimit(value) { + if (value === undefined) { + return STAFF_ATTENDANCE_DEFAULT_LIMIT; + } + + const limit = Number(value); + + if (!Number.isInteger(limit) || limit <= 0) { + throw new ValidationError(); + } + + return Math.min(limit, STAFF_ATTENDANCE_MAX_LIMIT); +} + +function requiredDate(value) { + if (value === undefined) { + return null; + } + + if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/u.test(value)) { + throw new ValidationError(); + } + + return value; +} + +function applyVisibilityScope(where, currentUser) { + if (!canViewReports(currentUser)) { + where.userId = currentUser.id; + return; + } + + if (hasTenantWideAccess(currentUser)) { + return; + } + + const campusId = getCampusId(currentUser); + + if (campusId) { + where.campusId = campusId; + } +} + +function applyDateFilter(where, filter) { + const startDate = requiredDate(filter.startDate); + const endDate = requiredDate(filter.endDate); + + if (startDate) { + where.attendance_date = { + ...(where.attendance_date || {}), + [db.Sequelize.Op.gte]: startDate, + }; + } + + if (endDate) { + where.attendance_date = { + ...(where.attendance_date || {}), + [db.Sequelize.Op.lte]: endDate, + }; + } +} + +function applyStaffScope(where, currentUser) { + where.organizationId = getOrganizationId(currentUser); + + if (!hasTenantWideAccess(currentUser)) { + const campusId = getCampusId(currentUser); + + if (campusId) { + where.campusId = campusId; + } + } +} + +function toRecordDto(record) { + const plainRecord = typeof record.get === 'function' + ? record.get({ plain: true }) + : record; + + return { + id: plainRecord.id, + date: plainRecord.attendance_date, + status: plainRecord.status, + note: plainRecord.note, + user_name: plainRecord.user_name, + user_role: plainRecord.user_role, + organizationId: plainRecord.organizationId, + campusId: plainRecord.campusId, + userId: plainRecord.userId, + createdAt: plainRecord.createdAt, + updatedAt: plainRecord.updatedAt, + }; +} + +function countStatus(records, status) { + return records.filter((record) => record.status === status).length; +} + +module.exports = class StaffAttendanceService { + static async listRecords(filter, currentUser) { + assertAuthenticatedTenantUser(currentUser); + + const where = { + organizationId: getOrganizationId(currentUser), + }; + applyVisibilityScope(where, currentUser); + applyDateFilter(where, filter); + + const result = await db.staff_attendance_records.findAndCountAll({ + where, + limit: parseLimit(filter.limit), + order: [['attendance_date', 'desc'], ['user_name', 'asc']], + }); + + return { + rows: result.rows.map(toRecordDto), + count: result.count, + }; + } + + static async summary(filter, currentUser) { + assertAuthenticatedTenantUser(currentUser); + + const recordsPayload = await this.listRecords(filter, currentUser); + const staffWhere = {}; + applyStaffScope(staffWhere, currentUser); + staffWhere.status = 'active'; + + const staffCount = await db.staff.count({ where: staffWhere }); + const records = recordsPayload.rows; + const present = countStatus(records, STAFF_ATTENDANCE_STATUSES.PRESENT); + const late = countStatus(records, STAFF_ATTENDANCE_STATUSES.LATE); + const absent = countStatus(records, STAFF_ATTENDANCE_STATUSES.ABSENT); + + return { + staffCount, + recordsCount: recordsPayload.count, + present, + late, + absent, + }; + } +}; diff --git a/backend/src/services/user_progress.js b/backend/src/services/user_progress.js new file mode 100644 index 0000000..5d528d6 --- /dev/null +++ b/backend/src/services/user_progress.js @@ -0,0 +1,160 @@ +const db = require('../db/models'); +const ForbiddenError = require('./notifications/errors/forbidden'); +const ValidationError = require('./notifications/errors/validation'); + +function getOrganizationId(currentUser) { + return currentUser?.organizations?.id || currentUser?.organizationsId || null; +} + +function getCampusId(currentUser) { + if (Array.isArray(currentUser?.staff_user) && currentUser.staff_user[0]?.campusId) { + return currentUser.staff_user[0].campusId; + } + + return null; +} + +function assertAuthenticatedUser(currentUser) { + if (!currentUser?.id || !getOrganizationId(currentUser)) { + throw new ForbiddenError(); + } +} + +function assertValidProgressType(progressType) { + if (typeof progressType !== 'string' || progressType.trim().length === 0) { + throw new ValidationError(); + } +} + +function assertValidMutation(data) { + if (!data || typeof data !== 'object' || Array.isArray(data)) { + throw new ValidationError(); + } + + assertValidProgressType(data.progress_type); + + if (typeof data.item_id !== 'string' || data.item_id.trim().length === 0) { + throw new ValidationError(); + } +} + +function toDto(record) { + const plainRecord = typeof record.get === 'function' + ? record.get({ plain: true }) + : record; + + return { + id: plainRecord.id, + progress_type: plainRecord.progress_type, + item_id: plainRecord.item_id, + value: plainRecord.value, + score: plainRecord.score, + metadata: plainRecord.metadata, + organizationId: plainRecord.organizationId, + campusId: plainRecord.campusId, + userId: plainRecord.userId, + createdAt: plainRecord.createdAt, + updatedAt: plainRecord.updatedAt, + }; +} + +module.exports = class UserProgressService { + static async list(filter, currentUser) { + assertAuthenticatedUser(currentUser); + assertValidProgressType(filter.progress_type); + + const where = { + organizationId: getOrganizationId(currentUser), + userId: currentUser.id, + progress_type: filter.progress_type.trim(), + }; + + if (filter.item_id) { + where.item_id = filter.item_id; + } + + const result = await db.user_progress.findAndCountAll({ + where, + order: [['createdAt', 'desc']], + }); + + return { + rows: result.rows.map(toDto), + count: result.count, + }; + } + + static async upsert(data, currentUser) { + assertAuthenticatedUser(currentUser); + assertValidMutation(data); + + const organizationId = getOrganizationId(currentUser); + const progressType = data.progress_type.trim(); + const itemId = data.item_id.trim(); + const transaction = await db.sequelize.transaction(); + + try { + const existing = await db.user_progress.findOne({ + where: { + organizationId, + userId: currentUser.id, + progress_type: progressType, + item_id: itemId, + }, + transaction, + }); + + const payload = { + progress_type: progressType, + item_id: itemId, + value: typeof data.value === 'string' ? data.value : null, + score: Number.isInteger(data.score) ? data.score : null, + metadata: data.metadata || null, + organizationId, + campusId: getCampusId(currentUser), + userId: currentUser.id, + updatedById: currentUser.id, + }; + + if (existing) { + await existing.update(payload, { transaction }); + await transaction.commit(); + return toDto(existing); + } + + const created = await db.user_progress.create( + { + ...payload, + createdById: currentUser.id, + }, + { transaction }, + ); + + await transaction.commit(); + return toDto(created); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async removeByItem(filter, currentUser) { + assertAuthenticatedUser(currentUser); + assertValidProgressType(filter.progress_type); + + if (typeof filter.item_id !== 'string' || filter.item_id.trim().length === 0) { + throw new ValidationError(); + } + + const deletedCount = await db.user_progress.destroy({ + where: { + organizationId: getOrganizationId(currentUser), + userId: currentUser.id, + progress_type: filter.progress_type.trim(), + item_id: filter.item_id.trim(), + }, + }); + + return { deletedCount }; + } +}; diff --git a/backend/src/services/walkthrough_checkins.js b/backend/src/services/walkthrough_checkins.js new file mode 100644 index 0000000..9557fc3 --- /dev/null +++ b/backend/src/services/walkthrough_checkins.js @@ -0,0 +1,210 @@ +const db = require('../db/models'); +const ForbiddenError = require('./notifications/errors/forbidden'); +const ValidationError = require('./notifications/errors/validation'); +const { + WALKTHROUGH_MANAGER_ROLE_NAMES, + WALKTHROUGH_TENANT_WIDE_ROLE_NAMES, +} = require('../constants/walkthrough'); + +const REQUIRED_STRING_FIELDS = [ + 'teacher_name', + 'classroom', + 'director_name', + 'check_in_date', + 'check_in_time', +]; + +const RATING_FIELDS = [ + 'attitude_rating', + 'classroom_management_rating', + 'cleanliness_rating', + 'vibes_rating', + 'team_dynamics_rating', + 'emergency_exit_rating', + 'lesson_plan_rating', +]; + +function getOrganizationId(currentUser) { + return currentUser?.organizations?.id || currentUser?.organizationsId || null; +} + +function getCampusId(currentUser) { + if (Array.isArray(currentUser?.staff_user) && currentUser.staff_user[0]?.campusId) { + return currentUser.staff_user[0].campusId; + } + + return null; +} + +function assertCanManage(currentUser) { + const roleName = currentUser?.app_role?.name; + const hasGlobalAccess = currentUser?.app_role?.globalAccess === true; + + if (currentUser?.id && getOrganizationId(currentUser) && (hasGlobalAccess || WALKTHROUGH_MANAGER_ROLE_NAMES.includes(roleName))) { + return; + } + + throw new ForbiddenError(); +} + +function assertValidCheckin(data) { + if (!data || typeof data !== 'object' || Array.isArray(data)) { + throw new ValidationError(); + } + + const missingString = REQUIRED_STRING_FIELDS.some((field) => { + const value = data[field]; + return typeof value !== 'string' || value.trim().length === 0; + }); + + const missingRating = RATING_FIELDS.some((field) => !Number.isInteger(data[field])); + + if (missingString || missingRating) { + throw new ValidationError(); + } +} + +function nullableString(value) { + if (typeof value !== 'string' || value.trim().length === 0) { + return null; + } + + return value.trim(); +} + +function toDto(record) { + const plainRecord = typeof record.get === 'function' + ? record.get({ plain: true }) + : record; + + return { + id: plainRecord.id, + teacher_name: plainRecord.teacher_name, + classroom: plainRecord.classroom, + director_name: plainRecord.director_name, + check_in_date: plainRecord.check_in_date, + check_in_time: plainRecord.check_in_time, + attitude_rating: plainRecord.attitude_rating, + attitude_comment: plainRecord.attitude_comment, + classroom_management_rating: plainRecord.classroom_management_rating, + classroom_management_comment: plainRecord.classroom_management_comment, + cleanliness_rating: plainRecord.cleanliness_rating, + cleanliness_comment: plainRecord.cleanliness_comment, + vibes_rating: plainRecord.vibes_rating, + vibes_comment: plainRecord.vibes_comment, + team_dynamics_rating: plainRecord.team_dynamics_rating, + team_dynamics_comment: plainRecord.team_dynamics_comment, + emergency_exit_rating: plainRecord.emergency_exit_rating, + emergency_exit_comment: plainRecord.emergency_exit_comment, + lesson_plan_rating: plainRecord.lesson_plan_rating, + lesson_plan_comment: plainRecord.lesson_plan_comment, + overall_notes: plainRecord.overall_notes, + organizationId: plainRecord.organizationId, + campusId: plainRecord.campusId, + createdById: plainRecord.createdById, + updatedById: plainRecord.updatedById, + createdAt: plainRecord.createdAt, + updatedAt: plainRecord.updatedAt, + }; +} + +function applyCampusScope(where, currentUser) { + const roleName = currentUser?.app_role?.name; + const hasGlobalAccess = currentUser?.app_role?.globalAccess === true; + + if (hasGlobalAccess || WALKTHROUGH_TENANT_WIDE_ROLE_NAMES.includes(roleName)) { + return; + } + + const campusId = getCampusId(currentUser); + + if (campusId) { + where.campusId = campusId; + } +} + +module.exports = class WalkthroughCheckinsService { + static async list(filter, currentUser) { + assertCanManage(currentUser); + + const where = { + organizationId: getOrganizationId(currentUser), + }; + applyCampusScope(where, currentUser); + + if (filter.teacher_name) { + where.teacher_name = filter.teacher_name; + } + + const result = await db.walkthrough_checkins.findAndCountAll({ + where, + order: [['check_in_date', 'desc'], ['createdAt', 'desc']], + }); + + return { + rows: result.rows.map(toDto), + count: result.count, + }; + } + + static async create(data, currentUser) { + assertCanManage(currentUser); + assertValidCheckin(data); + + const transaction = await db.sequelize.transaction(); + + try { + const created = await db.walkthrough_checkins.create( + { + teacher_name: data.teacher_name.trim(), + classroom: data.classroom.trim(), + director_name: data.director_name.trim(), + check_in_date: data.check_in_date.trim(), + check_in_time: data.check_in_time.trim(), + attitude_rating: data.attitude_rating, + attitude_comment: nullableString(data.attitude_comment), + classroom_management_rating: data.classroom_management_rating, + classroom_management_comment: nullableString(data.classroom_management_comment), + cleanliness_rating: data.cleanliness_rating, + cleanliness_comment: nullableString(data.cleanliness_comment), + vibes_rating: data.vibes_rating, + vibes_comment: nullableString(data.vibes_comment), + team_dynamics_rating: data.team_dynamics_rating, + team_dynamics_comment: nullableString(data.team_dynamics_comment), + emergency_exit_rating: data.emergency_exit_rating, + emergency_exit_comment: nullableString(data.emergency_exit_comment), + lesson_plan_rating: data.lesson_plan_rating, + lesson_plan_comment: nullableString(data.lesson_plan_comment), + overall_notes: nullableString(data.overall_notes), + organizationId: getOrganizationId(currentUser), + campusId: getCampusId(currentUser), + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { transaction }, + ); + + await transaction.commit(); + return toDto(created); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async remove(id, currentUser) { + assertCanManage(currentUser); + + const where = { + id, + organizationId: getOrganizationId(currentUser), + }; + applyCampusScope(where, currentUser); + + const deletedCount = await db.walkthrough_checkins.destroy({ + where, + }); + + return { deletedCount }; + } +}; diff --git a/backend/yarn.lock b/backend/yarn.lock deleted file mode 100644 index 222a4f9..0000000 --- a/backend/yarn.lock +++ /dev/null @@ -1,4470 +0,0 @@ -# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. -# yarn lockfile v1 - - -"@apidevtools/json-schema-ref-parser@^9.0.6": - version "9.1.2" - resolved "https://registry.yarnpkg.com/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-9.1.2.tgz#8ff5386b365d4c9faa7c8b566ff16a46a577d9b8" - integrity sha512-r1w81DpR+KyRWd3f+rk6TNqMgedmAxZP5v5KWlXQWlgMUUtyEJch0DKEci1SorPMiSeM8XPl7MZ3miJ60JIpQg== - dependencies: - "@jsdevtools/ono" "^7.1.3" - "@types/json-schema" "^7.0.6" - call-me-maybe "^1.0.1" - js-yaml "^4.1.0" - -"@apidevtools/openapi-schemas@^2.0.4": - version "2.1.0" - resolved "https://registry.yarnpkg.com/@apidevtools/openapi-schemas/-/openapi-schemas-2.1.0.tgz#9fa08017fb59d80538812f03fc7cac5992caaa17" - integrity sha512-Zc1AlqrJlX3SlpupFGpiLi2EbteyP7fXmUOGup6/DnkRgjP9bgMM/ag+n91rsv0U1Gpz0H3VILA/o3bW7Ua6BQ== - -"@apidevtools/swagger-methods@^3.0.2": - version "3.0.2" - resolved "https://registry.yarnpkg.com/@apidevtools/swagger-methods/-/swagger-methods-3.0.2.tgz#b789a362e055b0340d04712eafe7027ddc1ac267" - integrity sha512-QAkD5kK2b1WfjDS/UQn/qQkbwF31uqRjPTrsCs5ZG9BQGAkjwvqGFjjPqAuzac/IYzpPtRzjCP1WrTuAIjMrXg== - -"@apidevtools/swagger-parser@10.0.3": - version "10.0.3" - resolved "https://registry.yarnpkg.com/@apidevtools/swagger-parser/-/swagger-parser-10.0.3.tgz#32057ae99487872c4dd96b314a1ab4b95d89eaf5" - integrity sha512-sNiLY51vZOmSPFZA5TF35KZ2HbgYklQnTSDnkghamzLb3EkNtcQnrBQEj5AOCxHpTtXpqMCRM1CrmV2rG6nw4g== - dependencies: - "@apidevtools/json-schema-ref-parser" "^9.0.6" - "@apidevtools/openapi-schemas" "^2.0.4" - "@apidevtools/swagger-methods" "^3.0.2" - "@jsdevtools/ono" "^7.1.3" - call-me-maybe "^1.0.1" - z-schema "^5.0.1" - -"@azure/abort-controller@^1.0.0": - version "1.1.0" - resolved "https://registry.yarnpkg.com/@azure/abort-controller/-/abort-controller-1.1.0.tgz#788ee78457a55af8a1ad342acb182383d2119249" - integrity sha512-TrRLIoSQVzfAJX9H1JeFjzAoDGcoK1IYX1UImfceTZpsyYfWr09Ss1aHW1y5TrrR3iq6RZLBwJ3E24uwPhwahw== - dependencies: - tslib "^2.2.0" - -"@azure/abort-controller@^2.0.0": - version "2.1.2" - resolved "https://registry.yarnpkg.com/@azure/abort-controller/-/abort-controller-2.1.2.tgz#42fe0ccab23841d9905812c58f1082d27784566d" - integrity sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA== - dependencies: - tslib "^2.6.2" - -"@azure/core-auth@^1.3.0", "@azure/core-auth@^1.4.0", "@azure/core-auth@^1.5.0": - version "1.7.2" - resolved "https://registry.yarnpkg.com/@azure/core-auth/-/core-auth-1.7.2.tgz#558b7cb7dd12b00beec07ae5df5907d74df1ebd9" - integrity sha512-Igm/S3fDYmnMq1uKS38Ae1/m37B3zigdlZw+kocwEhh5GjyKjPrXKO2J6rzpC1wAxrNil/jX9BJRqBshyjnF3g== - dependencies: - "@azure/abort-controller" "^2.0.0" - "@azure/core-util" "^1.1.0" - tslib "^2.6.2" - -"@azure/core-client@^1.3.0", "@azure/core-client@^1.5.0", "@azure/core-client@^1.9.2": - version "1.9.2" - resolved "https://registry.yarnpkg.com/@azure/core-client/-/core-client-1.9.2.tgz#6fc69cee2816883ab6c5cdd653ee4f2ff9774f74" - integrity sha512-kRdry/rav3fUKHl/aDLd/pDLcB+4pOFwPPTVEExuMyaI5r+JBbMWqRbCY1pn5BniDaU3lRxO9eaQ1AmSMehl/w== - dependencies: - "@azure/abort-controller" "^2.0.0" - "@azure/core-auth" "^1.4.0" - "@azure/core-rest-pipeline" "^1.9.1" - "@azure/core-tracing" "^1.0.0" - "@azure/core-util" "^1.6.1" - "@azure/logger" "^1.0.0" - tslib "^2.6.2" - -"@azure/core-http-compat@^2.0.1": - version "2.1.2" - resolved "https://registry.yarnpkg.com/@azure/core-http-compat/-/core-http-compat-2.1.2.tgz#d1585ada24ba750dc161d816169b33b35f762f0d" - integrity sha512-5MnV1yqzZwgNLLjlizsU3QqOeQChkIXw781Fwh1xdAqJR5AA32IUaq6xv1BICJvfbHoa+JYcaij2HFkhLbNTJQ== - dependencies: - "@azure/abort-controller" "^2.0.0" - "@azure/core-client" "^1.3.0" - "@azure/core-rest-pipeline" "^1.3.0" - -"@azure/core-lro@^2.2.0": - version "2.7.2" - resolved "https://registry.yarnpkg.com/@azure/core-lro/-/core-lro-2.7.2.tgz#787105027a20e45c77651a98b01a4d3b01b75a08" - integrity sha512-0YIpccoX8m/k00O7mDDMdJpbr6mf1yWo2dfmxt5A8XVZVVMz2SSKaEbMCeJRvgQ0IaSlqhjT47p4hVIRRy90xw== - dependencies: - "@azure/abort-controller" "^2.0.0" - "@azure/core-util" "^1.2.0" - "@azure/logger" "^1.0.0" - tslib "^2.6.2" - -"@azure/core-paging@^1.1.1": - version "1.6.2" - resolved "https://registry.yarnpkg.com/@azure/core-paging/-/core-paging-1.6.2.tgz#40d3860dc2df7f291d66350b2cfd9171526433e7" - integrity sha512-YKWi9YuCU04B55h25cnOYZHxXYtEvQEbKST5vqRga7hWY9ydd3FZHdeQF8pyh+acWZvppw13M/LMGx0LABUVMA== - dependencies: - tslib "^2.6.2" - -"@azure/core-rest-pipeline@^1.1.0", "@azure/core-rest-pipeline@^1.3.0", "@azure/core-rest-pipeline@^1.8.1", "@azure/core-rest-pipeline@^1.9.1": - version "1.16.2" - resolved "https://registry.yarnpkg.com/@azure/core-rest-pipeline/-/core-rest-pipeline-1.16.2.tgz#3f71b09e45a65926cc598478b4f1bcd0fe67bf4b" - integrity sha512-Hnhm/PG9/SQ07JJyLDv3l9Qr8V3xgAe1hFoBYzt6LaalMxfL/ZqFaZf/bz5VN3pMcleCPwl8ivlS2Fjxq/iC8Q== - dependencies: - "@azure/abort-controller" "^2.0.0" - "@azure/core-auth" "^1.4.0" - "@azure/core-tracing" "^1.0.1" - "@azure/core-util" "^1.9.0" - "@azure/logger" "^1.0.0" - http-proxy-agent "^7.0.0" - https-proxy-agent "^7.0.0" - tslib "^2.6.2" - -"@azure/core-tracing@^1.0.0", "@azure/core-tracing@^1.0.1": - version "1.1.2" - resolved "https://registry.yarnpkg.com/@azure/core-tracing/-/core-tracing-1.1.2.tgz#065dab4e093fb61899988a1cdbc827d9ad90b4ee" - integrity sha512-dawW9ifvWAWmUm9/h+/UQ2jrdvjCJ7VJEuCJ6XVNudzcOwm53BFZH4Q845vjfgoUAM8ZxokvVNxNxAITc502YA== - dependencies: - tslib "^2.6.2" - -"@azure/core-util@^1.0.0", "@azure/core-util@^1.1.0", "@azure/core-util@^1.2.0", "@azure/core-util@^1.3.0", "@azure/core-util@^1.6.1", "@azure/core-util@^1.9.0": - version "1.9.1" - resolved "https://registry.yarnpkg.com/@azure/core-util/-/core-util-1.9.1.tgz#05ea9505c5cdf29c55ccf99a648c66ddd678590b" - integrity sha512-OLsq0etbHO1MA7j6FouXFghuHrAFGk+5C1imcpQ2e+0oZhYF07WLA+NW2Vqs70R7d+zOAWiWM3tbE1sXcDN66g== - dependencies: - "@azure/abort-controller" "^2.0.0" - tslib "^2.6.2" - -"@azure/identity@^4.2.1": - version "4.4.0" - resolved "https://registry.yarnpkg.com/@azure/identity/-/identity-4.4.0.tgz#f2743e63d346000a70b0eed5a3b397dedd3984a7" - integrity sha512-oG6oFNMxUuoivYg/ElyZWVSZfw42JQyHbrp+lR7VJ1BYWsGzt34NwyDw3miPp1QI7Qm5+4KAd76wGsbHQmkpkg== - dependencies: - "@azure/abort-controller" "^1.0.0" - "@azure/core-auth" "^1.5.0" - "@azure/core-client" "^1.9.2" - "@azure/core-rest-pipeline" "^1.1.0" - "@azure/core-tracing" "^1.0.0" - "@azure/core-util" "^1.3.0" - "@azure/logger" "^1.0.0" - "@azure/msal-browser" "^3.14.0" - "@azure/msal-node" "^2.9.2" - events "^3.0.0" - jws "^4.0.0" - open "^8.0.0" - stoppable "^1.1.0" - tslib "^2.2.0" - -"@azure/keyvault-keys@^4.4.0": - version "4.8.0" - resolved "https://registry.yarnpkg.com/@azure/keyvault-keys/-/keyvault-keys-4.8.0.tgz#1513b3a187bb3a9a372b5980c593962fb793b2ad" - integrity sha512-jkuYxgkw0aaRfk40OQhFqDIupqblIOIlYESWB6DKCVDxQet1pyv86Tfk9M+5uFM0+mCs6+MUHU+Hxh3joiUn4Q== - dependencies: - "@azure/abort-controller" "^1.0.0" - "@azure/core-auth" "^1.3.0" - "@azure/core-client" "^1.5.0" - "@azure/core-http-compat" "^2.0.1" - "@azure/core-lro" "^2.2.0" - "@azure/core-paging" "^1.1.1" - "@azure/core-rest-pipeline" "^1.8.1" - "@azure/core-tracing" "^1.0.0" - "@azure/core-util" "^1.0.0" - "@azure/logger" "^1.0.0" - tslib "^2.2.0" - -"@azure/logger@^1.0.0": - version "1.1.3" - resolved "https://registry.yarnpkg.com/@azure/logger/-/logger-1.1.3.tgz#09a8fd4850b9112865756e92d5e8b728ee457345" - integrity sha512-J8/cIKNQB1Fc9fuYqBVnrppiUtW+5WWJPCj/tAokC5LdSTwkWWttN+jsRgw9BLYD7JDBx7PceiqOBxJJ1tQz3Q== - dependencies: - tslib "^2.6.2" - -"@azure/msal-browser@^3.14.0": - version "3.19.1" - resolved "https://registry.yarnpkg.com/@azure/msal-browser/-/msal-browser-3.19.1.tgz#c5e5a7996f95cadc11920bffa2bf6321e3a24555" - integrity sha512-pqYP2gK0GCEa4OxtOqlS+EdFQqhXV6ZuESgSTYWq2ABXyxBVVdd5KNuqgR5SU0OwI2V1YWdFVvLDe1487dyQ0g== - dependencies: - "@azure/msal-common" "14.13.1" - -"@azure/msal-common@14.13.1": - version "14.13.1" - resolved "https://registry.yarnpkg.com/@azure/msal-common/-/msal-common-14.13.1.tgz#e296cf8cc556082af9c35d803496424e8a95d8b7" - integrity sha512-iUp3BYrsRZ4X3EiaZ2fDjNFjmtYMv9rEQd6c1op6ULn0HWk4ACvDmosL6NaBgWOhl1BAblIbd9vmB5/ilF8d4A== - -"@azure/msal-node@^2.9.2": - version "2.11.1" - resolved "https://registry.yarnpkg.com/@azure/msal-node/-/msal-node-2.11.1.tgz#7fea67a1c6904301eb8853fae7df86c34306a9cc" - integrity sha512-8ECtug4RL+zsgh20VL8KYHjrRO3MJOeAKEPRXT2lwtiu5U3BdyIdBb50+QZthEkIi60K6pc/pdOx/k5Jp4sLng== - dependencies: - "@azure/msal-common" "14.13.1" - jsonwebtoken "^9.0.0" - uuid "^8.3.0" - -"@google-cloud/paginator@^3.0.7": - version "3.0.7" - resolved "https://registry.yarnpkg.com/@google-cloud/paginator/-/paginator-3.0.7.tgz#fb6f8e24ec841f99defaebf62c75c2e744dd419b" - integrity sha512-jJNutk0arIQhmpUUQJPJErsojqo834KcyB6X7a1mxuic8i1tKXxde8E69IZxNZawRIlZdIK2QY4WALvlK5MzYQ== - dependencies: - arrify "^2.0.0" - extend "^3.0.2" - -"@google-cloud/projectify@^2.0.0": - version "2.1.1" - resolved "https://registry.yarnpkg.com/@google-cloud/projectify/-/projectify-2.1.1.tgz#ae6af4fee02d78d044ae434699a630f8df0084ef" - integrity sha512-+rssMZHnlh0twl122gXY4/aCrk0G1acBqkHFfYddtsqpYXGxA29nj9V5V9SfC+GyOG00l650f6lG9KL+EpFEWQ== - -"@google-cloud/promisify@^2.0.0": - version "2.0.4" - resolved "https://registry.yarnpkg.com/@google-cloud/promisify/-/promisify-2.0.4.tgz#9d8705ecb2baa41b6b2673f3a8e9b7b7e1abc52a" - integrity sha512-j8yRSSqswWi1QqUGKVEKOG03Q7qOoZP6/h2zN2YO+F5h2+DHU0bSrHCK9Y7lo2DI9fBd8qGAw795sf+3Jva4yA== - -"@google-cloud/storage@^5.18.2": - version "5.20.5" - resolved "https://registry.yarnpkg.com/@google-cloud/storage/-/storage-5.20.5.tgz#1de71fc88d37934a886bc815722c134b162d335d" - integrity sha512-lOs/dCyveVF8TkVFnFSF7IGd0CJrTm91qiK6JLu+Z8qiT+7Ag0RyVhxZIWkhiACqwABo7kSHDm8FdH8p2wxSSw== - dependencies: - "@google-cloud/paginator" "^3.0.7" - "@google-cloud/projectify" "^2.0.0" - "@google-cloud/promisify" "^2.0.0" - abort-controller "^3.0.0" - arrify "^2.0.0" - async-retry "^1.3.3" - compressible "^2.0.12" - configstore "^5.0.0" - duplexify "^4.0.0" - ent "^2.2.0" - extend "^3.0.2" - gaxios "^4.0.0" - google-auth-library "^7.14.1" - hash-stream-validation "^0.2.2" - mime "^3.0.0" - mime-types "^2.0.8" - p-limit "^3.0.1" - pumpify "^2.0.0" - retry-request "^4.2.2" - stream-events "^1.0.4" - teeny-request "^7.1.3" - uuid "^8.0.0" - xdg-basedir "^4.0.0" - -"@isaacs/cliui@^8.0.2": - version "8.0.2" - resolved "https://registry.yarnpkg.com/@isaacs/cliui/-/cliui-8.0.2.tgz#b37667b7bc181c168782259bab42474fbf52b550" - integrity sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA== - dependencies: - string-width "^5.1.2" - string-width-cjs "npm:string-width@^4.2.0" - strip-ansi "^7.0.1" - strip-ansi-cjs "npm:strip-ansi@^6.0.1" - wrap-ansi "^8.1.0" - wrap-ansi-cjs "npm:wrap-ansi@^7.0.0" - -"@js-joda/core@^5.6.1": - version "5.6.3" - resolved "https://registry.yarnpkg.com/@js-joda/core/-/core-5.6.3.tgz#41ae1c07de1ebe0f6dde1abcbc9700a09b9c6056" - integrity sha512-T1rRxzdqkEXcou0ZprN1q9yDRlvzCPLqmlNt5IIsGBzoEVgLCCYrKEwc84+TvsXuAc95VAZwtWD2zVsKPY4bcA== - -"@jsdevtools/ono@^7.1.3": - version "7.1.3" - resolved "https://registry.yarnpkg.com/@jsdevtools/ono/-/ono-7.1.3.tgz#9df03bbd7c696a5c58885c34aa06da41c8543796" - integrity sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg== - -"@mapbox/node-pre-gyp@^1.0.11": - version "1.0.11" - resolved "https://registry.yarnpkg.com/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz#417db42b7f5323d79e93b34a6d7a2a12c0df43fa" - integrity sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ== - dependencies: - detect-libc "^2.0.0" - https-proxy-agent "^5.0.0" - make-dir "^3.1.0" - node-fetch "^2.6.7" - nopt "^5.0.0" - npmlog "^5.0.1" - rimraf "^3.0.2" - semver "^7.3.5" - tar "^6.1.11" - -"@one-ini/wasm@0.1.1": - version "0.1.1" - resolved "https://registry.yarnpkg.com/@one-ini/wasm/-/wasm-0.1.1.tgz#6013659736c9dbfccc96e8a9c2b3de317df39323" - integrity sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw== - -"@pkgjs/parseargs@^0.11.0": - version "0.11.0" - resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33" - integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg== - -"@sindresorhus/is@^0.14.0": - version "0.14.0" - resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-0.14.0.tgz#9fb3a3cf3132328151f353de4632e01e52102bea" - integrity sha512-9NET910DNaIPngYnLLPeg+Ogzqsi9uM4mSboU5y6p8S5DzMTVEsJZrawi+BoDNUVBa2DhJqQYUFvMDfgU062LQ== - -"@szmarczak/http-timer@^1.1.2": - version "1.1.2" - resolved "https://registry.yarnpkg.com/@szmarczak/http-timer/-/http-timer-1.1.2.tgz#b1665e2c461a2cd92f4c1bbf50d5454de0d4b421" - integrity sha512-XIB2XbzHTN6ieIjfIMV9hlVcfPU26s2vafYWQcZHWXHOxiaRZYEDKEwdl129Zyg50+foYV2jCgtrqSA6qNuNSA== - dependencies: - defer-to-connect "^1.0.1" - -"@tootallnate/once@2": - version "2.0.0" - resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-2.0.0.tgz#f544a148d3ab35801c1f633a7441fd87c2e484bf" - integrity sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A== - -"@types/debug@^4.1.8": - version "4.1.12" - resolved "https://registry.yarnpkg.com/@types/debug/-/debug-4.1.12.tgz#a155f21690871953410df4b6b6f53187f0500917" - integrity sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ== - dependencies: - "@types/ms" "*" - -"@types/json-schema@^7.0.6": - version "7.0.15" - resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841" - integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA== - -"@types/ms@*": - version "0.7.34" - resolved "https://registry.yarnpkg.com/@types/ms/-/ms-0.7.34.tgz#10964ba0dee6ac4cd462e2795b6bebd407303433" - integrity sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g== - -"@types/node@*", "@types/node@>=18": - version "20.14.11" - resolved "https://registry.yarnpkg.com/@types/node/-/node-20.14.11.tgz#09b300423343460455043ddd4d0ded6ac579b74b" - integrity sha512-kprQpL8MMeszbz6ojB5/tU8PLN4kesnN8Gjzw349rDlNgsSzg90lAVj3llK99Dh7JON+t9AuscPPFW6mPbTnSA== - dependencies: - undici-types "~5.26.4" - -"@types/readable-stream@^4.0.0": - version "4.0.15" - resolved "https://registry.yarnpkg.com/@types/readable-stream/-/readable-stream-4.0.15.tgz#e6ec26fe5b02f578c60baf1fa9452e90957d2bfb" - integrity sha512-oAZ3kw+kJFkEqyh7xORZOku1YAKvsFTogRY8kVl4vHpEKiDkfnSA/My8haRE7fvmix5Zyy+1pwzOi7yycGLBJw== - dependencies: - "@types/node" "*" - safe-buffer "~5.1.1" - -"@types/validator@^13.7.17": - version "13.12.0" - resolved "https://registry.yarnpkg.com/@types/validator/-/validator-13.12.0.tgz#1fe4c3ae9de5cf5193ce64717c99ef2fa7d8756f" - integrity sha512-nH45Lk7oPIJ1RVOF6JgFI6Dy0QpHEzq4QecZhvguxYPDwT8c93prCMqAtiIttm39voZ+DDR+qkNnMpJmMBRqag== - -abbrev@1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8" - integrity sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q== - -abbrev@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-2.0.0.tgz#cf59829b8b4f03f89dda2771cb7f3653828c89bf" - integrity sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ== - -abort-controller@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/abort-controller/-/abort-controller-3.0.0.tgz#eaf54d53b62bae4138e809ca225c8439a6efb392" - integrity sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg== - dependencies: - event-target-shim "^5.0.0" - -accepts@^1.3.7, accepts@~1.3.8: - version "1.3.8" - resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.8.tgz#0bf0be125b67014adcb0b0921e62db7bffe16b2e" - integrity sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw== - dependencies: - mime-types "~2.1.34" - negotiator "0.6.3" - -agent-base@6: - version "6.0.2" - resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-6.0.2.tgz#49fff58577cfee3f37176feab4c22e00f86d7f77" - integrity sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ== - dependencies: - debug "4" - -agent-base@^7.0.2, agent-base@^7.1.0: - version "7.1.1" - resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-7.1.1.tgz#bdbded7dfb096b751a2a087eeeb9664725b2e317" - integrity sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA== - dependencies: - debug "^4.3.4" - -ansi-align@^3.0.0: - version "3.0.1" - resolved "https://registry.yarnpkg.com/ansi-align/-/ansi-align-3.0.1.tgz#0cdf12e111ace773a86e9a1fad1225c43cb19a59" - integrity sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w== - dependencies: - string-width "^4.1.0" - -ansi-colors@4.1.1: - version "4.1.1" - resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-4.1.1.tgz#cbb9ae256bf750af1eab344f229aa27fe94ba348" - integrity sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA== - -ansi-regex@^3.0.0: - version "3.0.1" - resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-3.0.1.tgz#123d6479e92ad45ad897d4054e3c7ca7db4944e1" - integrity sha512-+O9Jct8wf++lXxxFc4hc8LsjaSq0HFzzL7cVsw8pRDIPdjKD2mT4ytDZlLuSBZ4cLKZFXIrMGO7DbQCtMJJMKw== - -ansi-regex@^4.1.0: - version "4.1.1" - resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-4.1.1.tgz#164daac87ab2d6f6db3a29875e2d1766582dabed" - integrity sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g== - -ansi-regex@^5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" - integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== - -ansi-regex@^6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-6.0.1.tgz#3183e38fae9a65d7cb5e53945cd5897d0260a06a" - integrity sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA== - -ansi-styles@^3.2.0: - version "3.2.1" - resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d" - integrity sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA== - dependencies: - color-convert "^1.9.0" - -ansi-styles@^4.0.0, ansi-styles@^4.1.0: - version "4.3.0" - resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937" - integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg== - dependencies: - color-convert "^2.0.1" - -ansi-styles@^6.1.0: - version "6.2.1" - resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-6.2.1.tgz#0e62320cf99c21afff3b3012192546aacbfb05c5" - integrity sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug== - -anymatch@~3.1.1, anymatch@~3.1.2: - version "3.1.3" - resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.3.tgz#790c58b19ba1720a84205b57c618d5ad8524973e" - integrity sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw== - dependencies: - normalize-path "^3.0.0" - picomatch "^2.0.4" - -append-field@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/append-field/-/append-field-1.0.0.tgz#1e3440e915f0b1203d23748e78edd7b9b5b43e56" - integrity sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw== - -"aproba@^1.0.3 || ^2.0.0": - version "2.0.0" - resolved "https://registry.yarnpkg.com/aproba/-/aproba-2.0.0.tgz#52520b8ae5b569215b354efc0caa3fe1e45a8adc" - integrity sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ== - -are-we-there-yet@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz#372e0e7bd279d8e94c653aaa1f67200884bf3e1c" - integrity sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw== - dependencies: - delegates "^1.0.0" - readable-stream "^3.6.0" - -argparse@^1.0.7: - version "1.0.10" - resolved "https://registry.yarnpkg.com/argparse/-/argparse-1.0.10.tgz#bcd6791ea5ae09725e17e5ad988134cd40b3d911" - integrity sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg== - dependencies: - sprintf-js "~1.0.2" - -argparse@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38" - integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== - -array-buffer-byte-length@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/array-buffer-byte-length/-/array-buffer-byte-length-1.0.1.tgz#1e5583ec16763540a27ae52eed99ff899223568f" - integrity sha512-ahC5W1xgou+KTXix4sAO8Ki12Q+jf4i0+tmk3sC+zgcynshkHxzpXdImBehiUYKKKDwvfFiJl1tZt6ewscS1Mg== - dependencies: - call-bind "^1.0.5" - is-array-buffer "^3.0.4" - -array-flatten@1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/array-flatten/-/array-flatten-1.1.1.tgz#9a5f699051b1e7073328f2a008968b64ea2955d2" - integrity sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg== - -array.prototype.map@^1.0.1: - version "1.0.7" - resolved "https://registry.yarnpkg.com/array.prototype.map/-/array.prototype.map-1.0.7.tgz#82fa4d6027272d1fca28a63bbda424d0185d78a7" - integrity sha512-XpcFfLoBEAhezrrNw1V+yLXkE7M6uR7xJEsxbG6c/V9v043qurwVJB9r9UTnoSioFDoz1i1VOydpWGmJpfVZbg== - dependencies: - call-bind "^1.0.7" - define-properties "^1.2.1" - es-abstract "^1.23.2" - es-array-method-boxes-properly "^1.0.0" - es-object-atoms "^1.0.0" - is-string "^1.0.7" - -arraybuffer.prototype.slice@^1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.3.tgz#097972f4255e41bc3425e37dc3f6421cf9aefde6" - integrity sha512-bMxMKAjg13EBSVscxTaYA4mRc5t1UAXa2kXiGTNfZ079HIWXEkKmkgFrh/nJqamaLSrXO5H4WFFkPEaLJWbs3A== - dependencies: - array-buffer-byte-length "^1.0.1" - call-bind "^1.0.5" - define-properties "^1.2.1" - es-abstract "^1.22.3" - es-errors "^1.2.1" - get-intrinsic "^1.2.3" - is-array-buffer "^3.0.4" - is-shared-array-buffer "^1.0.2" - -arrify@^2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/arrify/-/arrify-2.0.1.tgz#c9655e9331e0abcd588d2a7cad7e9956f66701fa" - integrity sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug== - -async-retry@^1.3.3: - version "1.3.3" - resolved "https://registry.yarnpkg.com/async-retry/-/async-retry-1.3.3.tgz#0e7f36c04d8478e7a58bdbed80cedf977785f280" - integrity sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw== - dependencies: - retry "0.13.1" - -asynckit@^0.4.0: - version "0.4.0" - resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" - integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q== - -at-least-node@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/at-least-node/-/at-least-node-1.0.0.tgz#602cd4b46e844ad4effc92a8011a3c46e0238dc2" - integrity sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg== - -available-typed-arrays@^1.0.7: - version "1.0.7" - resolved "https://registry.yarnpkg.com/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz#a5cc375d6a03c2efc87a553f3e0b1522def14846" - integrity sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ== - dependencies: - possible-typed-array-names "^1.0.0" - -axios@^1.6.7: - version "1.7.2" - resolved "https://registry.yarnpkg.com/axios/-/axios-1.7.2.tgz#b625db8a7051fbea61c35a3cbb3a1daa7b9c7621" - integrity sha512-2A8QhOMrbomlDuiLeK9XibIBzuHeRcqqNOHp0Cyp5EoJ1IFDh+XZH3A6BkXtv0K4gFGCI0Y4BM7B1wOEi0Rmgw== - dependencies: - follow-redirects "^1.15.6" - form-data "^4.0.0" - proxy-from-env "^1.1.0" - -balanced-match@^1.0.0: - version "1.0.2" - resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" - integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== - -base64-js@^1.3.0, base64-js@^1.3.1: - version "1.5.1" - resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" - integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== - -base64url@3.x.x: - version "3.0.1" - resolved "https://registry.yarnpkg.com/base64url/-/base64url-3.0.1.tgz#6399d572e2bc3f90a9a8b22d5dbb0a32d33f788d" - integrity sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A== - -bcrypt@5.1.1: - version "5.1.1" - resolved "https://registry.yarnpkg.com/bcrypt/-/bcrypt-5.1.1.tgz#0f732c6dcb4e12e5b70a25e326a72965879ba6e2" - integrity sha512-AGBHOG5hPYZ5Xl9KXzU5iKq9516yEmvCKDg3ecP5kX2aB6UqTeXZxk2ELnDgDm6BQSMlLt9rDB4LoSMx0rYwww== - dependencies: - "@mapbox/node-pre-gyp" "^1.0.11" - node-addon-api "^5.0.0" - -bignumber.js@^9.0.0: - version "9.1.2" - resolved "https://registry.yarnpkg.com/bignumber.js/-/bignumber.js-9.1.2.tgz#b7c4242259c008903b13707983b5f4bbd31eda0c" - integrity sha512-2/mKyZH9K85bzOEfhXDBFZTGd1CTs+5IHpeFQo9luiBG7hghdC851Pj2WAhb6E3R6b9tZj/XKhbg4fum+Kepug== - -binary-extensions@^2.0.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.3.0.tgz#f6e14a97858d327252200242d4ccfe522c445522" - integrity sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw== - -bl@^6.0.11: - version "6.0.14" - resolved "https://registry.yarnpkg.com/bl/-/bl-6.0.14.tgz#b9ae9862118a3d2ebec999c5318466012314f96c" - integrity sha512-TJfbvGdL7KFGxTsEbsED7avqpFdY56q9IW0/aiytyheJzxST/+Io6cx/4Qx0K2/u0BPRDs65mjaQzYvMZeNocQ== - dependencies: - "@types/readable-stream" "^4.0.0" - buffer "^6.0.3" - inherits "^2.0.4" - readable-stream "^4.2.0" - -bluebird@^3.7.2: - version "3.7.2" - resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f" - integrity sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg== - -body-parser@1.20.1: - version "1.20.1" - resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.1.tgz#b1812a8912c195cd371a3ee5e66faa2338a5c668" - integrity sha512-jWi7abTbYwajOytWCQc37VulmWiRae5RyTpaCyDcS5/lMdtwSz5lOpDE67srw/HYe35f1z3fDQw+3txg7gNtWw== - dependencies: - bytes "3.1.2" - content-type "~1.0.4" - debug "2.6.9" - depd "2.0.0" - destroy "1.2.0" - http-errors "2.0.0" - iconv-lite "0.4.24" - on-finished "2.4.1" - qs "6.11.0" - raw-body "2.5.1" - type-is "~1.6.18" - unpipe "1.0.0" - -boxen@^4.2.0: - version "4.2.0" - resolved "https://registry.yarnpkg.com/boxen/-/boxen-4.2.0.tgz#e411b62357d6d6d36587c8ac3d5d974daa070e64" - integrity sha512-eB4uT9RGzg2odpER62bBwSLvUeGC+WbRjjyyFhGsKnc8wp/m0+hQsMUvUe3H2V0D5vw0nBdO1hCJoZo5mKeuIQ== - dependencies: - ansi-align "^3.0.0" - camelcase "^5.3.1" - chalk "^3.0.0" - cli-boxes "^2.2.0" - string-width "^4.1.0" - term-size "^2.1.0" - type-fest "^0.8.1" - widest-line "^3.1.0" - -brace-expansion@^1.1.7: - version "1.1.11" - resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" - integrity sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA== - dependencies: - balanced-match "^1.0.0" - concat-map "0.0.1" - -brace-expansion@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.0.1.tgz#1edc459e0f0c548486ecf9fc99f2221364b9a0ae" - integrity sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA== - dependencies: - balanced-match "^1.0.0" - -braces@~3.0.2: - version "3.0.3" - resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.3.tgz#490332f40919452272d55a8480adc0c441358789" - integrity sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA== - dependencies: - fill-range "^7.1.1" - -browser-stdout@1.3.1: - version "1.3.1" - resolved "https://registry.yarnpkg.com/browser-stdout/-/browser-stdout-1.3.1.tgz#baa559ee14ced73452229bad7326467c61fabd60" - integrity sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw== - -buffer-equal-constant-time@1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz#f8e71132f7ffe6e01a5c9697a4c6f3e48d5cc819" - integrity sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA== - -buffer-from@^1.0.0: - version "1.1.2" - resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5" - integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ== - -buffer-writer@2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/buffer-writer/-/buffer-writer-2.0.0.tgz#ce7eb81a38f7829db09c873f2fbb792c0c98ec04" - integrity sha512-a7ZpuTZU1TRtnwyCNW3I5dc0wWNC3VR9S++Ewyk2HHZdrO3CQJqSpd+95Us590V6AL7JqUAH2IwZ/398PmNFgw== - -buffer@^6.0.3: - version "6.0.3" - resolved "https://registry.yarnpkg.com/buffer/-/buffer-6.0.3.tgz#2ace578459cc8fbe2a70aaa8f52ee63b6a74c6c6" - integrity sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA== - dependencies: - base64-js "^1.3.1" - ieee754 "^1.2.1" - -busboy@^0.2.11: - version "0.2.14" - resolved "https://registry.yarnpkg.com/busboy/-/busboy-0.2.14.tgz#6c2a622efcf47c57bbbe1e2a9c37ad36c7925453" - integrity sha512-InWFDomvlkEj+xWLBfU3AvnbVYqeTWmQopiW0tWWEy5yehYm2YkGEc59sUmw/4ty5Zj/b0WHGs1LgecuBSBGrg== - dependencies: - dicer "0.2.5" - readable-stream "1.1.x" - -bytes@3.1.2: - version "3.1.2" - resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5" - integrity sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg== - -cacheable-request@^6.0.0: - version "6.1.0" - resolved "https://registry.yarnpkg.com/cacheable-request/-/cacheable-request-6.1.0.tgz#20ffb8bd162ba4be11e9567d823db651052ca912" - integrity sha512-Oj3cAGPCqOZX7Rz64Uny2GYAZNliQSqfbePrgAQ1wKAihYmCUnraBtJtKcGR4xz7wF+LoJC+ssFZvv5BgF9Igg== - dependencies: - clone-response "^1.0.2" - get-stream "^5.1.0" - http-cache-semantics "^4.0.0" - keyv "^3.0.0" - lowercase-keys "^2.0.0" - normalize-url "^4.1.0" - responselike "^1.0.2" - -call-bind@^1.0.2, call-bind@^1.0.5, call-bind@^1.0.6, call-bind@^1.0.7: - version "1.0.7" - resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.7.tgz#06016599c40c56498c18769d2730be242b6fa3b9" - integrity sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w== - dependencies: - es-define-property "^1.0.0" - es-errors "^1.3.0" - function-bind "^1.1.2" - get-intrinsic "^1.2.4" - set-function-length "^1.2.1" - -call-me-maybe@^1.0.1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/call-me-maybe/-/call-me-maybe-1.0.2.tgz#03f964f19522ba643b1b0693acb9152fe2074baa" - integrity sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ== - -camelcase@^5.0.0, camelcase@^5.3.1: - version "5.3.1" - resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.3.1.tgz#e3c9b31569e106811df242f715725a1f4c494320" - integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg== - -chalk@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/chalk/-/chalk-3.0.0.tgz#3f73c2bf526591f574cc492c51e2456349f844e4" - integrity sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg== - dependencies: - ansi-styles "^4.1.0" - supports-color "^7.1.0" - -chalk@^4.0.0: - version "4.1.2" - resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01" - integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA== - dependencies: - ansi-styles "^4.1.0" - supports-color "^7.1.0" - -chokidar@3.4.2: - version "3.4.2" - resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.4.2.tgz#38dc8e658dec3809741eb3ef7bb0a47fe424232d" - integrity sha512-IZHaDeBeI+sZJRX7lGcXsdzgvZqKv6sECqsbErJA4mHWfpRrD8B97kSFN4cQz6nGBGiuFia1MKR4d6c1o8Cv7A== - dependencies: - anymatch "~3.1.1" - braces "~3.0.2" - glob-parent "~5.1.0" - is-binary-path "~2.1.0" - is-glob "~4.0.1" - normalize-path "~3.0.0" - readdirp "~3.4.0" - optionalDependencies: - fsevents "~2.1.2" - -chokidar@^3.2.2: - version "3.6.0" - resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.6.0.tgz#197c6cc669ef2a8dc5e7b4d97ee4e092c3eb0d5b" - integrity sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw== - dependencies: - anymatch "~3.1.2" - braces "~3.0.2" - glob-parent "~5.1.2" - is-binary-path "~2.1.0" - is-glob "~4.0.1" - normalize-path "~3.0.0" - readdirp "~3.6.0" - optionalDependencies: - fsevents "~2.3.2" - -chownr@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/chownr/-/chownr-2.0.0.tgz#15bfbe53d2eab4cf70f18a8cd68ebe5b3cb1dece" - integrity sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ== - -ci-info@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-2.0.0.tgz#67a9e964be31a51e15e5010d58e6f12834002f46" - integrity sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ== - -cli-boxes@^2.2.0: - version "2.2.1" - resolved "https://registry.yarnpkg.com/cli-boxes/-/cli-boxes-2.2.1.tgz#ddd5035d25094fce220e9cab40a45840a440318f" - integrity sha512-y4coMcylgSCdVinjiDBuR8PCC2bLjyGTwEmPb9NHR/QaNU6EUOXcTY/s6VjGMD6ENSEaeQYHCY0GNGS5jfMwPw== - -cli-color@^2.0.3: - version "2.0.4" - resolved "https://registry.yarnpkg.com/cli-color/-/cli-color-2.0.4.tgz#d658080290968816b322248b7306fad2346fb2c8" - integrity sha512-zlnpg0jNcibNrO7GG9IeHH7maWFeCz+Ja1wx/7tZNU5ASSSSZ+/qZciM0/LHCYxSdqv5h2sdbQ/PXYdOuetXvA== - dependencies: - d "^1.0.1" - es5-ext "^0.10.64" - es6-iterator "^2.0.3" - memoizee "^0.4.15" - timers-ext "^0.1.7" - -cliui@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/cliui/-/cliui-5.0.0.tgz#deefcfdb2e800784aa34f46fa08e06851c7bbbc5" - integrity sha512-PYeGSEmmHM6zvoef2w8TPzlrnNpXIjTipYK780YswmIP9vjxmd6Y2a3CB2Ks6/AU8NHjZugXvo8w3oWM2qnwXA== - dependencies: - string-width "^3.1.0" - strip-ansi "^5.2.0" - wrap-ansi "^5.1.0" - -cliui@^7.0.2: - version "7.0.4" - resolved "https://registry.yarnpkg.com/cliui/-/cliui-7.0.4.tgz#a0265ee655476fc807aea9df3df8df7783808b4f" - integrity sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ== - dependencies: - string-width "^4.2.0" - strip-ansi "^6.0.0" - wrap-ansi "^7.0.0" - -clone-response@^1.0.2: - version "1.0.3" - resolved "https://registry.yarnpkg.com/clone-response/-/clone-response-1.0.3.tgz#af2032aa47816399cf5f0a1d0db902f517abb8c3" - integrity sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA== - dependencies: - mimic-response "^1.0.0" - -color-convert@^1.9.0: - version "1.9.3" - resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8" - integrity sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg== - dependencies: - color-name "1.1.3" - -color-convert@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3" - integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ== - dependencies: - color-name "~1.1.4" - -color-name@1.1.3: - version "1.1.3" - resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25" - integrity sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw== - -color-name@~1.1.4: - version "1.1.4" - resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" - integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== - -color-support@^1.1.2: - version "1.1.3" - resolved "https://registry.yarnpkg.com/color-support/-/color-support-1.1.3.tgz#93834379a1cc9a0c61f82f52f0d04322251bd5a2" - integrity sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg== - -combined-stream@^1.0.8: - version "1.0.8" - resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" - integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg== - dependencies: - delayed-stream "~1.0.0" - -commander@6.2.0: - version "6.2.0" - resolved "https://registry.yarnpkg.com/commander/-/commander-6.2.0.tgz#b990bfb8ac030aedc6d11bc04d1488ffef56db75" - integrity sha512-zP4jEKbe8SHzKJYQmq8Y9gYjtO/POJLgIdKgV7B9qNmABVFVc+ctqSX6iXh4mCpJfRBOabiZ2YKPg8ciDw6C+Q== - -commander@^10.0.0: - version "10.0.1" - resolved "https://registry.yarnpkg.com/commander/-/commander-10.0.1.tgz#881ee46b4f77d1c1dccc5823433aa39b022cbe06" - integrity sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug== - -commander@^6.1.0: - version "6.2.1" - resolved "https://registry.yarnpkg.com/commander/-/commander-6.2.1.tgz#0792eb682dfbc325999bb2b84fddddba110ac73c" - integrity sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA== - -compressible@^2.0.12: - version "2.0.18" - resolved "https://registry.yarnpkg.com/compressible/-/compressible-2.0.18.tgz#af53cca6b070d4c3c0750fbd77286a6d7cc46fba" - integrity sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg== - dependencies: - mime-db ">= 1.43.0 < 2" - -concat-map@0.0.1: - version "0.0.1" - resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" - integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg== - -concat-stream@^1.5.2: - version "1.6.2" - resolved "https://registry.yarnpkg.com/concat-stream/-/concat-stream-1.6.2.tgz#904bdf194cd3122fc675c77fc4ac3d4ff0fd1a34" - integrity sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw== - dependencies: - buffer-from "^1.0.0" - inherits "^2.0.3" - readable-stream "^2.2.2" - typedarray "^0.0.6" - -config-chain@^1.1.13: - version "1.1.13" - resolved "https://registry.yarnpkg.com/config-chain/-/config-chain-1.1.13.tgz#fad0795aa6a6cdaff9ed1b68e9dff94372c232f4" - integrity sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ== - dependencies: - ini "^1.3.4" - proto-list "~1.2.1" - -configstore@^5.0.0, configstore@^5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/configstore/-/configstore-5.0.1.tgz#d365021b5df4b98cdd187d6a3b0e3f6a7cc5ed96" - integrity sha512-aMKprgk5YhBNyH25hj8wGt2+D52Sw1DRRIzqBwLp2Ya9mFmY8KPvvtvmna8SxVR9JMZ4kzMD68N22vlaRpkeFA== - dependencies: - dot-prop "^5.2.0" - graceful-fs "^4.1.2" - make-dir "^3.0.0" - unique-string "^2.0.0" - write-file-atomic "^3.0.0" - xdg-basedir "^4.0.0" - -console-control-strings@^1.0.0, console-control-strings@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/console-control-strings/-/console-control-strings-1.1.0.tgz#3d7cf4464db6446ea644bf4b39507f9851008e8e" - integrity sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ== - -content-disposition@0.5.4: - version "0.5.4" - resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.4.tgz#8b82b4efac82512a02bb0b1dcec9d2c5e8eb5bfe" - integrity sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ== - dependencies: - safe-buffer "5.2.1" - -content-type@~1.0.4: - version "1.0.5" - resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.5.tgz#8b773162656d1d1086784c8f23a54ce6d73d7918" - integrity sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA== - -cookie-signature@1.0.6: - version "1.0.6" - resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c" - integrity sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ== - -cookie@0.5.0: - version "0.5.0" - resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.5.0.tgz#d1f5d71adec6558c58f389987c366aa47e994f8b" - integrity sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw== - -core-util-is@~1.0.0: - version "1.0.3" - resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.3.tgz#a6042d3634c2b27e9328f837b965fac83808db85" - integrity sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ== - -cors@2.8.5: - version "2.8.5" - resolved "https://registry.yarnpkg.com/cors/-/cors-2.8.5.tgz#eac11da51592dd86b9f06f6e7ac293b3df875d29" - integrity sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g== - dependencies: - object-assign "^4" - vary "^1" - -cross-env@7.0.3: - version "7.0.3" - resolved "https://registry.yarnpkg.com/cross-env/-/cross-env-7.0.3.tgz#865264b29677dc015ba8418918965dd232fc54cf" - integrity sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw== - dependencies: - cross-spawn "^7.0.1" - -cross-spawn@^7.0.0, cross-spawn@^7.0.1: - version "7.0.3" - resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" - integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w== - dependencies: - path-key "^3.1.0" - shebang-command "^2.0.0" - which "^2.0.1" - -crypto-random-string@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/crypto-random-string/-/crypto-random-string-2.0.0.tgz#ef2a7a966ec11083388369baa02ebead229b30d5" - integrity sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA== - -csv-parser@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/csv-parser/-/csv-parser-3.0.0.tgz#b88a6256d79e090a97a1b56451f9327b01d710e7" - integrity sha512-s6OYSXAK3IdKqYO33y09jhypG/bSDHPuyCme/IdEHfWpLf/jKcpitVFyOC6UemgGk8v7Q5u2XE0vvwmanxhGlQ== - dependencies: - minimist "^1.2.0" - -d@1, d@^1.0.1, d@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/d/-/d-1.0.2.tgz#2aefd554b81981e7dccf72d6842ae725cb17e5de" - integrity sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw== - dependencies: - es5-ext "^0.10.64" - type "^2.7.2" - -data-view-buffer@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/data-view-buffer/-/data-view-buffer-1.0.1.tgz#8ea6326efec17a2e42620696e671d7d5a8bc66b2" - integrity sha512-0lht7OugA5x3iJLOWFhWK/5ehONdprk0ISXqVFn/NFrDu+cuc8iADFrGQz5BnRK7LLU3JmkbXSxaqX+/mXYtUA== - dependencies: - call-bind "^1.0.6" - es-errors "^1.3.0" - is-data-view "^1.0.1" - -data-view-byte-length@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/data-view-byte-length/-/data-view-byte-length-1.0.1.tgz#90721ca95ff280677eb793749fce1011347669e2" - integrity sha512-4J7wRJD3ABAzr8wP+OcIcqq2dlUKp4DVflx++hs5h5ZKydWMI6/D/fAot+yh6g2tHh8fLFTvNOaVN357NvSrOQ== - dependencies: - call-bind "^1.0.7" - es-errors "^1.3.0" - is-data-view "^1.0.1" - -data-view-byte-offset@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/data-view-byte-offset/-/data-view-byte-offset-1.0.0.tgz#5e0bbfb4828ed2d1b9b400cd8a7d119bca0ff18a" - integrity sha512-t/Ygsytq+R995EJ5PZlD4Cu56sWa8InXySaViRzw9apusqsOO2bQP+SbYzAhR0pFKoB+43lYy8rWban9JSuXnA== - dependencies: - call-bind "^1.0.6" - es-errors "^1.3.0" - is-data-view "^1.0.1" - -debug@2.6.9: - version "2.6.9" - resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" - integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA== - dependencies: - ms "2.0.0" - -debug@4, debug@^4.1.1, debug@^4.3.4: - version "4.3.5" - resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.5.tgz#e83444eceb9fedd4a1da56d671ae2446a01a6e1e" - integrity sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg== - dependencies: - ms "2.1.2" - -debug@4.1.1: - version "4.1.1" - resolved "https://registry.yarnpkg.com/debug/-/debug-4.1.1.tgz#3b72260255109c6b589cee050f1d516139664791" - integrity sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw== - dependencies: - ms "^2.1.1" - -debug@^3.2.6: - version "3.2.7" - resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.7.tgz#72580b7e9145fb39b6676f9c5e5fb100b934179a" - integrity sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ== - dependencies: - ms "^2.1.1" - -decamelize@^1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290" - integrity sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA== - -decompress-response@^3.3.0: - version "3.3.0" - resolved "https://registry.yarnpkg.com/decompress-response/-/decompress-response-3.3.0.tgz#80a4dd323748384bfa248083622aedec982adff3" - integrity sha512-BzRPQuY1ip+qDonAOz42gRm/pg9F768C+npV/4JOsxRC2sq+Rlk+Q4ZCAsOhnIaMrgarILY+RMUIvMmmX1qAEA== - dependencies: - mimic-response "^1.0.0" - -deep-extend@^0.6.0: - version "0.6.0" - resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.6.0.tgz#c4fa7c95404a17a9c3e8ca7e1537312b736330ac" - integrity sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA== - -defer-to-connect@^1.0.1: - version "1.1.3" - resolved "https://registry.yarnpkg.com/defer-to-connect/-/defer-to-connect-1.1.3.tgz#331ae050c08dcf789f8c83a7b81f0ed94f4ac591" - integrity sha512-0ISdNousHvZT2EiFlZeZAHBUvSxmKswVCEf8hW7KWgG4a8MVEu/3Vb6uWYozkjylyCxe0JBIiRB1jV45S70WVQ== - -define-data-property@^1.0.1, define-data-property@^1.1.4: - version "1.1.4" - resolved "https://registry.yarnpkg.com/define-data-property/-/define-data-property-1.1.4.tgz#894dc141bb7d3060ae4366f6a0107e68fbe48c5e" - integrity sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A== - dependencies: - es-define-property "^1.0.0" - es-errors "^1.3.0" - gopd "^1.0.1" - -define-lazy-prop@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz#3f7ae421129bcaaac9bc74905c98a0009ec9ee7f" - integrity sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og== - -define-properties@^1.1.2, define-properties@^1.1.3, define-properties@^1.2.0, define-properties@^1.2.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/define-properties/-/define-properties-1.2.1.tgz#10781cc616eb951a80a034bafcaa7377f6af2b6c" - integrity sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg== - dependencies: - define-data-property "^1.0.1" - has-property-descriptors "^1.0.0" - object-keys "^1.1.1" - -delayed-stream@~1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" - integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ== - -delegates@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a" - integrity sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ== - -denque@^1.4.1: - version "1.5.1" - resolved "https://registry.yarnpkg.com/denque/-/denque-1.5.1.tgz#07f670e29c9a78f8faecb2566a1e2c11929c5cbf" - integrity sha512-XwE+iZ4D6ZUB7mfYRMb5wByE8L74HCn30FBN7sWnXksWc1LO1bPDl67pBR9o/kC4z/xSNAwkMYcGgqDV3BE3Hw== - -depd@2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df" - integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw== - -depd@^1.1.0: - version "1.1.2" - resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9" - integrity sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ== - -destroy@1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.2.0.tgz#4803735509ad8be552934c67df614f94e66fa015" - integrity sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg== - -detect-libc@^2.0.0: - version "2.0.3" - resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.0.3.tgz#f0cd503b40f9939b894697d19ad50895e30cf700" - integrity sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw== - -dicer@0.2.5: - version "0.2.5" - resolved "https://registry.yarnpkg.com/dicer/-/dicer-0.2.5.tgz#5996c086bb33218c812c090bddc09cd12facb70f" - integrity sha512-FDvbtnq7dzlPz0wyYlOExifDEZcu8h+rErEXgfxqmLfRfC/kJidEFh4+effJRO3P0xmfqyPbSMG0LveNRfTKVg== - dependencies: - readable-stream "1.1.x" - streamsearch "0.1.2" - -diff@4.0.2: - version "4.0.2" - resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d" - integrity sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A== - -doctrine@3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-3.0.0.tgz#addebead72a6574db783639dc87a121773973961" - integrity sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w== - dependencies: - esutils "^2.0.2" - -dot-prop@^5.2.0: - version "5.3.0" - resolved "https://registry.yarnpkg.com/dot-prop/-/dot-prop-5.3.0.tgz#90ccce708cd9cd82cc4dc8c3ddd9abdd55b20e88" - integrity sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q== - dependencies: - is-obj "^2.0.0" - -dottie@^2.0.6: - version "2.0.6" - resolved "https://registry.yarnpkg.com/dottie/-/dottie-2.0.6.tgz#34564ebfc6ec5e5772272d466424ad5b696484d4" - integrity sha512-iGCHkfUc5kFekGiqhe8B/mdaurD+lakO9txNnTvKtA6PISrw86LgqHvRzWYPyoE2Ph5aMIrCw9/uko6XHTKCwA== - -duplexer3@^0.1.4: - version "0.1.5" - resolved "https://registry.yarnpkg.com/duplexer3/-/duplexer3-0.1.5.tgz#0b5e4d7bad5de8901ea4440624c8e1d20099217e" - integrity sha512-1A8za6ws41LQgv9HrE/66jyC5yuSjQ3L/KOpFtoBilsAK2iA2wuS5rTt1OCzIvtS2V7nVmedsUU+DGRcjBmOYA== - -duplexify@^4.0.0, duplexify@^4.1.1: - version "4.1.3" - resolved "https://registry.yarnpkg.com/duplexify/-/duplexify-4.1.3.tgz#a07e1c0d0a2c001158563d32592ba58bddb0236f" - integrity sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA== - dependencies: - end-of-stream "^1.4.1" - inherits "^2.0.3" - readable-stream "^3.1.1" - stream-shift "^1.0.2" - -eastasianwidth@^0.2.0: - version "0.2.0" - resolved "https://registry.yarnpkg.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz#696ce2ec0aa0e6ea93a397ffcf24aa7840c827cb" - integrity sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA== - -ecdsa-sig-formatter@1.0.11, ecdsa-sig-formatter@^1.0.11: - version "1.0.11" - resolved "https://registry.yarnpkg.com/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz#ae0f0fa2d85045ef14a817daa3ce9acd0489e5bf" - integrity sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ== - dependencies: - safe-buffer "^5.0.1" - -editorconfig@^1.0.4: - version "1.0.4" - resolved "https://registry.yarnpkg.com/editorconfig/-/editorconfig-1.0.4.tgz#040c9a8e9a6c5288388b87c2db07028aa89f53a3" - integrity sha512-L9Qe08KWTlqYMVvMcTIvMAdl1cDUubzRNYL+WfA4bLDMHe4nemKkpmYzkznE1FwLKu0EEmy6obgQKzMJrg4x9Q== - dependencies: - "@one-ini/wasm" "0.1.1" - commander "^10.0.0" - minimatch "9.0.1" - semver "^7.5.3" - -ee-first@1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" - integrity sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow== - -emoji-regex@^7.0.1: - version "7.0.3" - resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-7.0.3.tgz#933a04052860c85e83c122479c4748a8e4c72156" - integrity sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA== - -emoji-regex@^8.0.0: - version "8.0.0" - resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" - integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== - -emoji-regex@^9.2.2: - version "9.2.2" - resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-9.2.2.tgz#840c8803b0d8047f4ff0cf963176b32d4ef3ed72" - integrity sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg== - -encodeurl@~1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" - integrity sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w== - -end-of-stream@^1.1.0, end-of-stream@^1.4.1: - version "1.4.4" - resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.4.tgz#5ae64a5f45057baf3626ec14da0ca5e4b2431eb0" - integrity sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q== - dependencies: - once "^1.4.0" - -ent@^2.2.0: - version "2.2.1" - resolved "https://registry.yarnpkg.com/ent/-/ent-2.2.1.tgz#68dc99a002f115792c26239baedaaea9e70c0ca2" - integrity sha512-QHuXVeZx9d+tIQAz/XztU0ZwZf2Agg9CcXcgE1rurqvdBeDBrpSwjl8/6XUqMg7tw2Y7uAdKb2sRv+bSEFqQ5A== - dependencies: - punycode "^1.4.1" - -es-abstract@^1.17.0-next.1, es-abstract@^1.22.1, es-abstract@^1.22.3, es-abstract@^1.23.0, es-abstract@^1.23.2: - version "1.23.3" - resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.23.3.tgz#8f0c5a35cd215312573c5a27c87dfd6c881a0aa0" - integrity sha512-e+HfNH61Bj1X9/jLc5v1owaLYuHdeHHSQlkhCBiTK8rBvKaULl/beGMxwrMXjpYrv4pz22BlY570vVePA2ho4A== - dependencies: - array-buffer-byte-length "^1.0.1" - arraybuffer.prototype.slice "^1.0.3" - available-typed-arrays "^1.0.7" - call-bind "^1.0.7" - data-view-buffer "^1.0.1" - data-view-byte-length "^1.0.1" - data-view-byte-offset "^1.0.0" - es-define-property "^1.0.0" - es-errors "^1.3.0" - es-object-atoms "^1.0.0" - es-set-tostringtag "^2.0.3" - es-to-primitive "^1.2.1" - function.prototype.name "^1.1.6" - get-intrinsic "^1.2.4" - get-symbol-description "^1.0.2" - globalthis "^1.0.3" - gopd "^1.0.1" - has-property-descriptors "^1.0.2" - has-proto "^1.0.3" - has-symbols "^1.0.3" - hasown "^2.0.2" - internal-slot "^1.0.7" - is-array-buffer "^3.0.4" - is-callable "^1.2.7" - is-data-view "^1.0.1" - is-negative-zero "^2.0.3" - is-regex "^1.1.4" - is-shared-array-buffer "^1.0.3" - is-string "^1.0.7" - is-typed-array "^1.1.13" - is-weakref "^1.0.2" - object-inspect "^1.13.1" - object-keys "^1.1.1" - object.assign "^4.1.5" - regexp.prototype.flags "^1.5.2" - safe-array-concat "^1.1.2" - safe-regex-test "^1.0.3" - string.prototype.trim "^1.2.9" - string.prototype.trimend "^1.0.8" - string.prototype.trimstart "^1.0.8" - typed-array-buffer "^1.0.2" - typed-array-byte-length "^1.0.1" - typed-array-byte-offset "^1.0.2" - typed-array-length "^1.0.6" - unbox-primitive "^1.0.2" - which-typed-array "^1.1.15" - -es-array-method-boxes-properly@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/es-array-method-boxes-properly/-/es-array-method-boxes-properly-1.0.0.tgz#873f3e84418de4ee19c5be752990b2e44718d09e" - integrity sha512-wd6JXUmyHmt8T5a2xreUwKcGPq6f1f+WwIJkijUqiGcJz1qqnZgP6XIK+QyIWU5lT7imeNxUll48bziG+TSYcA== - -es-define-property@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/es-define-property/-/es-define-property-1.0.0.tgz#c7faefbdff8b2696cf5f46921edfb77cc4ba3845" - integrity sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ== - dependencies: - get-intrinsic "^1.2.4" - -es-errors@^1.2.1, es-errors@^1.3.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/es-errors/-/es-errors-1.3.0.tgz#05f75a25dab98e4fb1dcd5e1472c0546d5057c8f" - integrity sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw== - -es-get-iterator@^1.0.2: - version "1.1.3" - resolved "https://registry.yarnpkg.com/es-get-iterator/-/es-get-iterator-1.1.3.tgz#3ef87523c5d464d41084b2c3c9c214f1199763d6" - integrity sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw== - dependencies: - call-bind "^1.0.2" - get-intrinsic "^1.1.3" - has-symbols "^1.0.3" - is-arguments "^1.1.1" - is-map "^2.0.2" - is-set "^2.0.2" - is-string "^1.0.7" - isarray "^2.0.5" - stop-iteration-iterator "^1.0.0" - -es-object-atoms@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/es-object-atoms/-/es-object-atoms-1.0.0.tgz#ddb55cd47ac2e240701260bc2a8e31ecb643d941" - integrity sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw== - dependencies: - es-errors "^1.3.0" - -es-set-tostringtag@^2.0.3: - version "2.0.3" - resolved "https://registry.yarnpkg.com/es-set-tostringtag/-/es-set-tostringtag-2.0.3.tgz#8bb60f0a440c2e4281962428438d58545af39777" - integrity sha512-3T8uNMC3OQTHkFUsFq8r/BwAXLHvU/9O9mE0fBc/MY5iq/8H7ncvO947LmYA6ldWw9Uh8Yhf25zu6n7nML5QWQ== - dependencies: - get-intrinsic "^1.2.4" - has-tostringtag "^1.0.2" - hasown "^2.0.1" - -es-to-primitive@^1.2.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/es-to-primitive/-/es-to-primitive-1.2.1.tgz#e55cd4c9cdc188bcefb03b366c736323fc5c898a" - integrity sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA== - dependencies: - is-callable "^1.1.4" - is-date-object "^1.0.1" - is-symbol "^1.0.2" - -es5-ext@^0.10.35, es5-ext@^0.10.46, es5-ext@^0.10.62, es5-ext@^0.10.64, es5-ext@~0.10.14, es5-ext@~0.10.2: - version "0.10.64" - resolved "https://registry.yarnpkg.com/es5-ext/-/es5-ext-0.10.64.tgz#12e4ffb48f1ba2ea777f1fcdd1918ef73ea21714" - integrity sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg== - dependencies: - es6-iterator "^2.0.3" - es6-symbol "^3.1.3" - esniff "^2.0.1" - next-tick "^1.1.0" - -es6-iterator@^2.0.3: - version "2.0.3" - resolved "https://registry.yarnpkg.com/es6-iterator/-/es6-iterator-2.0.3.tgz#a7de889141a05a94b0854403b2d0a0fbfa98f3b7" - integrity sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g== - dependencies: - d "1" - es5-ext "^0.10.35" - es6-symbol "^3.1.1" - -es6-symbol@^3.1.1, es6-symbol@^3.1.3: - version "3.1.4" - resolved "https://registry.yarnpkg.com/es6-symbol/-/es6-symbol-3.1.4.tgz#f4e7d28013770b4208ecbf3e0bf14d3bcb557b8c" - integrity sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg== - dependencies: - d "^1.0.2" - ext "^1.7.0" - -es6-weak-map@^2.0.3: - version "2.0.3" - resolved "https://registry.yarnpkg.com/es6-weak-map/-/es6-weak-map-2.0.3.tgz#b6da1f16cc2cc0d9be43e6bdbfc5e7dfcdf31d53" - integrity sha512-p5um32HOTO1kP+w7PRnB+5lQ43Z6muuMuIMffvDN8ZB4GcnjLBV6zGStpbASIMk4DCAvEaamhe2zhyCb/QXXsA== - dependencies: - d "1" - es5-ext "^0.10.46" - es6-iterator "^2.0.3" - es6-symbol "^3.1.1" - -escalade@^3.1.1: - version "3.1.2" - resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.2.tgz#54076e9ab29ea5bf3d8f1ed62acffbb88272df27" - integrity sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA== - -escape-goat@^2.0.0: - version "2.1.1" - resolved "https://registry.yarnpkg.com/escape-goat/-/escape-goat-2.1.1.tgz#1b2dc77003676c457ec760b2dc68edb648188675" - integrity sha512-8/uIhbG12Csjy2JEW7D9pHbreaVaS/OpN3ycnyvElTdwM5n6GY6W6e2IPemfvGZeUMqZ9A/3GqIZMgKnBhAw/Q== - -escape-html@~1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988" - integrity sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow== - -escape-string-regexp@4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34" - integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA== - -esniff@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/esniff/-/esniff-2.0.1.tgz#a4d4b43a5c71c7ec51c51098c1d8a29081f9b308" - integrity sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg== - dependencies: - d "^1.0.1" - es5-ext "^0.10.62" - event-emitter "^0.3.5" - type "^2.7.2" - -esprima@^4.0.0: - version "4.0.1" - resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71" - integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A== - -esutils@^2.0.2: - version "2.0.3" - resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64" - integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== - -etag@~1.8.1: - version "1.8.1" - resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887" - integrity sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg== - -event-emitter@^0.3.5: - version "0.3.5" - resolved "https://registry.yarnpkg.com/event-emitter/-/event-emitter-0.3.5.tgz#df8c69eef1647923c7157b9ce83840610b02cc39" - integrity sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA== - dependencies: - d "1" - es5-ext "~0.10.14" - -event-target-shim@^5.0.0: - version "5.0.1" - resolved "https://registry.yarnpkg.com/event-target-shim/-/event-target-shim-5.0.1.tgz#5d4d3ebdf9583d63a5333ce2deb7480ab2b05789" - integrity sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ== - -events@^3.0.0, events@^3.3.0: - version "3.3.0" - resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400" - integrity sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q== - -express@4.18.2: - version "4.18.2" - resolved "https://registry.yarnpkg.com/express/-/express-4.18.2.tgz#3fabe08296e930c796c19e3c516979386ba9fd59" - integrity sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ== - dependencies: - accepts "~1.3.8" - array-flatten "1.1.1" - body-parser "1.20.1" - content-disposition "0.5.4" - content-type "~1.0.4" - cookie "0.5.0" - cookie-signature "1.0.6" - debug "2.6.9" - depd "2.0.0" - encodeurl "~1.0.2" - escape-html "~1.0.3" - etag "~1.8.1" - finalhandler "1.2.0" - fresh "0.5.2" - http-errors "2.0.0" - merge-descriptors "1.0.1" - methods "~1.1.2" - on-finished "2.4.1" - parseurl "~1.3.3" - path-to-regexp "0.1.7" - proxy-addr "~2.0.7" - qs "6.11.0" - range-parser "~1.2.1" - safe-buffer "5.2.1" - send "0.18.0" - serve-static "1.15.0" - setprototypeof "1.2.0" - statuses "2.0.1" - type-is "~1.6.18" - utils-merge "1.0.1" - vary "~1.1.2" - -ext@^1.7.0: - version "1.7.0" - resolved "https://registry.yarnpkg.com/ext/-/ext-1.7.0.tgz#0ea4383c0103d60e70be99e9a7f11027a33c4f5f" - integrity sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw== - dependencies: - type "^2.7.2" - -extend@^3.0.2: - version "3.0.2" - resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa" - integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g== - -fast-text-encoding@^1.0.0: - version "1.0.6" - resolved "https://registry.yarnpkg.com/fast-text-encoding/-/fast-text-encoding-1.0.6.tgz#0aa25f7f638222e3396d72bf936afcf1d42d6867" - integrity sha512-VhXlQgj9ioXCqGstD37E/HBeqEGV/qOD/kmbVG8h5xKBYvM1L3lR1Zn4555cQ8GkYbJa8aJSipLPndE1k6zK2w== - -fill-range@^7.1.1: - version "7.1.1" - resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.1.1.tgz#44265d3cac07e3ea7dc247516380643754a05292" - integrity sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg== - dependencies: - to-regex-range "^5.0.1" - -finalhandler@1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.2.0.tgz#7d23fe5731b207b4640e4fcd00aec1f9207a7b32" - integrity sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg== - dependencies: - debug "2.6.9" - encodeurl "~1.0.2" - escape-html "~1.0.3" - on-finished "2.4.1" - parseurl "~1.3.3" - statuses "2.0.1" - unpipe "~1.0.0" - -find-up@5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/find-up/-/find-up-5.0.0.tgz#4c92819ecb7083561e4f4a240a86be5198f536fc" - integrity sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng== - dependencies: - locate-path "^6.0.0" - path-exists "^4.0.0" - -find-up@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/find-up/-/find-up-3.0.0.tgz#49169f1d7993430646da61ecc5ae355c21c97b73" - integrity sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg== - dependencies: - locate-path "^3.0.0" - -flat@^4.1.0: - version "4.1.1" - resolved "https://registry.yarnpkg.com/flat/-/flat-4.1.1.tgz#a392059cc382881ff98642f5da4dde0a959f309b" - integrity sha512-FmTtBsHskrU6FJ2VxCnsDb84wu9zhmO3cUX2kGFb5tuwhfXxGciiT0oRY+cck35QmG+NmGh5eLz6lLCpWTqwpA== - dependencies: - is-buffer "~2.0.3" - -follow-redirects@^1.15.6: - version "1.15.6" - resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.6.tgz#7f815c0cda4249c74ff09e95ef97c23b5fd0399b" - integrity sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA== - -for-each@^0.3.3: - version "0.3.3" - resolved "https://registry.yarnpkg.com/for-each/-/for-each-0.3.3.tgz#69b447e88a0a5d32c3e7084f3f1710034b21376e" - integrity sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw== - dependencies: - is-callable "^1.1.3" - -foreground-child@^3.1.0: - version "3.2.1" - resolved "https://registry.yarnpkg.com/foreground-child/-/foreground-child-3.2.1.tgz#767004ccf3a5b30df39bed90718bab43fe0a59f7" - integrity sha512-PXUUyLqrR2XCWICfv6ukppP96sdFwWbNEnfEMt7jNsISjMsvaLNinAHNDYyvkyU+SZG2BTSbT5NjG+vZslfGTA== - dependencies: - cross-spawn "^7.0.0" - signal-exit "^4.0.1" - -form-data@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.0.tgz#93919daeaf361ee529584b9b31664dc12c9fa452" - integrity sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww== - dependencies: - asynckit "^0.4.0" - combined-stream "^1.0.8" - mime-types "^2.1.12" - -formidable@1.2.2: - version "1.2.2" - resolved "https://registry.yarnpkg.com/formidable/-/formidable-1.2.2.tgz#bf69aea2972982675f00865342b982986f6b8dd9" - integrity sha512-V8gLm+41I/8kguQ4/o1D3RIHRmhYFG4pnNyonvua+40rqcEmT4+V71yaZ3B457xbbgCsCfjSPi65u/W6vK1U5Q== - -forwarded@0.2.0: - version "0.2.0" - resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.2.0.tgz#2269936428aad4c15c7ebe9779a84bf0b2a81811" - integrity sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow== - -fresh@0.5.2, fresh@^0.5.2: - version "0.5.2" - resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7" - integrity sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q== - -fs-extra@^9.1.0: - version "9.1.0" - resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-9.1.0.tgz#5954460c764a8da2094ba3554bf839e6b9a7c86d" - integrity sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ== - dependencies: - at-least-node "^1.0.0" - graceful-fs "^4.2.0" - jsonfile "^6.0.1" - universalify "^2.0.0" - -fs-minipass@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/fs-minipass/-/fs-minipass-2.1.0.tgz#7f5036fdbf12c63c169190cbe4199c852271f9fb" - integrity sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg== - dependencies: - minipass "^3.0.0" - -fs.realpath@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" - integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== - -fsevents@~2.1.2: - version "2.1.3" - resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.1.3.tgz#fb738703ae8d2f9fe900c33836ddebee8b97f23e" - integrity sha512-Auw9a4AxqWpa9GUfj370BMPzzyncfBABW8Mab7BGWBYDj4Isgq+cDKtx0i6u9jcX9pQDnswsaaOTgTmA5pEjuQ== - -fsevents@~2.3.2: - version "2.3.3" - resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" - integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== - -function-bind@^1.1.1, function-bind@^1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c" - integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA== - -function.prototype.name@^1.1.6: - version "1.1.6" - resolved "https://registry.yarnpkg.com/function.prototype.name/-/function.prototype.name-1.1.6.tgz#cdf315b7d90ee77a4c6ee216c3c3362da07533fd" - integrity sha512-Z5kx79swU5P27WEayXM1tBi5Ze/lbIyiNgU3qyXUOf9b2rgXYyF9Dy9Cx+IQv/Lc8WCG6L82zwUPpSS9hGehIg== - dependencies: - call-bind "^1.0.2" - define-properties "^1.2.0" - es-abstract "^1.22.1" - functions-have-names "^1.2.3" - -functions-have-names@^1.2.3: - version "1.2.3" - resolved "https://registry.yarnpkg.com/functions-have-names/-/functions-have-names-1.2.3.tgz#0404fe4ee2ba2f607f0e0ec3c80bae994133b834" - integrity sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ== - -gauge@^3.0.0: - version "3.0.2" - resolved "https://registry.yarnpkg.com/gauge/-/gauge-3.0.2.tgz#03bf4441c044383908bcfa0656ad91803259b395" - integrity sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q== - dependencies: - aproba "^1.0.3 || ^2.0.0" - color-support "^1.1.2" - console-control-strings "^1.0.0" - has-unicode "^2.0.1" - object-assign "^4.1.1" - signal-exit "^3.0.0" - string-width "^4.2.3" - strip-ansi "^6.0.1" - wide-align "^1.1.2" - -gaxios@^4.0.0: - version "4.3.3" - resolved "https://registry.yarnpkg.com/gaxios/-/gaxios-4.3.3.tgz#d44bdefe52d34b6435cc41214fdb160b64abfc22" - integrity sha512-gSaYYIO1Y3wUtdfHmjDUZ8LWaxJQpiavzbF5Kq53akSzvmVg0RfyOcFDbO1KJ/KCGRFz2qG+lS81F0nkr7cRJA== - dependencies: - abort-controller "^3.0.0" - extend "^3.0.2" - https-proxy-agent "^5.0.0" - is-stream "^2.0.0" - node-fetch "^2.6.7" - -gcp-metadata@^4.2.0: - version "4.3.1" - resolved "https://registry.yarnpkg.com/gcp-metadata/-/gcp-metadata-4.3.1.tgz#fb205fe6a90fef2fd9c85e6ba06e5559ee1eefa9" - integrity sha512-x850LS5N7V1F3UcV7PoupzGsyD6iVwTVvsh3tbXfkctZnBnjW5yu5z1/3k3SehF7TyoTIe78rJs02GMMy+LF+A== - dependencies: - gaxios "^4.0.0" - json-bigint "^1.0.0" - -generate-function@^2.3.1: - version "2.3.1" - resolved "https://registry.yarnpkg.com/generate-function/-/generate-function-2.3.1.tgz#f069617690c10c868e73b8465746764f97c3479f" - integrity sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ== - dependencies: - is-property "^1.0.2" - -get-caller-file@^2.0.1, get-caller-file@^2.0.5: - version "2.0.5" - resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e" - integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== - -get-intrinsic@^1.1.3, get-intrinsic@^1.2.1, get-intrinsic@^1.2.3, get-intrinsic@^1.2.4: - version "1.2.4" - resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.2.4.tgz#e385f5a4b5227d449c3eabbad05494ef0abbeadd" - integrity sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ== - dependencies: - es-errors "^1.3.0" - function-bind "^1.1.2" - has-proto "^1.0.1" - has-symbols "^1.0.3" - hasown "^2.0.0" - -get-stream@^4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-4.1.0.tgz#c1b255575f3dc21d59bfc79cd3d2b46b1c3a54b5" - integrity sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w== - dependencies: - pump "^3.0.0" - -get-stream@^5.1.0: - version "5.2.0" - resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-5.2.0.tgz#4966a1795ee5ace65e706c4b7beb71257d6e22d3" - integrity sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA== - dependencies: - pump "^3.0.0" - -get-symbol-description@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/get-symbol-description/-/get-symbol-description-1.0.2.tgz#533744d5aa20aca4e079c8e5daf7fd44202821f5" - integrity sha512-g0QYk1dZBxGwk+Ngc+ltRH2IBp2f7zBkBMBJZCDerh6EhlhSR6+9irMCuT/09zD6qkarHUSn529sK/yL4S27mg== - dependencies: - call-bind "^1.0.5" - es-errors "^1.3.0" - get-intrinsic "^1.2.4" - -glob-parent@~5.1.0, glob-parent@~5.1.2: - version "5.1.2" - resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4" - integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow== - dependencies: - is-glob "^4.0.1" - -glob@7.1.6: - version "7.1.6" - resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.6.tgz#141f33b81a7c2492e125594307480c46679278a6" - integrity sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA== - dependencies: - fs.realpath "^1.0.0" - inflight "^1.0.4" - inherits "2" - minimatch "^3.0.4" - once "^1.3.0" - path-is-absolute "^1.0.0" - -glob@^10.3.3: - version "10.4.5" - resolved "https://registry.yarnpkg.com/glob/-/glob-10.4.5.tgz#f4d9f0b90ffdbab09c9d77f5f29b4262517b0956" - integrity sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg== - dependencies: - foreground-child "^3.1.0" - jackspeak "^3.1.2" - minimatch "^9.0.4" - minipass "^7.1.2" - package-json-from-dist "^1.0.0" - path-scurry "^1.11.1" - -glob@^7.1.3: - version "7.2.3" - resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b" - integrity sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q== - dependencies: - fs.realpath "^1.0.0" - inflight "^1.0.4" - inherits "2" - minimatch "^3.1.1" - once "^1.3.0" - path-is-absolute "^1.0.0" - -global-dirs@^2.0.1: - version "2.1.0" - resolved "https://registry.yarnpkg.com/global-dirs/-/global-dirs-2.1.0.tgz#e9046a49c806ff04d6c1825e196c8f0091e8df4d" - integrity sha512-MG6kdOUh/xBnyo9cJFeIKkLEc1AyFq42QTU4XiX51i2NEdxLxLWXIjEjmqKeSuKR7pAZjTqUVoT2b2huxVLgYQ== - dependencies: - ini "1.3.7" - -globalthis@^1.0.3: - version "1.0.4" - resolved "https://registry.yarnpkg.com/globalthis/-/globalthis-1.0.4.tgz#7430ed3a975d97bfb59bcce41f5cabbafa651236" - integrity sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ== - dependencies: - define-properties "^1.2.1" - gopd "^1.0.1" - -google-auth-library@^7.14.1: - version "7.14.1" - resolved "https://registry.yarnpkg.com/google-auth-library/-/google-auth-library-7.14.1.tgz#e3483034162f24cc71b95c8a55a210008826213c" - integrity sha512-5Rk7iLNDFhFeBYc3s8l1CqzbEBcdhwR193RlD4vSNFajIcINKI8W8P0JLmBpwymHqqWbX34pJDQu39cSy/6RsA== - dependencies: - arrify "^2.0.0" - base64-js "^1.3.0" - ecdsa-sig-formatter "^1.0.11" - fast-text-encoding "^1.0.0" - gaxios "^4.0.0" - gcp-metadata "^4.2.0" - gtoken "^5.0.4" - jws "^4.0.0" - lru-cache "^6.0.0" - -google-p12-pem@^3.1.3: - version "3.1.4" - resolved "https://registry.yarnpkg.com/google-p12-pem/-/google-p12-pem-3.1.4.tgz#123f7b40da204de4ed1fbf2fd5be12c047fc8b3b" - integrity sha512-HHuHmkLgwjdmVRngf5+gSmpkyaRI6QmOg77J8tkNBHhNEI62sGHyw4/+UkgyZEI7h84NbWprXDJ+sa3xOYFvTg== - dependencies: - node-forge "^1.3.1" - -gopd@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.0.1.tgz#29ff76de69dac7489b7c0918a5788e56477c332c" - integrity sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA== - dependencies: - get-intrinsic "^1.1.3" - -got@^9.6.0: - version "9.6.0" - resolved "https://registry.yarnpkg.com/got/-/got-9.6.0.tgz#edf45e7d67f99545705de1f7bbeeeb121765ed85" - integrity sha512-R7eWptXuGYxwijs0eV+v3o6+XH1IqVK8dJOEecQfTmkncw9AV4dcw/Dhxi8MdlqPthxxpZyizMzyg8RTmEsG+Q== - dependencies: - "@sindresorhus/is" "^0.14.0" - "@szmarczak/http-timer" "^1.1.2" - cacheable-request "^6.0.0" - decompress-response "^3.3.0" - duplexer3 "^0.1.4" - get-stream "^4.1.0" - lowercase-keys "^1.0.1" - mimic-response "^1.0.1" - p-cancelable "^1.0.0" - to-readable-stream "^1.0.0" - url-parse-lax "^3.0.0" - -graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.0: - version "4.2.11" - resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3" - integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ== - -growl@1.10.5: - version "1.10.5" - resolved "https://registry.yarnpkg.com/growl/-/growl-1.10.5.tgz#f2735dc2283674fa67478b10181059355c369e5e" - integrity sha512-qBr4OuELkhPenW6goKVXiv47US3clb3/IbuWF9KNKEijAy9oeHxU9IgzjvJhHkUzhaj7rOUD7+YGWqUjLp5oSA== - -gtoken@^5.0.4: - version "5.3.2" - resolved "https://registry.yarnpkg.com/gtoken/-/gtoken-5.3.2.tgz#deb7dc876abe002178e0515e383382ea9446d58f" - integrity sha512-gkvEKREW7dXWF8NV8pVrKfW7WqReAmjjkMBh6lNCCGOM4ucS0r0YyXXl0r/9Yj8wcW/32ISkfc8h5mPTDbtifQ== - dependencies: - gaxios "^4.0.0" - google-p12-pem "^3.1.3" - jws "^4.0.0" - -has-bigints@^1.0.1, has-bigints@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/has-bigints/-/has-bigints-1.0.2.tgz#0871bd3e3d51626f6ca0966668ba35d5602d6eaa" - integrity sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ== - -has-flag@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd" - integrity sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw== - -has-flag@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b" - integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== - -has-property-descriptors@^1.0.0, has-property-descriptors@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz#963ed7d071dc7bf5f084c5bfbe0d1b6222586854" - integrity sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg== - dependencies: - es-define-property "^1.0.0" - -has-proto@^1.0.1, has-proto@^1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/has-proto/-/has-proto-1.0.3.tgz#b31ddfe9b0e6e9914536a6ab286426d0214f77fd" - integrity sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q== - -has-symbols@^1.0.0, has-symbols@^1.0.2, has-symbols@^1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.3.tgz#bb7b2c4349251dce87b125f7bdf874aa7c8b39f8" - integrity sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A== - -has-tostringtag@^1.0.0, has-tostringtag@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz#2cdc42d40bef2e5b4eeab7c01a73c54ce7ab5abc" - integrity sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw== - dependencies: - has-symbols "^1.0.3" - -has-unicode@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/has-unicode/-/has-unicode-2.0.1.tgz#e0e6fe6a28cf51138855e086d1691e771de2a8b9" - integrity sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ== - -has-yarn@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/has-yarn/-/has-yarn-2.1.0.tgz#137e11354a7b5bf11aa5cb649cf0c6f3ff2b2e77" - integrity sha512-UqBRqi4ju7T+TqGNdqAO0PaSVGsDGJUBQvk9eUWNGRY1CFGDzYhLWoM7JQEemnlvVcv/YEmc2wNW8BC24EnUsw== - -hash-stream-validation@^0.2.2: - version "0.2.4" - resolved "https://registry.yarnpkg.com/hash-stream-validation/-/hash-stream-validation-0.2.4.tgz#ee68b41bf822f7f44db1142ec28ba9ee7ccb7512" - integrity sha512-Gjzu0Xn7IagXVkSu9cSFuK1fqzwtLwFhNhVL8IFJijRNMgUttFbBSIAzKuSIrsFMO1+g1RlsoN49zPIbwPDMGQ== - -hasown@^2.0.0, hasown@^2.0.1, hasown@^2.0.2: - version "2.0.2" - resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.2.tgz#003eaf91be7adc372e84ec59dc37252cedb80003" - integrity sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ== - dependencies: - function-bind "^1.1.2" - -he@1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f" - integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw== - -helmet@4.1.1: - version "4.1.1" - resolved "https://registry.yarnpkg.com/helmet/-/helmet-4.1.1.tgz#751f0e273d809ace9c172073e0003bed27d27a4a" - integrity sha512-Avg4XxSBrehD94mkRwEljnO+6RZx7AGfk8Wa6K1nxaU+hbXlFOhlOIMgPfFqOYQB/dBCsTpootTGuiOG+CHiQA== - -http-cache-semantics@^4.0.0: - version "4.1.1" - resolved "https://registry.yarnpkg.com/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz#abe02fcb2985460bf0323be664436ec3476a6d5a" - integrity sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ== - -http-errors@2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-2.0.0.tgz#b7774a1486ef73cf7667ac9ae0858c012c57b9d3" - integrity sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ== - dependencies: - depd "2.0.0" - inherits "2.0.4" - setprototypeof "1.2.0" - statuses "2.0.1" - toidentifier "1.0.1" - -http-proxy-agent@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz#5129800203520d434f142bc78ff3c170800f2b43" - integrity sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w== - dependencies: - "@tootallnate/once" "2" - agent-base "6" - debug "4" - -http-proxy-agent@^7.0.0: - version "7.0.2" - resolved "https://registry.yarnpkg.com/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz#9a8b1f246866c028509486585f62b8f2c18c270e" - integrity sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig== - dependencies: - agent-base "^7.1.0" - debug "^4.3.4" - -https-proxy-agent@^5.0.0: - version "5.0.1" - resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz#c59ef224a04fe8b754f3db0063a25ea30d0005d6" - integrity sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA== - dependencies: - agent-base "6" - debug "4" - -https-proxy-agent@^7.0.0: - version "7.0.5" - resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-7.0.5.tgz#9e8b5013873299e11fab6fd548405da2d6c602b2" - integrity sha512-1e4Wqeblerz+tMKPIq2EMGiiWW1dIjZOksyHWSUm1rmuvw/how9hBHZ38lAGj5ID4Ik6EdkOw7NmWPy6LAwalw== - dependencies: - agent-base "^7.0.2" - debug "4" - -iconv-lite@0.4.24: - version "0.4.24" - resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" - integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA== - dependencies: - safer-buffer ">= 2.1.2 < 3" - -iconv-lite@^0.6.2, iconv-lite@^0.6.3: - version "0.6.3" - resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.6.3.tgz#a52f80bf38da1952eb5c681790719871a1a72501" - integrity sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw== - dependencies: - safer-buffer ">= 2.1.2 < 3.0.0" - -ieee754@^1.2.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352" - integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA== - -ignore-by-default@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/ignore-by-default/-/ignore-by-default-1.0.1.tgz#48ca6d72f6c6a3af00a9ad4ae6876be3889e2b09" - integrity sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA== - -import-lazy@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/import-lazy/-/import-lazy-2.1.0.tgz#05698e3d45c88e8d7e9d92cb0584e77f096f3e43" - integrity sha512-m7ZEHgtw69qOGw+jwxXkHlrlIPdTGkyh66zXZ1ajZbxkDBNjSY/LGbmjc7h0s2ELsUDTAhFr55TrPSSqJGPG0A== - -imurmurhash@^0.1.4: - version "0.1.4" - resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" - integrity sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA== - -inflection@^1.13.4: - version "1.13.4" - resolved "https://registry.yarnpkg.com/inflection/-/inflection-1.13.4.tgz#65aa696c4e2da6225b148d7a154c449366633a32" - integrity sha512-6I/HUDeYFfuNCVS3td055BaXBwKYuzw7K3ExVMStBowKo9oOAMJIXIHvdyR3iboTCp1b+1i5DSkIZTcwIktuDw== - -inflight@^1.0.4: - version "1.0.6" - resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" - integrity sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA== - dependencies: - once "^1.3.0" - wrappy "1" - -inherits@2, inherits@2.0.4, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.1, inherits@~2.0.3: - version "2.0.4" - resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" - integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== - -ini@1.3.7: - version "1.3.7" - resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.7.tgz#a09363e1911972ea16d7a8851005d84cf09a9a84" - integrity sha512-iKpRpXP+CrP2jyrxvg1kMUpXDyRUFDWurxbnVT1vQPx+Wz9uCYsMIqYuSBLV+PAaZG/d7kRLKRFc9oDMsH+mFQ== - -ini@^1.3.4, ini@~1.3.0: - version "1.3.8" - resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.8.tgz#a29da425b48806f34767a4efce397269af28432c" - integrity sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew== - -internal-slot@^1.0.4, internal-slot@^1.0.7: - version "1.0.7" - resolved "https://registry.yarnpkg.com/internal-slot/-/internal-slot-1.0.7.tgz#c06dcca3ed874249881007b0a5523b172a190802" - integrity sha512-NGnrKwXzSms2qUUih/ILZ5JBqNTSa1+ZmP6flaIp6KmSElgE9qdndzS3cqjrDovwFdmwsGsLdeFgB6suw+1e9g== - dependencies: - es-errors "^1.3.0" - hasown "^2.0.0" - side-channel "^1.0.4" - -ipaddr.js@1.9.1: - version "1.9.1" - resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3" - integrity sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g== - -is-arguments@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/is-arguments/-/is-arguments-1.1.1.tgz#15b3f88fda01f2a97fec84ca761a560f123efa9b" - integrity sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA== - dependencies: - call-bind "^1.0.2" - has-tostringtag "^1.0.0" - -is-array-buffer@^3.0.4: - version "3.0.4" - resolved "https://registry.yarnpkg.com/is-array-buffer/-/is-array-buffer-3.0.4.tgz#7a1f92b3d61edd2bc65d24f130530ea93d7fae98" - integrity sha512-wcjaerHw0ydZwfhiKbXJWLDY8A7yV7KhjQOpb83hGgGfId/aQa4TOvwyzn2PuswW2gPCYEL/nEAiSVpdOj1lXw== - dependencies: - call-bind "^1.0.2" - get-intrinsic "^1.2.1" - -is-bigint@^1.0.1: - version "1.0.4" - resolved "https://registry.yarnpkg.com/is-bigint/-/is-bigint-1.0.4.tgz#08147a1875bc2b32005d41ccd8291dffc6691df3" - integrity sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg== - dependencies: - has-bigints "^1.0.1" - -is-binary-path@~2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-2.1.0.tgz#ea1f7f3b80f064236e83470f86c09c254fb45b09" - integrity sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw== - dependencies: - binary-extensions "^2.0.0" - -is-boolean-object@^1.1.0: - version "1.1.2" - resolved "https://registry.yarnpkg.com/is-boolean-object/-/is-boolean-object-1.1.2.tgz#5c6dc200246dd9321ae4b885a114bb1f75f63719" - integrity sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA== - dependencies: - call-bind "^1.0.2" - has-tostringtag "^1.0.0" - -is-buffer@~2.0.3: - version "2.0.5" - resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-2.0.5.tgz#ebc252e400d22ff8d77fa09888821a24a658c191" - integrity sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ== - -is-callable@^1.1.3, is-callable@^1.1.4, is-callable@^1.2.7: - version "1.2.7" - resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.7.tgz#3bc2a85ea742d9e36205dcacdd72ca1fdc51b055" - integrity sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA== - -is-ci@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/is-ci/-/is-ci-2.0.0.tgz#6bc6334181810e04b5c22b3d589fdca55026404c" - integrity sha512-YfJT7rkpQB0updsdHLGWrvhBJfcfzNNawYDNIyQXJz0IViGf75O8EBPKSdvw2rF+LGCsX4FZ8tcr3b19LcZq4w== - dependencies: - ci-info "^2.0.0" - -is-core-module@^2.13.0: - version "2.15.0" - resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.15.0.tgz#71c72ec5442ace7e76b306e9d48db361f22699ea" - integrity sha512-Dd+Lb2/zvk9SKy1TGCt1wFJFo/MWBPMX5x7KcvLajWTGuomczdQX61PvY5yK6SVACwpoexWo81IfFyoKY2QnTA== - dependencies: - hasown "^2.0.2" - -is-data-view@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/is-data-view/-/is-data-view-1.0.1.tgz#4b4d3a511b70f3dc26d42c03ca9ca515d847759f" - integrity sha512-AHkaJrsUVW6wq6JS8y3JnM/GJF/9cf+k20+iDzlSaJrinEo5+7vRiteOSwBhHRiAyQATN1AmY4hwzxJKPmYf+w== - dependencies: - is-typed-array "^1.1.13" - -is-date-object@^1.0.1: - version "1.0.5" - resolved "https://registry.yarnpkg.com/is-date-object/-/is-date-object-1.0.5.tgz#0841d5536e724c25597bf6ea62e1bd38298df31f" - integrity sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ== - dependencies: - has-tostringtag "^1.0.0" - -is-docker@^2.0.0, is-docker@^2.1.1: - version "2.2.1" - resolved "https://registry.yarnpkg.com/is-docker/-/is-docker-2.2.1.tgz#33eeabe23cfe86f14bde4408a02c0cfb853acdaa" - integrity sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ== - -is-extglob@^2.1.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" - integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ== - -is-fullwidth-code-point@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz#a3b30a5c4f199183167aaab93beefae3ddfb654f" - integrity sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w== - -is-fullwidth-code-point@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d" - integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== - -is-glob@^4.0.1, is-glob@~4.0.1: - version "4.0.3" - resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084" - integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg== - dependencies: - is-extglob "^2.1.1" - -is-installed-globally@^0.3.1: - version "0.3.2" - resolved "https://registry.yarnpkg.com/is-installed-globally/-/is-installed-globally-0.3.2.tgz#fd3efa79ee670d1187233182d5b0a1dd00313141" - integrity sha512-wZ8x1js7Ia0kecP/CHM/3ABkAmujX7WPvQk6uu3Fly/Mk44pySulQpnHG46OMjHGXApINnV4QhY3SWnECO2z5g== - dependencies: - global-dirs "^2.0.1" - is-path-inside "^3.0.1" - -is-map@^2.0.2: - version "2.0.3" - resolved "https://registry.yarnpkg.com/is-map/-/is-map-2.0.3.tgz#ede96b7fe1e270b3c4465e3a465658764926d62e" - integrity sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw== - -is-negative-zero@^2.0.3: - version "2.0.3" - resolved "https://registry.yarnpkg.com/is-negative-zero/-/is-negative-zero-2.0.3.tgz#ced903a027aca6381b777a5743069d7376a49747" - integrity sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw== - -is-npm@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/is-npm/-/is-npm-4.0.0.tgz#c90dd8380696df87a7a6d823c20d0b12bbe3c84d" - integrity sha512-96ECIfh9xtDDlPylNPXhzjsykHsMJZ18ASpaWzQyBr4YRTcVjUvzaHayDAES2oU/3KpljhHUjtSRNiDwi0F0ig== - -is-number-object@^1.0.4: - version "1.0.7" - resolved "https://registry.yarnpkg.com/is-number-object/-/is-number-object-1.0.7.tgz#59d50ada4c45251784e9904f5246c742f07a42fc" - integrity sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ== - dependencies: - has-tostringtag "^1.0.0" - -is-number@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b" - integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== - -is-obj@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/is-obj/-/is-obj-2.0.0.tgz#473fb05d973705e3fd9620545018ca8e22ef4982" - integrity sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w== - -is-path-inside@^3.0.1: - version "3.0.3" - resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-3.0.3.tgz#d231362e53a07ff2b0e0ea7fed049161ffd16283" - integrity sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ== - -is-plain-obj@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-1.1.0.tgz#71a50c8429dfca773c92a390a4a03b39fcd51d3e" - integrity sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg== - -is-promise@^2.2.2: - version "2.2.2" - resolved "https://registry.yarnpkg.com/is-promise/-/is-promise-2.2.2.tgz#39ab959ccbf9a774cf079f7b40c7a26f763135f1" - integrity sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ== - -is-property@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/is-property/-/is-property-1.0.2.tgz#57fe1c4e48474edd65b09911f26b1cd4095dda84" - integrity sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g== - -is-regex@^1.1.4: - version "1.1.4" - resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.1.4.tgz#eef5663cd59fa4c0ae339505323df6854bb15958" - integrity sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg== - dependencies: - call-bind "^1.0.2" - has-tostringtag "^1.0.0" - -is-set@^2.0.2: - version "2.0.3" - resolved "https://registry.yarnpkg.com/is-set/-/is-set-2.0.3.tgz#8ab209ea424608141372ded6e0cb200ef1d9d01d" - integrity sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg== - -is-shared-array-buffer@^1.0.2, is-shared-array-buffer@^1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/is-shared-array-buffer/-/is-shared-array-buffer-1.0.3.tgz#1237f1cba059cdb62431d378dcc37d9680181688" - integrity sha512-nA2hv5XIhLR3uVzDDfCIknerhx8XUKnstuOERPNNIinXG7v9u+ohXF67vxm4TPTEPU6lm61ZkwP3c9PCB97rhg== - dependencies: - call-bind "^1.0.7" - -is-stream@^2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-2.0.1.tgz#fac1e3d53b97ad5a9d0ae9cef2389f5810a5c077" - integrity sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg== - -is-string@^1.0.5, is-string@^1.0.7: - version "1.0.7" - resolved "https://registry.yarnpkg.com/is-string/-/is-string-1.0.7.tgz#0dd12bf2006f255bb58f695110eff7491eebc0fd" - integrity sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg== - dependencies: - has-tostringtag "^1.0.0" - -is-symbol@^1.0.2, is-symbol@^1.0.3: - version "1.0.4" - resolved "https://registry.yarnpkg.com/is-symbol/-/is-symbol-1.0.4.tgz#a6dac93b635b063ca6872236de88910a57af139c" - integrity sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg== - dependencies: - has-symbols "^1.0.2" - -is-typed-array@^1.1.13: - version "1.1.13" - resolved "https://registry.yarnpkg.com/is-typed-array/-/is-typed-array-1.1.13.tgz#d6c5ca56df62334959322d7d7dd1cca50debe229" - integrity sha512-uZ25/bUAlUY5fR4OKT4rZQEBrzQWYV9ZJYGGsUmEJ6thodVJ1HX64ePQ6Z0qPWP+m+Uq6e9UugrE38jeYsDSMw== - dependencies: - which-typed-array "^1.1.14" - -is-typedarray@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a" - integrity sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA== - -is-weakref@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/is-weakref/-/is-weakref-1.0.2.tgz#9529f383a9338205e89765e0392efc2f100f06f2" - integrity sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ== - dependencies: - call-bind "^1.0.2" - -is-wsl@^2.2.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/is-wsl/-/is-wsl-2.2.0.tgz#74a4c76e77ca9fd3f932f290c17ea326cd157271" - integrity sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww== - dependencies: - is-docker "^2.0.0" - -is-yarn-global@^0.3.0: - version "0.3.0" - resolved "https://registry.yarnpkg.com/is-yarn-global/-/is-yarn-global-0.3.0.tgz#d502d3382590ea3004893746754c89139973e232" - integrity sha512-VjSeb/lHmkoyd8ryPVIKvOCn4D1koMqY+vqyjjUfc3xyKtP4dYOxM44sZrnqQSzSds3xyOrUTLTC9LVCVgLngw== - -isarray@0.0.1: - version "0.0.1" - resolved "https://registry.yarnpkg.com/isarray/-/isarray-0.0.1.tgz#8a18acfca9a8f4177e09abfc6038939b05d1eedf" - integrity sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ== - -isarray@^2.0.5: - version "2.0.5" - resolved "https://registry.yarnpkg.com/isarray/-/isarray-2.0.5.tgz#8af1e4c1221244cc62459faf38940d4e644a5723" - integrity sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw== - -isarray@~1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11" - integrity sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ== - -isexe@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" - integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw== - -iterate-iterator@^1.0.1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/iterate-iterator/-/iterate-iterator-1.0.2.tgz#551b804c9eaa15b847ea6a7cdc2f5bf1ec150f91" - integrity sha512-t91HubM4ZDQ70M9wqp+pcNpu8OyJ9UAtXntT/Bcsvp5tZMnz9vRa+IunKXeI8AnfZMTv0jNuVEmGeLSMjVvfPw== - -iterate-value@^1.0.0: - version "1.0.2" - resolved "https://registry.yarnpkg.com/iterate-value/-/iterate-value-1.0.2.tgz#935115bd37d006a52046535ebc8d07e9c9337f57" - integrity sha512-A6fMAio4D2ot2r/TYzr4yUWrmwNdsN5xL7+HUiyACE4DXm+q8HtPcnFTp+NnW3k4N05tZ7FVYFFb2CR13NxyHQ== - dependencies: - es-get-iterator "^1.0.2" - iterate-iterator "^1.0.1" - -jackspeak@^3.1.2: - version "3.4.3" - resolved "https://registry.yarnpkg.com/jackspeak/-/jackspeak-3.4.3.tgz#8833a9d89ab4acde6188942bd1c53b6390ed5a8a" - integrity sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw== - dependencies: - "@isaacs/cliui" "^8.0.2" - optionalDependencies: - "@pkgjs/parseargs" "^0.11.0" - -js-beautify@^1.14.5: - version "1.15.1" - resolved "https://registry.yarnpkg.com/js-beautify/-/js-beautify-1.15.1.tgz#4695afb508c324e1084ee0b952a102023fc65b64" - integrity sha512-ESjNzSlt/sWE8sciZH8kBF8BPlwXPwhR6pWKAw8bw4Bwj+iZcnKW6ONWUutJ7eObuBZQpiIb8S7OYspWrKt7rA== - dependencies: - config-chain "^1.1.13" - editorconfig "^1.0.4" - glob "^10.3.3" - js-cookie "^3.0.5" - nopt "^7.2.0" - -js-cookie@^3.0.5: - version "3.0.5" - resolved "https://registry.yarnpkg.com/js-cookie/-/js-cookie-3.0.5.tgz#0b7e2fd0c01552c58ba86e0841f94dc2557dcdbc" - integrity sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw== - -js-md4@^0.3.2: - version "0.3.2" - resolved "https://registry.yarnpkg.com/js-md4/-/js-md4-0.3.2.tgz#cd3b3dc045b0c404556c81ddb5756c23e59d7cf5" - integrity sha512-/GDnfQYsltsjRswQhN9fhv3EMw2sCpUdrdxyWDOUK7eyD++r3gRhzgiQgc/x4MAv2i1iuQ4lxO5mvqM3vj4bwA== - -js-yaml@3.14.0: - version "3.14.0" - resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.14.0.tgz#a7a34170f26a21bb162424d8adacb4113a69e482" - integrity sha512-/4IbIeHcD9VMHFqDR/gQ7EdZdLimOvW2DdcxFjdyyZ9NsbS+ccrXqVWDtab/lRl5AlUqmpBx8EhPaWR+OtY17A== - dependencies: - argparse "^1.0.7" - esprima "^4.0.0" - -js-yaml@^4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.0.tgz#c1fb65f8f5017901cdd2c951864ba18458a10602" - integrity sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA== - dependencies: - argparse "^2.0.1" - -json-bigint@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/json-bigint/-/json-bigint-1.0.0.tgz#ae547823ac0cad8398667f8cd9ef4730f5b01ff1" - integrity sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ== - dependencies: - bignumber.js "^9.0.0" - -json-buffer@3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/json-buffer/-/json-buffer-3.0.0.tgz#5b1f397afc75d677bde8bcfc0e47e1f9a3d9a898" - integrity sha512-CuUqjv0FUZIdXkHPI8MezCnFCdaTAacej1TZYulLoAg1h/PhwkdXFN4V/gzY4g+fMBCOV2xF+rp7t2XD2ns/NQ== - -json2csv@^5.0.7: - version "5.0.7" - resolved "https://registry.yarnpkg.com/json2csv/-/json2csv-5.0.7.tgz#f3a583c25abd9804be873e495d1e65ad8d1b54ae" - integrity sha512-YRZbUnyaJZLZUJSRi2G/MqahCyRv9n/ds+4oIetjDF3jWQA7AG7iSeKTiZiCNqtMZM7HDyt0e/W6lEnoGEmMGA== - dependencies: - commander "^6.1.0" - jsonparse "^1.3.1" - lodash.get "^4.4.2" - -jsonfile@^6.0.1: - version "6.1.0" - resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-6.1.0.tgz#bc55b2634793c679ec6403094eb13698a6ec0aae" - integrity sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ== - dependencies: - universalify "^2.0.0" - optionalDependencies: - graceful-fs "^4.1.6" - -jsonparse@^1.3.1: - version "1.3.1" - resolved "https://registry.yarnpkg.com/jsonparse/-/jsonparse-1.3.1.tgz#3f4dae4a91fac315f71062f8521cc239f1366280" - integrity sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg== - -jsonwebtoken@8.5.1: - version "8.5.1" - resolved "https://registry.yarnpkg.com/jsonwebtoken/-/jsonwebtoken-8.5.1.tgz#00e71e0b8df54c2121a1f26137df2280673bcc0d" - integrity sha512-XjwVfRS6jTMsqYs0EsuJ4LGxXV14zQybNd4L2r0UvbVnSF9Af8x7p5MzbJ90Ioz/9TI41/hTCvznF/loiSzn8w== - dependencies: - jws "^3.2.2" - lodash.includes "^4.3.0" - lodash.isboolean "^3.0.3" - lodash.isinteger "^4.0.4" - lodash.isnumber "^3.0.3" - lodash.isplainobject "^4.0.6" - lodash.isstring "^4.0.1" - lodash.once "^4.0.0" - ms "^2.1.1" - semver "^5.6.0" - -jsonwebtoken@^9.0.0: - version "9.0.2" - resolved "https://registry.yarnpkg.com/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz#65ff91f4abef1784697d40952bb1998c504caaf3" - integrity sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ== - dependencies: - jws "^3.2.2" - lodash.includes "^4.3.0" - lodash.isboolean "^3.0.3" - lodash.isinteger "^4.0.4" - lodash.isnumber "^3.0.3" - lodash.isplainobject "^4.0.6" - lodash.isstring "^4.0.1" - lodash.once "^4.0.0" - ms "^2.1.1" - semver "^7.5.4" - -jwa@^1.4.1: - version "1.4.1" - resolved "https://registry.yarnpkg.com/jwa/-/jwa-1.4.1.tgz#743c32985cb9e98655530d53641b66c8645b039a" - integrity sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA== - dependencies: - buffer-equal-constant-time "1.0.1" - ecdsa-sig-formatter "1.0.11" - safe-buffer "^5.0.1" - -jwa@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/jwa/-/jwa-2.0.0.tgz#a7e9c3f29dae94027ebcaf49975c9345593410fc" - integrity sha512-jrZ2Qx916EA+fq9cEAeCROWPTfCwi1IVHqT2tapuqLEVVDKFDENFw1oL+MwrTvH6msKxsd1YTDVw6uKEcsrLEA== - dependencies: - buffer-equal-constant-time "1.0.1" - ecdsa-sig-formatter "1.0.11" - safe-buffer "^5.0.1" - -jws@^3.2.2: - version "3.2.2" - resolved "https://registry.yarnpkg.com/jws/-/jws-3.2.2.tgz#001099f3639468c9414000e99995fa52fb478304" - integrity sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA== - dependencies: - jwa "^1.4.1" - safe-buffer "^5.0.1" - -jws@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/jws/-/jws-4.0.0.tgz#2d4e8cf6a318ffaa12615e9dec7e86e6c97310f4" - integrity sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg== - dependencies: - jwa "^2.0.0" - safe-buffer "^5.0.1" - -keyv@^3.0.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/keyv/-/keyv-3.1.0.tgz#ecc228486f69991e49e9476485a5be1e8fc5c4d9" - integrity sha512-9ykJ/46SN/9KPM/sichzQ7OvXyGDYKGTaDlKMGCAlg2UK8KRy4jb0d8sFc+0Tt0YYnThq8X2RZgCg74RPxgcVA== - dependencies: - json-buffer "3.0.0" - -latest-version@^5.0.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/latest-version/-/latest-version-5.1.0.tgz#119dfe908fe38d15dfa43ecd13fa12ec8832face" - integrity sha512-weT+r0kTkRQdCdYCNtkMwWXQTMEswKrFBkm4ckQOMVhhqhIMI1UT2hMj+1iigIhgSZm5gTmrRXBNoGUgaTY1xA== - dependencies: - package-json "^6.3.0" - -locate-path@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-3.0.0.tgz#dbec3b3ab759758071b58fe59fc41871af21400e" - integrity sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A== - dependencies: - p-locate "^3.0.0" - path-exists "^3.0.0" - -locate-path@^6.0.0: - version "6.0.0" - resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-6.0.0.tgz#55321eb309febbc59c4801d931a72452a681d286" - integrity sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw== - dependencies: - p-locate "^5.0.0" - -lodash.get@^4.4.2: - version "4.4.2" - resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99" - integrity sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ== - -lodash.includes@^4.3.0: - version "4.3.0" - resolved "https://registry.yarnpkg.com/lodash.includes/-/lodash.includes-4.3.0.tgz#60bb98a87cb923c68ca1e51325483314849f553f" - integrity sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w== - -lodash.isboolean@^3.0.3: - version "3.0.3" - resolved "https://registry.yarnpkg.com/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz#6c2e171db2a257cd96802fd43b01b20d5f5870f6" - integrity sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg== - -lodash.isequal@^4.5.0: - version "4.5.0" - resolved "https://registry.yarnpkg.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz#415c4478f2bcc30120c22ce10ed3226f7d3e18e0" - integrity sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ== - -lodash.isinteger@^4.0.4: - version "4.0.4" - resolved "https://registry.yarnpkg.com/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz#619c0af3d03f8b04c31f5882840b77b11cd68343" - integrity sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA== - -lodash.isnumber@^3.0.3: - version "3.0.3" - resolved "https://registry.yarnpkg.com/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz#3ce76810c5928d03352301ac287317f11c0b1ffc" - integrity sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw== - -lodash.isplainobject@^4.0.6: - version "4.0.6" - resolved "https://registry.yarnpkg.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz#7c526a52d89b45c45cc690b88163be0497f550cb" - integrity sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA== - -lodash.isstring@^4.0.1: - version "4.0.1" - resolved "https://registry.yarnpkg.com/lodash.isstring/-/lodash.isstring-4.0.1.tgz#d527dfb5456eca7cc9bb95d5daeaf88ba54a5451" - integrity sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw== - -lodash.mergewith@^4.6.2: - version "4.6.2" - resolved "https://registry.yarnpkg.com/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz#617121f89ac55f59047c7aec1ccd6654c6590f55" - integrity sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ== - -lodash.once@^4.0.0: - version "4.1.1" - resolved "https://registry.yarnpkg.com/lodash.once/-/lodash.once-4.1.1.tgz#0dd3971213c7c56df880977d504c88fb471a97ac" - integrity sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg== - -lodash@4.17.21, lodash@^4.17.21: - version "4.17.21" - resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" - integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== - -log-symbols@4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-4.0.0.tgz#69b3cc46d20f448eccdb75ea1fa733d9e821c920" - integrity sha512-FN8JBzLx6CzeMrB0tg6pqlGU1wCrXW+ZXGH481kfsBqer0hToTIiHdjH4Mq8xJUbvATujKCvaREGWpGUionraA== - dependencies: - chalk "^4.0.0" - -long@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/long/-/long-4.0.0.tgz#9a7b71cfb7d361a194ea555241c92f7468d5bf28" - integrity sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA== - -lowercase-keys@^1.0.0, lowercase-keys@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/lowercase-keys/-/lowercase-keys-1.0.1.tgz#6f9e30b47084d971a7c820ff15a6c5167b74c26f" - integrity sha512-G2Lj61tXDnVFFOi8VZds+SoQjtQC3dgokKdDG2mTm1tx4m50NUHBOZSBwQQHyy0V12A0JTG4icfZQH+xPyh8VA== - -lowercase-keys@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/lowercase-keys/-/lowercase-keys-2.0.0.tgz#2603e78b7b4b0006cbca2fbcc8a3202558ac9479" - integrity sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA== - -lru-cache@^10.2.0: - version "10.4.3" - resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.4.3.tgz#410fc8a17b70e598013df257c2446b7f3383f119" - integrity sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ== - -lru-cache@^6.0.0: - version "6.0.0" - resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-6.0.0.tgz#6d6fe6570ebd96aaf90fcad1dafa3b2566db3a94" - integrity sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA== - dependencies: - yallist "^4.0.0" - -lru-cache@^7.14.1: - version "7.18.3" - resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-7.18.3.tgz#f793896e0fd0e954a59dfdd82f0773808df6aa89" - integrity sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA== - -lru-queue@^0.1.0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/lru-queue/-/lru-queue-0.1.0.tgz#2738bd9f0d3cf4f84490c5736c48699ac632cda3" - integrity sha512-BpdYkt9EvGl8OfWHDQPISVpcl5xZthb+XPsbELj5AQXxIC8IriDZIQYjBJPEm5rS420sjZ0TLEzRcq5KdBhYrQ== - dependencies: - es5-ext "~0.10.2" - -make-dir@^3.0.0, make-dir@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-3.1.0.tgz#415e967046b3a7f1d185277d84aa58203726a13f" - integrity sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw== - dependencies: - semver "^6.0.0" - -media-typer@0.3.0: - version "0.3.0" - resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" - integrity sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ== - -memoizee@^0.4.15: - version "0.4.17" - resolved "https://registry.yarnpkg.com/memoizee/-/memoizee-0.4.17.tgz#942a5f8acee281fa6fb9c620bddc57e3b7382949" - integrity sha512-DGqD7Hjpi/1or4F/aYAspXKNm5Yili0QDAFAY4QYvpqpgiY6+1jOfqpmByzjxbWd/T9mChbCArXAbDAsTm5oXA== - dependencies: - d "^1.0.2" - es5-ext "^0.10.64" - es6-weak-map "^2.0.3" - event-emitter "^0.3.5" - is-promise "^2.2.2" - lru-queue "^0.1.0" - next-tick "^1.1.0" - timers-ext "^0.1.7" - -merge-descriptors@1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.1.tgz#b00aaa556dd8b44568150ec9d1b953f3f90cbb61" - integrity sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w== - -merge-descriptors@^1.0.1: - version "1.0.3" - resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.3.tgz#d80319a65f3c7935351e5cfdac8f9318504dbed5" - integrity sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ== - -methods@^1.1.2, methods@~1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee" - integrity sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w== - -mime-db@1.52.0: - version "1.52.0" - resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70" - integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== - -"mime-db@>= 1.43.0 < 2": - version "1.53.0" - resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.53.0.tgz#3cb63cd820fc29896d9d4e8c32ab4fcd74ccb447" - integrity sha512-oHlN/w+3MQ3rba9rqFr6V/ypF10LSkdwUysQL7GkXoTgIWeV+tcXGA852TBxH+gsh8UWoyhR1hKcoMJTuWflpg== - -mime-types@^2.0.8, mime-types@^2.1.12, mime-types@~2.1.24, mime-types@~2.1.34: - version "2.1.35" - resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a" - integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== - dependencies: - mime-db "1.52.0" - -mime@1.6.0, mime@^1.3.4: - version "1.6.0" - resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1" - integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg== - -mime@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/mime/-/mime-3.0.0.tgz#b374550dca3a0c18443b0c950a6a58f1931cf7a7" - integrity sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A== - -mimic-response@^1.0.0, mimic-response@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-1.0.1.tgz#4923538878eef42063cb8a3e3b0798781487ab1b" - integrity sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ== - -minimatch@3.0.4: - version "3.0.4" - resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083" - integrity sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA== - dependencies: - brace-expansion "^1.1.7" - -minimatch@9.0.1: - version "9.0.1" - resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.1.tgz#8a555f541cf976c622daf078bb28f29fb927c253" - integrity sha512-0jWhJpD/MdhPXwPuiRkCbfYfSKp2qnn2eOc279qI7f+osl/l+prKSrvhg157zSYvx/1nmgn2NqdT6k2Z7zSH9w== - dependencies: - brace-expansion "^2.0.1" - -minimatch@^3.0.4, minimatch@^3.1.1: - version "3.1.2" - resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b" - integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw== - dependencies: - brace-expansion "^1.1.7" - -minimatch@^9.0.4: - version "9.0.5" - resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.5.tgz#d74f9dd6b57d83d8e98cfb82133b03978bc929e5" - integrity sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow== - dependencies: - brace-expansion "^2.0.1" - -minimist@^1.2.0, minimist@^1.2.6: - version "1.2.8" - resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c" - integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== - -minipass@^3.0.0: - version "3.3.6" - resolved "https://registry.yarnpkg.com/minipass/-/minipass-3.3.6.tgz#7bba384db3a1520d18c9c0e5251c3444e95dd94a" - integrity sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw== - dependencies: - yallist "^4.0.0" - -minipass@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/minipass/-/minipass-5.0.0.tgz#3e9788ffb90b694a5d0ec94479a45b5d8738133d" - integrity sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ== - -"minipass@^5.0.0 || ^6.0.2 || ^7.0.0", minipass@^7.1.2: - version "7.1.2" - resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.1.2.tgz#93a9626ce5e5e66bd4db86849e7515e92340a707" - integrity sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw== - -minizlib@^2.1.1: - version "2.1.2" - resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-2.1.2.tgz#e90d3466ba209b932451508a11ce3d3632145931" - integrity sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg== - dependencies: - minipass "^3.0.0" - yallist "^4.0.0" - -mkdirp@^0.5.4: - version "0.5.6" - resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.6.tgz#7def03d2432dcae4ba1d611445c48396062255f6" - integrity sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw== - dependencies: - minimist "^1.2.6" - -mkdirp@^1.0.3: - version "1.0.4" - resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e" - integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw== - -mocha@8.1.3: - version "8.1.3" - resolved "https://registry.yarnpkg.com/mocha/-/mocha-8.1.3.tgz#5e93f873e35dfdd69617ea75f9c68c2ca61c2ac5" - integrity sha512-ZbaYib4hT4PpF4bdSO2DohooKXIn4lDeiYqB+vTmCdr6l2woW0b6H3pf5x4sM5nwQMru9RvjjHYWVGltR50ZBw== - dependencies: - ansi-colors "4.1.1" - browser-stdout "1.3.1" - chokidar "3.4.2" - debug "4.1.1" - diff "4.0.2" - escape-string-regexp "4.0.0" - find-up "5.0.0" - glob "7.1.6" - growl "1.10.5" - he "1.2.0" - js-yaml "3.14.0" - log-symbols "4.0.0" - minimatch "3.0.4" - ms "2.1.2" - object.assign "4.1.0" - promise.allsettled "1.0.2" - serialize-javascript "4.0.0" - strip-json-comments "3.0.1" - supports-color "7.1.0" - which "2.0.2" - wide-align "1.1.3" - workerpool "6.0.0" - yargs "13.3.2" - yargs-parser "13.1.2" - yargs-unparser "1.6.1" - -moment-timezone@^0.5.43: - version "0.5.45" - resolved "https://registry.yarnpkg.com/moment-timezone/-/moment-timezone-0.5.45.tgz#cb685acd56bac10e69d93c536366eb65aa6bcf5c" - integrity sha512-HIWmqA86KcmCAhnMAN0wuDOARV/525R2+lOLotuGFzn4HO+FH+/645z2wx0Dt3iDv6/p61SIvKnDstISainhLQ== - dependencies: - moment "^2.29.4" - -moment@2.30.1, moment@^2.29.4: - version "2.30.1" - resolved "https://registry.yarnpkg.com/moment/-/moment-2.30.1.tgz#f8c91c07b7a786e30c59926df530b4eac96974ae" - integrity sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how== - -ms@2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" - integrity sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A== - -ms@2.1.2: - version "2.1.2" - resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" - integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== - -ms@2.1.3, ms@^2.1.1: - version "2.1.3" - resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" - integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== - -multer@^1.4.4: - version "1.4.4" - resolved "https://registry.yarnpkg.com/multer/-/multer-1.4.4.tgz#e2bc6cac0df57a8832b858d7418ccaa8ebaf7d8c" - integrity sha512-2wY2+xD4udX612aMqMcB8Ws2Voq6NIUPEtD1be6m411T4uDH/VtL9i//xvcyFlTVfRdaBsk7hV5tgrGQqhuBiw== - dependencies: - append-field "^1.0.0" - busboy "^0.2.11" - concat-stream "^1.5.2" - mkdirp "^0.5.4" - object-assign "^4.1.1" - on-finished "^2.3.0" - type-is "^1.6.4" - xtend "^4.0.0" - -mysql2@2.2.5: - version "2.2.5" - resolved "https://registry.yarnpkg.com/mysql2/-/mysql2-2.2.5.tgz#72624ffb4816f80f96b9c97fedd8c00935f9f340" - integrity sha512-XRqPNxcZTpmFdXbJqb+/CtYVLCx14x1RTeNMD4954L331APu75IC74GDqnZMEt1kwaXy6TySo55rF2F3YJS78g== - dependencies: - denque "^1.4.1" - generate-function "^2.3.1" - iconv-lite "^0.6.2" - long "^4.0.0" - lru-cache "^6.0.0" - named-placeholders "^1.1.2" - seq-queue "^0.0.5" - sqlstring "^2.3.2" - -named-placeholders@^1.1.2: - version "1.1.3" - resolved "https://registry.yarnpkg.com/named-placeholders/-/named-placeholders-1.1.3.tgz#df595799a36654da55dda6152ba7a137ad1d9351" - integrity sha512-eLoBxg6wE/rZkJPhU/xRX1WTpkFEwDJEN96oxFrTsqBdbT5ec295Q+CoHrL9IT0DipqKhmGcaZmwOt8OON5x1w== - dependencies: - lru-cache "^7.14.1" - -native-duplexpair@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/native-duplexpair/-/native-duplexpair-1.0.0.tgz#7899078e64bf3c8a3d732601b3d40ff05db58fa0" - integrity sha512-E7QQoM+3jvNtlmyfqRZ0/U75VFgCls+fSkbml2MpgWkWyz3ox8Y58gNhfuziuQYGNNQAbFZJQck55LHCnCK6CA== - -negotiator@0.6.3: - version "0.6.3" - resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.3.tgz#58e323a72fedc0d6f9cd4d31fe49f51479590ccd" - integrity sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg== - -next-tick@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/next-tick/-/next-tick-1.1.0.tgz#1836ee30ad56d67ef281b22bd199f709449b35eb" - integrity sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ== - -node-addon-api@^5.0.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-5.1.0.tgz#49da1ca055e109a23d537e9de43c09cca21eb762" - integrity sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA== - -node-fetch@^2.6.1, node-fetch@^2.6.7: - version "2.7.0" - resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d" - integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A== - dependencies: - whatwg-url "^5.0.0" - -node-forge@^1.3.1: - version "1.3.1" - resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-1.3.1.tgz#be8da2af243b2417d5f646a770663a92b7e9ded3" - integrity sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA== - -node-mocks-http@1.9.0: - version "1.9.0" - resolved "https://registry.yarnpkg.com/node-mocks-http/-/node-mocks-http-1.9.0.tgz#6000c570fc4b809603782309be81c73a71d85b71" - integrity sha512-ILf7Ws8xyX9Rl2fLZ7xhZBovrRwgaP84M13esndP6V17M/8j25TpwNzb7Im8U9XCo6fRhdwqiQajWXpsas/E6w== - dependencies: - accepts "^1.3.7" - depd "^1.1.0" - fresh "^0.5.2" - merge-descriptors "^1.0.1" - methods "^1.1.2" - mime "^1.3.4" - parseurl "^1.3.3" - range-parser "^1.2.0" - type-is "^1.6.18" - -nodemailer@6.9.9: - version "6.9.9" - resolved "https://registry.yarnpkg.com/nodemailer/-/nodemailer-6.9.9.tgz#4549bfbf710cc6addec5064dd0f19874d24248d9" - integrity sha512-dexTll8zqQoVJEZPwQAKzxxtFn0qTnjdQTchoU6Re9BUUGBJiOy3YMn/0ShTW6J5M0dfQ1NeDeRTTl4oIWgQMA== - -nodemon@2.0.5: - version "2.0.5" - resolved "https://registry.yarnpkg.com/nodemon/-/nodemon-2.0.5.tgz#df67fe1fd1312ddb0c1e393ae2cf55aacdcec2f3" - integrity sha512-6/jqtZvJdk092pVnD2AIH19KQ9GQZAKOZVy/yT1ueL6aoV+Ix7a1lVZStXzvEh0fP4zE41DDWlkVoHjR6WlozA== - dependencies: - chokidar "^3.2.2" - debug "^3.2.6" - ignore-by-default "^1.0.1" - minimatch "^3.0.4" - pstree.remy "^1.1.7" - semver "^5.7.1" - supports-color "^5.5.0" - touch "^3.1.0" - undefsafe "^2.0.3" - update-notifier "^4.1.0" - -nopt@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/nopt/-/nopt-5.0.0.tgz#530942bb58a512fccafe53fe210f13a25355dc88" - integrity sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ== - dependencies: - abbrev "1" - -nopt@^7.2.0: - version "7.2.1" - resolved "https://registry.yarnpkg.com/nopt/-/nopt-7.2.1.tgz#1cac0eab9b8e97c9093338446eddd40b2c8ca1e7" - integrity sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w== - dependencies: - abbrev "^2.0.0" - -normalize-path@^3.0.0, normalize-path@~3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65" - integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA== - -normalize-url@^4.1.0: - version "4.5.1" - resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-4.5.1.tgz#0dd90cf1288ee1d1313b87081c9a5932ee48518a" - integrity sha512-9UZCFRHQdNrfTpGg8+1INIg93B6zE0aXMVFkw1WFwvO4SlZywU6aLg5Of0Ap/PgcbSw4LNxvMWXMeugwMCX0AA== - -npmlog@^5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-5.0.1.tgz#f06678e80e29419ad67ab964e0fa69959c1eb8b0" - integrity sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw== - dependencies: - are-we-there-yet "^2.0.0" - console-control-strings "^1.1.0" - gauge "^3.0.0" - set-blocking "^2.0.0" - -oauth@0.10.x: - version "0.10.0" - resolved "https://registry.yarnpkg.com/oauth/-/oauth-0.10.0.tgz#3551c4c9b95c53ea437e1e21e46b649482339c58" - integrity sha512-1orQ9MT1vHFGQxhuy7E/0gECD3fd2fCC+PIX+/jgmU/gI3EpRocXtmtvxCO5x3WZ443FLTLFWNDjl5MPJf9u+Q== - -oauth@0.9.x: - version "0.9.15" - resolved "https://registry.yarnpkg.com/oauth/-/oauth-0.9.15.tgz#bd1fefaf686c96b75475aed5196412ff60cfb9c1" - integrity sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA== - -object-assign@^4, object-assign@^4.1.1: - version "4.1.1" - resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" - integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg== - -object-inspect@^1.13.1: - version "1.13.2" - resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.2.tgz#dea0088467fb991e67af4058147a24824a3043ff" - integrity sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g== - -object-keys@^1.0.11, object-keys@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e" - integrity sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA== - -object.assign@4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/object.assign/-/object.assign-4.1.0.tgz#968bf1100d7956bb3ca086f006f846b3bc4008da" - integrity sha512-exHJeq6kBKj58mqGyTQ9DFvrZC/eR6OwxzoM9YRoGBqrXYonaFyGiFMuc9VZrXf7DarreEwMpurG3dd+CNyW5w== - dependencies: - define-properties "^1.1.2" - function-bind "^1.1.1" - has-symbols "^1.0.0" - object-keys "^1.0.11" - -object.assign@^4.1.5: - version "4.1.5" - resolved "https://registry.yarnpkg.com/object.assign/-/object.assign-4.1.5.tgz#3a833f9ab7fdb80fc9e8d2300c803d216d8fdbb0" - integrity sha512-byy+U7gp+FVwmyzKPYhW2h5l3crpmGsxl7X2s8y43IgxvG4g3QZ6CffDtsNQy1WsmZpQbO+ybo0AlW7TY6DcBQ== - dependencies: - call-bind "^1.0.5" - define-properties "^1.2.1" - has-symbols "^1.0.3" - object-keys "^1.1.1" - -on-finished@2.4.1, on-finished@^2.3.0: - version "2.4.1" - resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.4.1.tgz#58c8c44116e54845ad57f14ab10b03533184ac3f" - integrity sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg== - dependencies: - ee-first "1.1.1" - -once@^1.3.0, once@^1.3.1, once@^1.4.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" - integrity sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w== - dependencies: - wrappy "1" - -open@^8.0.0: - version "8.4.2" - resolved "https://registry.yarnpkg.com/open/-/open-8.4.2.tgz#5b5ffe2a8f793dcd2aad73e550cb87b59cb084f9" - integrity sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ== - dependencies: - define-lazy-prop "^2.0.0" - is-docker "^2.1.1" - is-wsl "^2.2.0" - -p-cancelable@^1.0.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/p-cancelable/-/p-cancelable-1.1.0.tgz#d078d15a3af409220c886f1d9a0ca2e441ab26cc" - integrity sha512-s73XxOZ4zpt1edZYZzvhqFa6uvQc1vwUa0K0BdtIZgQMAJj9IbebH+JkgKZc9h+B05PKHLOTl4ajG1BmNrVZlw== - -p-limit@^2.0.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.3.0.tgz#3dd33c647a214fdfffd835933eb086da0dc21db1" - integrity sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w== - dependencies: - p-try "^2.0.0" - -p-limit@^3.0.1, p-limit@^3.0.2: - version "3.1.0" - resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-3.1.0.tgz#e1daccbe78d0d1388ca18c64fea38e3e57e3706b" - integrity sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ== - dependencies: - yocto-queue "^0.1.0" - -p-locate@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-3.0.0.tgz#322d69a05c0264b25997d9f40cd8a891ab0064a4" - integrity sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ== - dependencies: - p-limit "^2.0.0" - -p-locate@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-5.0.0.tgz#83c8315c6785005e3bd021839411c9e110e6d834" - integrity sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw== - dependencies: - p-limit "^3.0.2" - -p-try@^2.0.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6" - integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ== - -package-json-from-dist@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/package-json-from-dist/-/package-json-from-dist-1.0.0.tgz#e501cd3094b278495eb4258d4c9f6d5ac3019f00" - integrity sha512-dATvCeZN/8wQsGywez1mzHtTlP22H8OEfPrVMLNr4/eGa+ijtLn/6M5f0dY8UKNrC2O9UCU6SSoG3qRKnt7STw== - -package-json@^6.3.0: - version "6.5.0" - resolved "https://registry.yarnpkg.com/package-json/-/package-json-6.5.0.tgz#6feedaca35e75725876d0b0e64974697fed145b0" - integrity sha512-k3bdm2n25tkyxcjSKzB5x8kfVxlMdgsbPr0GkZcwHsLpba6cBjqCt1KlcChKEvxHIcTB1FVMuwoijZ26xex5MQ== - dependencies: - got "^9.6.0" - registry-auth-token "^4.0.0" - registry-url "^5.0.0" - semver "^6.2.0" - -packet-reader@1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/packet-reader/-/packet-reader-1.0.0.tgz#9238e5480dedabacfe1fe3f2771063f164157d74" - integrity sha512-HAKu/fG3HpHFO0AA8WE8q2g+gBJaZ9MG7fcKk+IJPLTGAD6Psw4443l+9DGRbOIh3/aXr7Phy0TjilYivJo5XQ== - -parseurl@^1.3.3, parseurl@~1.3.3: - version "1.3.3" - resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4" - integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ== - -passport-google-oauth2@^0.2.0: - version "0.2.0" - resolved "https://registry.yarnpkg.com/passport-google-oauth2/-/passport-google-oauth2-0.2.0.tgz#fc9ea59e7091f02e24fd16d6be9257ea982ebbc3" - integrity sha512-62EdPtbfVdc55nIXi0p1WOa/fFMM8v/M8uQGnbcXA4OexZWCnfsEi3wo2buag+Is5oqpuHzOtI64JpHk0Xi5RQ== - dependencies: - passport-oauth2 "^1.1.2" - -passport-jwt@^4.0.1: - version "4.0.1" - resolved "https://registry.yarnpkg.com/passport-jwt/-/passport-jwt-4.0.1.tgz#c443795eff322c38d173faa0a3c481479646ec3d" - integrity sha512-UCKMDYhNuGOBE9/9Ycuoyh7vP6jpeTp/+sfMJl7nLff/t6dps+iaeE0hhNkKN8/HZHcJ7lCdOyDxHdDoxoSvdQ== - dependencies: - jsonwebtoken "^9.0.0" - passport-strategy "^1.0.0" - -passport-microsoft@^0.1.0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/passport-microsoft/-/passport-microsoft-0.1.0.tgz#dc72c1a38b294d74f4dc55fe93f52e25cb9aa5b4" - integrity sha512-0giBDgE1fnR5X84zJZkQ11hnKVrzEgViwRO6RGsormK9zTxFQmN/UHMTDbIpvhk989VqALewB6Pk1R5vNr3GHw== - dependencies: - passport-oauth2 "1.2.0" - pkginfo "0.2.x" - -passport-oauth2@1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/passport-oauth2/-/passport-oauth2-1.2.0.tgz#49613a3eca85c7a1e65bf1019e2b6b80a10c8ac2" - integrity sha512-6128N+n/MOrJdXxdC2q/PVKXtqgihGFIeup+9bsPybAvMPOUKqdGhh9ZIzZF8rFKJOlxUP9fgP3H0JQe18n0rg== - dependencies: - oauth "0.9.x" - passport-strategy "1.x.x" - uid2 "0.0.x" - -passport-oauth2@^1.1.2: - version "1.8.0" - resolved "https://registry.yarnpkg.com/passport-oauth2/-/passport-oauth2-1.8.0.tgz#55725771d160f09bbb191828d5e3d559eee079c8" - integrity sha512-cjsQbOrXIDE4P8nNb3FQRCCmJJ/utnFKEz2NX209f7KOHPoX18gF7gBzBbLLsj2/je4KrgiwLLGjf0lm9rtTBA== - dependencies: - base64url "3.x.x" - oauth "0.10.x" - passport-strategy "1.x.x" - uid2 "0.0.x" - utils-merge "1.x.x" - -passport-strategy@1.x.x, passport-strategy@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/passport-strategy/-/passport-strategy-1.0.0.tgz#b5539aa8fc225a3d1ad179476ddf236b440f52e4" - integrity sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA== - -passport@^0.7.0: - version "0.7.0" - resolved "https://registry.yarnpkg.com/passport/-/passport-0.7.0.tgz#3688415a59a48cf8068417a8a8092d4492ca3a05" - integrity sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ== - dependencies: - passport-strategy "1.x.x" - pause "0.0.1" - utils-merge "^1.0.1" - -path-exists@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-3.0.0.tgz#ce0ebeaa5f78cb18925ea7d810d7b59b010fd515" - integrity sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ== - -path-exists@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3" - integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w== - -path-is-absolute@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" - integrity sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg== - -path-key@^3.1.0: - version "3.1.1" - resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375" - integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q== - -path-parse@^1.0.7: - version "1.0.7" - resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735" - integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== - -path-scurry@^1.11.1: - version "1.11.1" - resolved "https://registry.yarnpkg.com/path-scurry/-/path-scurry-1.11.1.tgz#7960a668888594a0720b12a911d1a742ab9f11d2" - integrity sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA== - dependencies: - lru-cache "^10.2.0" - minipass "^5.0.0 || ^6.0.2 || ^7.0.0" - -path-to-regexp@0.1.7: - version "0.1.7" - resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.7.tgz#df604178005f522f15eb4490e7247a1bfaa67f8c" - integrity sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ== - -pause@0.0.1: - version "0.0.1" - resolved "https://registry.yarnpkg.com/pause/-/pause-0.0.1.tgz#1d408b3fdb76923b9543d96fb4c9dfd535d9cb5d" - integrity sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg== - -pg-connection-string@^2.4.0, pg-connection-string@^2.6.1: - version "2.6.4" - resolved "https://registry.yarnpkg.com/pg-connection-string/-/pg-connection-string-2.6.4.tgz#f543862adfa49fa4e14bc8a8892d2a84d754246d" - integrity sha512-v+Z7W/0EO707aNMaAEfiGnGL9sxxumwLl2fJvCQtMn9Fxsg+lPpPkdcyBSv/KFgpGdYkMfn+EI1Or2EHjpgLCA== - -pg-hstore@2.3.4: - version "2.3.4" - resolved "https://registry.yarnpkg.com/pg-hstore/-/pg-hstore-2.3.4.tgz#4425e3e2a3e15d2a334c35581186c27cf2e9b8dd" - integrity sha512-N3SGs/Rf+xA1M2/n0JBiXFDVMzdekwLZLAO0g7mpDY9ouX+fDI7jS6kTq3JujmYbtNSJ53TJ0q4G98KVZSM4EA== - dependencies: - underscore "^1.13.1" - -pg-int8@1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/pg-int8/-/pg-int8-1.0.1.tgz#943bd463bf5b71b4170115f80f8efc9a0c0eb78c" - integrity sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw== - -pg-pool@^3.2.1: - version "3.6.2" - resolved "https://registry.yarnpkg.com/pg-pool/-/pg-pool-3.6.2.tgz#3a592370b8ae3f02a7c8130d245bc02fa2c5f3f2" - integrity sha512-Htjbg8BlwXqSBQ9V8Vjtc+vzf/6fVUuak/3/XXKA9oxZprwW3IMDQTGHP+KDmVL7rtd+R1QjbnCFPuTHm3G4hg== - -pg-protocol@^1.3.0: - version "1.6.1" - resolved "https://registry.yarnpkg.com/pg-protocol/-/pg-protocol-1.6.1.tgz#21333e6d83b01faaebfe7a33a7ad6bfd9ed38cb3" - integrity sha512-jPIlvgoD63hrEuihvIg+tJhoGjUsLPn6poJY9N5CnlPd91c2T18T/9zBtLxZSb1EhYxBRoZJtzScCaWlYLtktg== - -pg-types@^2.1.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/pg-types/-/pg-types-2.2.0.tgz#2d0250d636454f7cfa3b6ae0382fdfa8063254a3" - integrity sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA== - dependencies: - pg-int8 "1.0.1" - postgres-array "~2.0.0" - postgres-bytea "~1.0.0" - postgres-date "~1.0.4" - postgres-interval "^1.1.0" - -pg@8.4.1: - version "8.4.1" - resolved "https://registry.yarnpkg.com/pg/-/pg-8.4.1.tgz#06cfb6208ae787a869b2f0022da11b90d13d933e" - integrity sha512-NRsH0aGMXmX1z8Dd0iaPCxWUw4ffu+lIAmGm+sTCwuDDWkpEgRCAHZYDwqaNhC5hG5DRMOjSUFasMWhvcmLN1A== - dependencies: - buffer-writer "2.0.0" - packet-reader "1.0.0" - pg-connection-string "^2.4.0" - pg-pool "^3.2.1" - pg-protocol "^1.3.0" - pg-types "^2.1.0" - pgpass "1.x" - -pgpass@1.x: - version "1.0.5" - resolved "https://registry.yarnpkg.com/pgpass/-/pgpass-1.0.5.tgz#9b873e4a564bb10fa7a7dbd55312728d422a223d" - integrity sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug== - dependencies: - split2 "^4.1.0" - -picomatch@^2.0.4, picomatch@^2.2.1: - version "2.3.1" - resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" - integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== - -pkginfo@0.2.x: - version "0.2.3" - resolved "https://registry.yarnpkg.com/pkginfo/-/pkginfo-0.2.3.tgz#7239c42a5ef6c30b8f328439d9b9ff71042490f8" - integrity sha512-7W7wTrE/NsY8xv/DTGjwNIyNah81EQH0MWcTzrHL6pOpMocOGZc0Mbdz9aXxSrp+U0mSmkU8jrNCDCfUs3sOBg== - -possible-typed-array-names@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz#89bb63c6fada2c3e90adc4a647beeeb39cc7bf8f" - integrity sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q== - -postgres-array@~2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/postgres-array/-/postgres-array-2.0.0.tgz#48f8fce054fbc69671999329b8834b772652d82e" - integrity sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA== - -postgres-bytea@~1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/postgres-bytea/-/postgres-bytea-1.0.0.tgz#027b533c0aa890e26d172d47cf9ccecc521acd35" - integrity sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w== - -postgres-date@~1.0.4: - version "1.0.7" - resolved "https://registry.yarnpkg.com/postgres-date/-/postgres-date-1.0.7.tgz#51bc086006005e5061c591cee727f2531bf641a8" - integrity sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q== - -postgres-interval@^1.1.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/postgres-interval/-/postgres-interval-1.2.0.tgz#b460c82cb1587507788819a06aa0fffdb3544695" - integrity sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ== - dependencies: - xtend "^4.0.0" - -prepend-http@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/prepend-http/-/prepend-http-2.0.0.tgz#e92434bfa5ea8c19f41cdfd401d741a3c819d897" - integrity sha512-ravE6m9Atw9Z/jjttRUZ+clIXogdghyZAuWJ3qEzjT+jI/dL1ifAqhZeC5VHzQp1MSt1+jxKkFNemj/iO7tVUA== - -process-nextick-args@~2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2" - integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag== - -process@^0.11.10: - version "0.11.10" - resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182" - integrity sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A== - -promise.allsettled@1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/promise.allsettled/-/promise.allsettled-1.0.2.tgz#d66f78fbb600e83e863d893e98b3d4376a9c47c9" - integrity sha512-UpcYW5S1RaNKT6pd+s9jp9K9rlQge1UXKskec0j6Mmuq7UJCvlS2J2/s/yuPN8ehftf9HXMxWlKiPbGGUzpoRg== - dependencies: - array.prototype.map "^1.0.1" - define-properties "^1.1.3" - es-abstract "^1.17.0-next.1" - function-bind "^1.1.1" - iterate-value "^1.0.0" - -proto-list@~1.2.1: - version "1.2.4" - resolved "https://registry.yarnpkg.com/proto-list/-/proto-list-1.2.4.tgz#212d5bfe1318306a420f6402b8e26ff39647a849" - integrity sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA== - -proxy-addr@~2.0.7: - version "2.0.7" - resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.7.tgz#f19fe69ceab311eeb94b42e70e8c2070f9ba1025" - integrity sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg== - dependencies: - forwarded "0.2.0" - ipaddr.js "1.9.1" - -proxy-from-env@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2" - integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg== - -pstree.remy@^1.1.7: - version "1.1.8" - resolved "https://registry.yarnpkg.com/pstree.remy/-/pstree.remy-1.1.8.tgz#c242224f4a67c21f686839bbdb4ac282b8373d3a" - integrity sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w== - -pump@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/pump/-/pump-3.0.0.tgz#b4a2116815bde2f4e1ea602354e8c75565107a64" - integrity sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww== - dependencies: - end-of-stream "^1.1.0" - once "^1.3.1" - -pumpify@^2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/pumpify/-/pumpify-2.0.1.tgz#abfc7b5a621307c728b551decbbefb51f0e4aa1e" - integrity sha512-m7KOje7jZxrmutanlkS1daj1dS6z6BgslzOXmcSEpIlCxM3VJH7lG5QLeck/6hgF6F4crFf01UtQmNsJfweTAw== - dependencies: - duplexify "^4.1.1" - inherits "^2.0.3" - pump "^3.0.0" - -punycode@^1.4.1: - version "1.4.1" - resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.4.1.tgz#c0d5a63b2718800ad8e1eb0fa5269c84dd41845e" - integrity sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ== - -pupa@^2.0.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/pupa/-/pupa-2.1.1.tgz#f5e8fd4afc2c5d97828faa523549ed8744a20d62" - integrity sha512-l1jNAspIBSFqbT+y+5FosojNpVpF94nlI+wDUpqP9enwOTfHx9f0gh5nB96vl+6yTpsJsypeNrwfzPrKuHB41A== - dependencies: - escape-goat "^2.0.0" - -qs@6.11.0: - version "6.11.0" - resolved "https://registry.yarnpkg.com/qs/-/qs-6.11.0.tgz#fd0d963446f7a65e1367e01abd85429453f0c37a" - integrity sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q== - dependencies: - side-channel "^1.0.4" - -randombytes@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a" - integrity sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ== - dependencies: - safe-buffer "^5.1.0" - -range-parser@^1.2.0, range-parser@~1.2.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031" - integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg== - -raw-body@2.5.1: - version "2.5.1" - resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.5.1.tgz#fe1b1628b181b700215e5fd42389f98b71392857" - integrity sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig== - dependencies: - bytes "3.1.2" - http-errors "2.0.0" - iconv-lite "0.4.24" - unpipe "1.0.0" - -rc@1.2.8, rc@^1.2.8: - version "1.2.8" - resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.8.tgz#cd924bf5200a075b83c188cd6b9e211b7fc0d3ed" - integrity sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw== - dependencies: - deep-extend "^0.6.0" - ini "~1.3.0" - minimist "^1.2.0" - strip-json-comments "~2.0.1" - -readable-stream@1.1.x: - version "1.1.14" - resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-1.1.14.tgz#7cf4c54ef648e3813084c636dd2079e166c081d9" - integrity sha512-+MeVjFf4L44XUkhM1eYbD8fyEsxcV81pqMSR5gblfcLCHfZvbrqy4/qYHE+/R5HoBUT11WV5O08Cr1n3YXkWVQ== - dependencies: - core-util-is "~1.0.0" - inherits "~2.0.1" - isarray "0.0.1" - string_decoder "~0.10.x" - -readable-stream@^2.2.2: - version "2.3.8" - resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.8.tgz#91125e8042bba1b9887f49345f6277027ce8be9b" - integrity sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA== - dependencies: - core-util-is "~1.0.0" - inherits "~2.0.3" - isarray "~1.0.0" - process-nextick-args "~2.0.0" - safe-buffer "~5.1.1" - string_decoder "~1.1.1" - util-deprecate "~1.0.1" - -readable-stream@^3.1.1, readable-stream@^3.6.0: - version "3.6.2" - resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.2.tgz#56a9b36ea965c00c5a93ef31eb111a0f11056967" - integrity sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA== - dependencies: - inherits "^2.0.3" - string_decoder "^1.1.1" - util-deprecate "^1.0.1" - -readable-stream@^4.2.0: - version "4.5.2" - resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-4.5.2.tgz#9e7fc4c45099baeed934bff6eb97ba6cf2729e09" - integrity sha512-yjavECdqeZ3GLXNgRXgeQEdz9fvDDkNKyHnbHRFtOr7/LcfgBcmct7t/ET+HaCTqfh06OzoAxrkN/IfjJBVe+g== - dependencies: - abort-controller "^3.0.0" - buffer "^6.0.3" - events "^3.3.0" - process "^0.11.10" - string_decoder "^1.3.0" - -readdirp@~3.4.0: - version "3.4.0" - resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.4.0.tgz#9fdccdf9e9155805449221ac645e8303ab5b9ada" - integrity sha512-0xe001vZBnJEK+uKcj8qOhyAKPzIT+gStxWr3LCB0DwcXR5NZJ3IaC+yGnHCYzB/S7ov3m3EEbZI2zeNvX+hGQ== - dependencies: - picomatch "^2.2.1" - -readdirp@~3.6.0: - version "3.6.0" - resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.6.0.tgz#74a370bd857116e245b29cc97340cd431a02a6c7" - integrity sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA== - dependencies: - picomatch "^2.2.1" - -regexp.prototype.flags@^1.5.2: - version "1.5.2" - resolved "https://registry.yarnpkg.com/regexp.prototype.flags/-/regexp.prototype.flags-1.5.2.tgz#138f644a3350f981a858c44f6bb1a61ff59be334" - integrity sha512-NcDiDkTLuPR+++OCKB0nWafEmhg/Da8aUPLPMQbK+bxKKCm1/S5he+AqYa4PlMCVBalb4/yxIRub6qkEx5yJbw== - dependencies: - call-bind "^1.0.6" - define-properties "^1.2.1" - es-errors "^1.3.0" - set-function-name "^2.0.1" - -registry-auth-token@^4.0.0: - version "4.2.2" - resolved "https://registry.yarnpkg.com/registry-auth-token/-/registry-auth-token-4.2.2.tgz#f02d49c3668884612ca031419491a13539e21fac" - integrity sha512-PC5ZysNb42zpFME6D/XlIgtNGdTl8bBOCw90xQLVMpzuuubJKYDWFAEuUNc+Cn8Z8724tg2SDhDRrkVEsqfDMg== - dependencies: - rc "1.2.8" - -registry-url@^5.0.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/registry-url/-/registry-url-5.1.0.tgz#e98334b50d5434b81136b44ec638d9c2009c5009" - integrity sha512-8acYXXTI0AkQv6RAOjE3vOaIXZkT9wo4LOFbBKYQEEnnMNBpKqdUrI6S4NT0KPIo/WVvJ5tE/X5LF/TQUf0ekw== - dependencies: - rc "^1.2.8" - -require-directory@^2.1.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" - integrity sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q== - -require-main-filename@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/require-main-filename/-/require-main-filename-2.0.0.tgz#d0b329ecc7cc0f61649f62215be69af54aa8989b" - integrity sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg== - -resolve@^1.22.1: - version "1.22.8" - resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.8.tgz#b6c87a9f2aa06dfab52e3d70ac8cde321fa5a48d" - integrity sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw== - dependencies: - is-core-module "^2.13.0" - path-parse "^1.0.7" - supports-preserve-symlinks-flag "^1.0.0" - -responselike@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/responselike/-/responselike-1.0.2.tgz#918720ef3b631c5642be068f15ade5a46f4ba1e7" - integrity sha512-/Fpe5guzJk1gPqdJLJR5u7eG/gNY4nImjbRDaVWVMRhne55TCmj2i9Q+54PBRfatRC8v/rIiv9BN0pMd9OV5EQ== - dependencies: - lowercase-keys "^1.0.0" - -retry-as-promised@^7.0.4: - version "7.0.4" - resolved "https://registry.yarnpkg.com/retry-as-promised/-/retry-as-promised-7.0.4.tgz#9df73adaeea08cb2948b9d34990549dc13d800a2" - integrity sha512-XgmCoxKWkDofwH8WddD0w85ZfqYz+ZHlr5yo+3YUCfycWawU56T5ckWXsScsj5B8tqUcIG67DxXByo3VUgiAdA== - -retry-request@^4.2.2: - version "4.2.2" - resolved "https://registry.yarnpkg.com/retry-request/-/retry-request-4.2.2.tgz#b7d82210b6d2651ed249ba3497f07ea602f1a903" - integrity sha512-xA93uxUD/rogV7BV59agW/JHPGXeREMWiZc9jhcwY4YdZ7QOtC7qbomYg0n4wyk2lJhggjvKvhNX8wln/Aldhg== - dependencies: - debug "^4.1.1" - extend "^3.0.2" - -retry@0.13.1: - version "0.13.1" - resolved "https://registry.yarnpkg.com/retry/-/retry-0.13.1.tgz#185b1587acf67919d63b357349e03537b2484658" - integrity sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg== - -rimraf@^3.0.2: - version "3.0.2" - resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a" - integrity sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA== - dependencies: - glob "^7.1.3" - -safe-array-concat@^1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/safe-array-concat/-/safe-array-concat-1.1.2.tgz#81d77ee0c4e8b863635227c721278dd524c20edb" - integrity sha512-vj6RsCsWBCf19jIeHEfkRMw8DPiBb+DMXklQ/1SGDHOMlHdPUkZXFQ2YdplS23zESTijAcurb1aSgJA3AgMu1Q== - dependencies: - call-bind "^1.0.7" - get-intrinsic "^1.2.4" - has-symbols "^1.0.3" - isarray "^2.0.5" - -safe-buffer@5.2.1, safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@~5.2.0: - version "5.2.1" - resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" - integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== - -safe-buffer@~5.1.0, safe-buffer@~5.1.1: - version "5.1.2" - resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" - integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== - -safe-regex-test@^1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/safe-regex-test/-/safe-regex-test-1.0.3.tgz#a5b4c0f06e0ab50ea2c395c14d8371232924c377" - integrity sha512-CdASjNJPvRa7roO6Ra/gLYBTzYzzPyyBXxIMdGW3USQLyjWEls2RgW5UBTXaQVp+OrpeCK3bLem8smtmheoRuw== - dependencies: - call-bind "^1.0.6" - es-errors "^1.3.0" - is-regex "^1.1.4" - -"safer-buffer@>= 2.1.2 < 3", "safer-buffer@>= 2.1.2 < 3.0.0": - version "2.1.2" - resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" - integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== - -semver-diff@^3.1.1: - version "3.1.1" - resolved "https://registry.yarnpkg.com/semver-diff/-/semver-diff-3.1.1.tgz#05f77ce59f325e00e2706afd67bb506ddb1ca32b" - integrity sha512-GX0Ix/CJcHyB8c4ykpHGIAvLyOwOobtM/8d+TQkAd81/bEjgPHrfba41Vpesr7jX/t8Uh+R3EX9eAS5be+jQYg== - dependencies: - semver "^6.3.0" - -semver@^5.6.0, semver@^5.7.1: - version "5.7.2" - resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.2.tgz#48d55db737c3287cd4835e17fa13feace1c41ef8" - integrity sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g== - -semver@^6.0.0, semver@^6.2.0, semver@^6.3.0: - version "6.3.1" - resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" - integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== - -semver@^7.3.5, semver@^7.5.3, semver@^7.5.4: - version "7.6.3" - resolved "https://registry.yarnpkg.com/semver/-/semver-7.6.3.tgz#980f7b5550bc175fb4dc09403085627f9eb33143" - integrity sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A== - -send@0.18.0: - version "0.18.0" - resolved "https://registry.yarnpkg.com/send/-/send-0.18.0.tgz#670167cc654b05f5aa4a767f9113bb371bc706be" - integrity sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg== - dependencies: - debug "2.6.9" - depd "2.0.0" - destroy "1.2.0" - encodeurl "~1.0.2" - escape-html "~1.0.3" - etag "~1.8.1" - fresh "0.5.2" - http-errors "2.0.0" - mime "1.6.0" - ms "2.1.3" - on-finished "2.4.1" - range-parser "~1.2.1" - statuses "2.0.1" - -seq-queue@^0.0.5: - version "0.0.5" - resolved "https://registry.yarnpkg.com/seq-queue/-/seq-queue-0.0.5.tgz#d56812e1c017a6e4e7c3e3a37a1da6d78dd3c93e" - integrity sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q== - -sequelize-cli@6.6.2: - version "6.6.2" - resolved "https://registry.yarnpkg.com/sequelize-cli/-/sequelize-cli-6.6.2.tgz#8d838b25c988cf136914cdc3843e19d88c3dcb67" - integrity sha512-V8Oh+XMz2+uquLZltZES6MVAD+yEnmMfwfn+gpXcDiwE3jyQygLt4xoI0zG8gKt6cRcs84hsKnXAKDQjG/JAgg== - dependencies: - cli-color "^2.0.3" - fs-extra "^9.1.0" - js-beautify "^1.14.5" - lodash "^4.17.21" - resolve "^1.22.1" - umzug "^2.3.0" - yargs "^16.2.0" - -sequelize-json-schema@^2.1.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/sequelize-json-schema/-/sequelize-json-schema-2.1.1.tgz#a82d3813925e81485d76ce291f4ff5c8cb2ae492" - integrity sha512-yCGaHnmQQeL6MQ/fOxhkR5C2aOGZyTD6OrgjP4yw1rbuujuIUVdzWN3AsC6r6AvlGZ3EUBBbCJHKl8OIFFES4Q== - -sequelize-pool@^7.1.0: - version "7.1.0" - resolved "https://registry.yarnpkg.com/sequelize-pool/-/sequelize-pool-7.1.0.tgz#210b391af4002762f823188fd6ecfc7413020768" - integrity sha512-G9c0qlIWQSK29pR/5U2JF5dDQeqqHRragoyahj/Nx4KOOQ3CPPfzxnfqFPCSB7x5UgjOgnZ61nSxz+fjDpRlJg== - -sequelize@6.35.2: - version "6.35.2" - resolved "https://registry.yarnpkg.com/sequelize/-/sequelize-6.35.2.tgz#9276d24055a9a07bd6812c89ab402659f5853e70" - integrity sha512-EdzLaw2kK4/aOnWQ7ed/qh3B6/g+1DvmeXr66RwbcqSm/+QRS9X0LDI5INBibsy4eNJHWIRPo3+QK0zL+IPBHg== - dependencies: - "@types/debug" "^4.1.8" - "@types/validator" "^13.7.17" - debug "^4.3.4" - dottie "^2.0.6" - inflection "^1.13.4" - lodash "^4.17.21" - moment "^2.29.4" - moment-timezone "^0.5.43" - pg-connection-string "^2.6.1" - retry-as-promised "^7.0.4" - semver "^7.5.4" - sequelize-pool "^7.1.0" - toposort-class "^1.0.1" - uuid "^8.3.2" - validator "^13.9.0" - wkx "^0.5.0" - -serialize-javascript@4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-4.0.0.tgz#b525e1238489a5ecfc42afacc3fe99e666f4b1aa" - integrity sha512-GaNA54380uFefWghODBWEGisLZFj00nS5ACs6yHa9nLqlLpVLO8ChDGeKRjZnV4Nh4n0Qi7nhYZD/9fCPzEqkw== - dependencies: - randombytes "^2.1.0" - -serve-static@1.15.0: - version "1.15.0" - resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-1.15.0.tgz#faaef08cffe0a1a62f60cad0c4e513cff0ac9540" - integrity sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g== - dependencies: - encodeurl "~1.0.2" - escape-html "~1.0.3" - parseurl "~1.3.3" - send "0.18.0" - -set-blocking@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7" - integrity sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw== - -set-function-length@^1.2.1: - version "1.2.2" - resolved "https://registry.yarnpkg.com/set-function-length/-/set-function-length-1.2.2.tgz#aac72314198eaed975cf77b2c3b6b880695e5449" - integrity sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg== - dependencies: - define-data-property "^1.1.4" - es-errors "^1.3.0" - function-bind "^1.1.2" - get-intrinsic "^1.2.4" - gopd "^1.0.1" - has-property-descriptors "^1.0.2" - -set-function-name@^2.0.1: - version "2.0.2" - resolved "https://registry.yarnpkg.com/set-function-name/-/set-function-name-2.0.2.tgz#16a705c5a0dc2f5e638ca96d8a8cd4e1c2b90985" - integrity sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ== - dependencies: - define-data-property "^1.1.4" - es-errors "^1.3.0" - functions-have-names "^1.2.3" - has-property-descriptors "^1.0.2" - -setprototypeof@1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424" - integrity sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw== - -shebang-command@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea" - integrity sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA== - dependencies: - shebang-regex "^3.0.0" - -shebang-regex@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172" - integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== - -side-channel@^1.0.4: - version "1.0.6" - resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.6.tgz#abd25fb7cd24baf45466406b1096b7831c9215f2" - integrity sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA== - dependencies: - call-bind "^1.0.7" - es-errors "^1.3.0" - get-intrinsic "^1.2.4" - object-inspect "^1.13.1" - -signal-exit@^3.0.0, signal-exit@^3.0.2: - version "3.0.7" - resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9" - integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ== - -signal-exit@^4.0.1: - version "4.1.0" - resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-4.1.0.tgz#952188c1cbd546070e2dd20d0f41c0ae0530cb04" - integrity sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw== - -split2@^4.1.0: - version "4.2.0" - resolved "https://registry.yarnpkg.com/split2/-/split2-4.2.0.tgz#c9c5920904d148bab0b9f67145f245a86aadbfa4" - integrity sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg== - -sprintf-js@^1.1.3: - version "1.1.3" - resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.1.3.tgz#4914b903a2f8b685d17fdf78a70e917e872e444a" - integrity sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA== - -sprintf-js@~1.0.2: - version "1.0.3" - resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" - integrity sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g== - -sqlite@4.0.15: - version "4.0.15" - resolved "https://registry.yarnpkg.com/sqlite/-/sqlite-4.0.15.tgz#071e0577afb327fbd74a75354ea15964378392e3" - integrity sha512-irPPTrbVoDvwzRGpe0v8vxpNwMl+q0tXQzffQTcCUnaJzQFO0hfLLvFwGDKxd6vYBuvEr3uvPkObVoGOvVsmzA== - -sqlstring@^2.3.2: - version "2.3.3" - resolved "https://registry.yarnpkg.com/sqlstring/-/sqlstring-2.3.3.tgz#2ddc21f03bce2c387ed60680e739922c65751d0c" - integrity sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg== - -statuses@2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.1.tgz#55cb000ccf1d48728bd23c685a063998cf1a1b63" - integrity sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ== - -stop-iteration-iterator@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/stop-iteration-iterator/-/stop-iteration-iterator-1.0.0.tgz#6a60be0b4ee757d1ed5254858ec66b10c49285e4" - integrity sha512-iCGQj+0l0HOdZ2AEeBADlsRC+vsnDsZsbdSiH1yNSjcfKM7fdpCMfqAL/dwF5BLiw/XhRft/Wax6zQbhq2BcjQ== - dependencies: - internal-slot "^1.0.4" - -stoppable@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/stoppable/-/stoppable-1.1.0.tgz#32da568e83ea488b08e4d7ea2c3bcc9d75015d5b" - integrity sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw== - -stream-events@^1.0.4, stream-events@^1.0.5: - version "1.0.5" - resolved "https://registry.yarnpkg.com/stream-events/-/stream-events-1.0.5.tgz#bbc898ec4df33a4902d892333d47da9bf1c406d5" - integrity sha512-E1GUzBSgvct8Jsb3v2X15pjzN1tYebtbLaMg+eBOUOAxgbLoSbT2NS91ckc5lJD1KfLjId+jXJRgo0qnV5Nerg== - dependencies: - stubs "^3.0.0" - -stream-shift@^1.0.2: - version "1.0.3" - resolved "https://registry.yarnpkg.com/stream-shift/-/stream-shift-1.0.3.tgz#85b8fab4d71010fc3ba8772e8046cc49b8a3864b" - integrity sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ== - -streamsearch@0.1.2: - version "0.1.2" - resolved "https://registry.yarnpkg.com/streamsearch/-/streamsearch-0.1.2.tgz#808b9d0e56fc273d809ba57338e929919a1a9f1a" - integrity sha512-jos8u++JKm0ARcSUTAZXOVC0mSox7Bhn6sBgty73P1f3JGf7yG2clTbBNHUdde/kdvP2FESam+vM6l8jBrNxHA== - -"string-width-cjs@npm:string-width@^4.2.0": - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - -"string-width@^1.0.2 || 2": - version "2.1.1" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-2.1.1.tgz#ab93f27a8dc13d28cac815c462143a6d9012ae9e" - integrity sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw== - dependencies: - is-fullwidth-code-point "^2.0.0" - strip-ansi "^4.0.0" - -"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - -string-width@^3.0.0, string-width@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-3.1.0.tgz#22767be21b62af1081574306f69ac51b62203961" - integrity sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w== - dependencies: - emoji-regex "^7.0.1" - is-fullwidth-code-point "^2.0.0" - strip-ansi "^5.1.0" - -string-width@^5.0.1, string-width@^5.1.2: - version "5.1.2" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794" - integrity sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA== - dependencies: - eastasianwidth "^0.2.0" - emoji-regex "^9.2.2" - strip-ansi "^7.0.1" - -string.prototype.trim@^1.2.9: - version "1.2.9" - resolved "https://registry.yarnpkg.com/string.prototype.trim/-/string.prototype.trim-1.2.9.tgz#b6fa326d72d2c78b6df02f7759c73f8f6274faa4" - integrity sha512-klHuCNxiMZ8MlsOihJhJEBJAiMVqU3Z2nEXWfWnIqjN0gEFS9J9+IxKozWWtQGcgoa1WUZzLjKPTr4ZHNFTFxw== - dependencies: - call-bind "^1.0.7" - define-properties "^1.2.1" - es-abstract "^1.23.0" - es-object-atoms "^1.0.0" - -string.prototype.trimend@^1.0.8: - version "1.0.8" - resolved "https://registry.yarnpkg.com/string.prototype.trimend/-/string.prototype.trimend-1.0.8.tgz#3651b8513719e8a9f48de7f2f77640b26652b229" - integrity sha512-p73uL5VCHCO2BZZ6krwwQE3kCzM7NKmis8S//xEC6fQonchbum4eP6kR4DLEjQFO3Wnj3Fuo8NM0kOSjVdHjZQ== - dependencies: - call-bind "^1.0.7" - define-properties "^1.2.1" - es-object-atoms "^1.0.0" - -string.prototype.trimstart@^1.0.8: - version "1.0.8" - resolved "https://registry.yarnpkg.com/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz#7ee834dda8c7c17eff3118472bb35bfedaa34dde" - integrity sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg== - dependencies: - call-bind "^1.0.7" - define-properties "^1.2.1" - es-object-atoms "^1.0.0" - -string_decoder@^1.1.1, string_decoder@^1.3.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e" - integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA== - dependencies: - safe-buffer "~5.2.0" - -string_decoder@~0.10.x: - version "0.10.31" - resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-0.10.31.tgz#62e203bc41766c6c28c9fc84301dab1c5310fa94" - integrity sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ== - -string_decoder@~1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.1.1.tgz#9cf1611ba62685d7030ae9e4ba34149c3af03fc8" - integrity sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg== - dependencies: - safe-buffer "~5.1.0" - -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - -strip-ansi@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-4.0.0.tgz#a8479022eb1ac368a871389b635262c505ee368f" - integrity sha512-4XaJ2zQdCzROZDivEVIDPkcQn8LMFSa8kj8Gxb/Lnwzv9A8VctNZ+lfivC/sV3ivW8ElJTERXZoPBRrZKkNKow== - dependencies: - ansi-regex "^3.0.0" - -strip-ansi@^5.0.0, strip-ansi@^5.1.0, strip-ansi@^5.2.0: - version "5.2.0" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-5.2.0.tgz#8c9a536feb6afc962bdfa5b104a5091c1ad9c0ae" - integrity sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA== - dependencies: - ansi-regex "^4.1.0" - -strip-ansi@^6.0.0, strip-ansi@^6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - -strip-ansi@^7.0.1: - version "7.1.0" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45" - integrity sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ== - dependencies: - ansi-regex "^6.0.1" - -strip-json-comments@3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.0.1.tgz#85713975a91fb87bf1b305cca77395e40d2a64a7" - integrity sha512-VTyMAUfdm047mwKl+u79WIdrZxtFtn+nBxHeb844XBQ9uMNTuTHdx2hc5RiAJYqwTj3wc/xe5HLSdJSkJ+WfZw== - -strip-json-comments@~2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a" - integrity sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ== - -stubs@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/stubs/-/stubs-3.0.0.tgz#e8d2ba1fa9c90570303c030b6900f7d5f89abe5b" - integrity sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw== - -supports-color@7.1.0: - version "7.1.0" - resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.1.0.tgz#68e32591df73e25ad1c4b49108a2ec507962bfd1" - integrity sha512-oRSIpR8pxT1Wr2FquTNnGet79b3BWljqOuoW/h4oBhxJ/HUbX5nX6JSruTkvXDCFMwDPvsaTTbvMLKZWSy0R5g== - dependencies: - has-flag "^4.0.0" - -supports-color@^5.5.0: - version "5.5.0" - resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f" - integrity sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow== - dependencies: - has-flag "^3.0.0" - -supports-color@^7.1.0: - version "7.2.0" - resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da" - integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw== - dependencies: - has-flag "^4.0.0" - -supports-preserve-symlinks-flag@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09" - integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== - -swagger-jsdoc@^6.2.8: - version "6.2.8" - resolved "https://registry.yarnpkg.com/swagger-jsdoc/-/swagger-jsdoc-6.2.8.tgz#6d33d9fb07ff4a7c1564379c52c08989ec7d0256" - integrity sha512-VPvil1+JRpmJ55CgAtn8DIcpBs0bL5L3q5bVQvF4tAW/k/9JYSj7dCpaYCAv5rufe0vcCbBRQXGvzpkWjvLklQ== - dependencies: - commander "6.2.0" - doctrine "3.0.0" - glob "7.1.6" - lodash.mergewith "^4.6.2" - swagger-parser "^10.0.3" - yaml "2.0.0-1" - -swagger-parser@^10.0.3: - version "10.0.3" - resolved "https://registry.yarnpkg.com/swagger-parser/-/swagger-parser-10.0.3.tgz#04cb01c18c3ac192b41161c77f81e79309135d03" - integrity sha512-nF7oMeL4KypldrQhac8RyHerJeGPD1p2xDh900GPvc+Nk7nWP6jX2FcC7WmkinMoAmoO774+AFXcWsW8gMWEIg== - dependencies: - "@apidevtools/swagger-parser" "10.0.3" - -swagger-ui-dist@>=5.0.0: - version "5.17.14" - resolved "https://registry.yarnpkg.com/swagger-ui-dist/-/swagger-ui-dist-5.17.14.tgz#e2c222e5bf9e15ccf80ec4bc08b4aaac09792fd6" - integrity sha512-CVbSfaLpstV65OnSjbXfVd6Sta3q3F7Cj/yYuvHMp1P90LztOLs6PfUnKEVAeiIVQt9u2SaPwv0LiH/OyMjHRw== - -swagger-ui-express@^5.0.0: - version "5.0.1" - resolved "https://registry.yarnpkg.com/swagger-ui-express/-/swagger-ui-express-5.0.1.tgz#fb8c1b781d2793a6bd2f8a205a3f4bd6fa020dd8" - integrity sha512-SrNU3RiBGTLLmFU8GIJdOdanJTl4TOmT27tt3bWWHppqYmAZ6IDuEuBvMU6nZq0zLEe6b/1rACXCgLZqO6ZfrA== - dependencies: - swagger-ui-dist ">=5.0.0" - -tar@^6.1.11: - version "6.2.1" - resolved "https://registry.yarnpkg.com/tar/-/tar-6.2.1.tgz#717549c541bc3c2af15751bea94b1dd068d4b03a" - integrity sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A== - dependencies: - chownr "^2.0.0" - fs-minipass "^2.0.0" - minipass "^5.0.0" - minizlib "^2.1.1" - mkdirp "^1.0.3" - yallist "^4.0.0" - -tedious@^18.2.4: - version "18.2.4" - resolved "https://registry.yarnpkg.com/tedious/-/tedious-18.2.4.tgz#c33986f2561b4fde92bb9df70f44ae1a14f71b46" - integrity sha512-+6Nzn/aURTQ+8OxLAJ8fKK5Fbb84HRTI3bHiAC3ZzBKrBg9BHtcHxjmlIni5Zn46hzKiZ5WrDMSwDH8oIYjV8w== - dependencies: - "@azure/identity" "^4.2.1" - "@azure/keyvault-keys" "^4.4.0" - "@js-joda/core" "^5.6.1" - "@types/node" ">=18" - bl "^6.0.11" - iconv-lite "^0.6.3" - js-md4 "^0.3.2" - native-duplexpair "^1.0.0" - sprintf-js "^1.1.3" - -teeny-request@^7.1.3: - version "7.2.0" - resolved "https://registry.yarnpkg.com/teeny-request/-/teeny-request-7.2.0.tgz#41347ece068f08d741e7b86df38a4498208b2633" - integrity sha512-SyY0pek1zWsi0LRVAALem+avzMLc33MKW/JLLakdP4s9+D7+jHcy5x6P+h94g2QNZsAqQNfX5lsbd3WSeJXrrw== - dependencies: - http-proxy-agent "^5.0.0" - https-proxy-agent "^5.0.0" - node-fetch "^2.6.1" - stream-events "^1.0.5" - uuid "^8.0.0" - -term-size@^2.1.0: - version "2.2.1" - resolved "https://registry.yarnpkg.com/term-size/-/term-size-2.2.1.tgz#2a6a54840432c2fb6320fea0f415531e90189f54" - integrity sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg== - -timers-ext@^0.1.7: - version "0.1.8" - resolved "https://registry.yarnpkg.com/timers-ext/-/timers-ext-0.1.8.tgz#b4e442f10b7624a29dd2aa42c295e257150cf16c" - integrity sha512-wFH7+SEAcKfJpfLPkrgMPvvwnEtj8W4IurvEyrKsDleXnKLCDw71w8jltvfLa8Rm4qQxxT4jmDBYbJG/z7qoww== - dependencies: - es5-ext "^0.10.64" - next-tick "^1.1.0" - -to-readable-stream@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/to-readable-stream/-/to-readable-stream-1.0.0.tgz#ce0aa0c2f3df6adf852efb404a783e77c0475771" - integrity sha512-Iq25XBt6zD5npPhlLVXGFN3/gyR2/qODcKNNyTMd4vbm39HUaOiAM4PMq0eMVC/Tkxz+Zjdsc55g9yyz+Yq00Q== - -to-regex-range@^5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4" - integrity sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ== - dependencies: - is-number "^7.0.0" - -toidentifier@1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35" - integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA== - -toposort-class@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/toposort-class/-/toposort-class-1.0.1.tgz#7ffd1f78c8be28c3ba45cd4e1a3f5ee193bd9988" - integrity sha512-OsLcGGbYF3rMjPUf8oKktyvCiUxSbqMMS39m33MAjLTC1DVIH6x3WSt63/M77ihI09+Sdfk1AXvfhCEeUmC7mg== - -touch@^3.1.0: - version "3.1.1" - resolved "https://registry.yarnpkg.com/touch/-/touch-3.1.1.tgz#097a23d7b161476435e5c1344a95c0f75b4a5694" - integrity sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA== - -tr46@~0.0.3: - version "0.0.3" - resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a" - integrity sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw== - -tslib@^2.2.0, tslib@^2.6.2: - version "2.6.3" - resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.3.tgz#0438f810ad7a9edcde7a241c3d80db693c8cbfe0" - integrity sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ== - -type-fest@^0.8.1: - version "0.8.1" - resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.8.1.tgz#09e249ebde851d3b1e48d27c105444667f17b83d" - integrity sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA== - -type-is@^1.6.18, type-is@^1.6.4, type-is@~1.6.18: - version "1.6.18" - resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131" - integrity sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g== - dependencies: - media-typer "0.3.0" - mime-types "~2.1.24" - -type@^2.7.2: - version "2.7.3" - resolved "https://registry.yarnpkg.com/type/-/type-2.7.3.tgz#436981652129285cc3ba94f392886c2637ea0486" - integrity sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ== - -typed-array-buffer@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/typed-array-buffer/-/typed-array-buffer-1.0.2.tgz#1867c5d83b20fcb5ccf32649e5e2fc7424474ff3" - integrity sha512-gEymJYKZtKXzzBzM4jqa9w6Q1Jjm7x2d+sh19AdsD4wqnMPDYyvwpsIc2Q/835kHuo3BEQ7CjelGhfTsoBb2MQ== - dependencies: - call-bind "^1.0.7" - es-errors "^1.3.0" - is-typed-array "^1.1.13" - -typed-array-byte-length@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/typed-array-byte-length/-/typed-array-byte-length-1.0.1.tgz#d92972d3cff99a3fa2e765a28fcdc0f1d89dec67" - integrity sha512-3iMJ9q0ao7WE9tWcaYKIptkNBuOIcZCCT0d4MRvuuH88fEoEH62IuQe0OtraD3ebQEoTRk8XCBoknUNc1Y67pw== - dependencies: - call-bind "^1.0.7" - for-each "^0.3.3" - gopd "^1.0.1" - has-proto "^1.0.3" - is-typed-array "^1.1.13" - -typed-array-byte-offset@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/typed-array-byte-offset/-/typed-array-byte-offset-1.0.2.tgz#f9ec1acb9259f395093e4567eb3c28a580d02063" - integrity sha512-Ous0vodHa56FviZucS2E63zkgtgrACj7omjwd/8lTEMEPFFyjfixMZ1ZXenpgCFBBt4EC1J2XsyVS2gkG0eTFA== - dependencies: - available-typed-arrays "^1.0.7" - call-bind "^1.0.7" - for-each "^0.3.3" - gopd "^1.0.1" - has-proto "^1.0.3" - is-typed-array "^1.1.13" - -typed-array-length@^1.0.6: - version "1.0.6" - resolved "https://registry.yarnpkg.com/typed-array-length/-/typed-array-length-1.0.6.tgz#57155207c76e64a3457482dfdc1c9d1d3c4c73a3" - integrity sha512-/OxDN6OtAk5KBpGb28T+HZc2M+ADtvRxXrKKbUwtsLgdoxgX13hyy7ek6bFRl5+aBs2yZzB0c4CnQfAtVypW/g== - dependencies: - call-bind "^1.0.7" - for-each "^0.3.3" - gopd "^1.0.1" - has-proto "^1.0.3" - is-typed-array "^1.1.13" - possible-typed-array-names "^1.0.0" - -typedarray-to-buffer@^3.1.5: - version "3.1.5" - resolved "https://registry.yarnpkg.com/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz#a97ee7a9ff42691b9f783ff1bc5112fe3fca9080" - integrity sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q== - dependencies: - is-typedarray "^1.0.0" - -typedarray@^0.0.6: - version "0.0.6" - resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" - integrity sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA== - -uid2@0.0.x: - version "0.0.4" - resolved "https://registry.yarnpkg.com/uid2/-/uid2-0.0.4.tgz#033f3b1d5d32505f5ce5f888b9f3b667123c0a44" - integrity sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA== - -umzug@^2.3.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/umzug/-/umzug-2.3.0.tgz#0ef42b62df54e216b05dcaf627830a6a8b84a184" - integrity sha512-Z274K+e8goZK8QJxmbRPhl89HPO1K+ORFtm6rySPhFKfKc5GHhqdzD0SGhSWHkzoXasqJuItdhorSvY7/Cgflw== - dependencies: - bluebird "^3.7.2" - -unbox-primitive@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/unbox-primitive/-/unbox-primitive-1.0.2.tgz#29032021057d5e6cdbd08c5129c226dff8ed6f9e" - integrity sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw== - dependencies: - call-bind "^1.0.2" - has-bigints "^1.0.2" - has-symbols "^1.0.3" - which-boxed-primitive "^1.0.2" - -undefsafe@^2.0.3: - version "2.0.5" - resolved "https://registry.yarnpkg.com/undefsafe/-/undefsafe-2.0.5.tgz#38733b9327bdcd226db889fb723a6efd162e6e2c" - integrity sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA== - -underscore@^1.13.1: - version "1.13.6" - resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.13.6.tgz#04786a1f589dc6c09f761fc5f45b89e935136441" - integrity sha512-+A5Sja4HP1M08MaXya7p5LvjuM7K6q/2EaC0+iovj/wOcMsTzMvDFbasi/oSapiwOlt252IqsKqPjCl7huKS0A== - -undici-types@~5.26.4: - version "5.26.5" - resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-5.26.5.tgz#bcd539893d00b56e964fd2657a4866b221a65617" - integrity sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA== - -unique-string@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/unique-string/-/unique-string-2.0.0.tgz#39c6451f81afb2749de2b233e3f7c5e8843bd89d" - integrity sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg== - dependencies: - crypto-random-string "^2.0.0" - -universalify@^2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/universalify/-/universalify-2.0.1.tgz#168efc2180964e6386d061e094df61afe239b18d" - integrity sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw== - -unpipe@1.0.0, unpipe@~1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec" - integrity sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ== - -update-notifier@^4.1.0: - version "4.1.3" - resolved "https://registry.yarnpkg.com/update-notifier/-/update-notifier-4.1.3.tgz#be86ee13e8ce48fb50043ff72057b5bd598e1ea3" - integrity sha512-Yld6Z0RyCYGB6ckIjffGOSOmHXj1gMeE7aROz4MG+XMkmixBX4jUngrGXNYz7wPKBmtoD4MnBa2Anu7RSKht/A== - dependencies: - boxen "^4.2.0" - chalk "^3.0.0" - configstore "^5.0.1" - has-yarn "^2.1.0" - import-lazy "^2.1.0" - is-ci "^2.0.0" - is-installed-globally "^0.3.1" - is-npm "^4.0.0" - is-yarn-global "^0.3.0" - latest-version "^5.0.0" - pupa "^2.0.1" - semver-diff "^3.1.1" - xdg-basedir "^4.0.0" - -url-parse-lax@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/url-parse-lax/-/url-parse-lax-3.0.0.tgz#16b5cafc07dbe3676c1b1999177823d6503acb0c" - integrity sha512-NjFKA0DidqPa5ciFcSrXnAltTtzz84ogy+NebPvfEgAck0+TNg4UJ4IN+fB7zRZfbgUf0syOo9MDxFkDSMuFaQ== - dependencies: - prepend-http "^2.0.0" - -util-deprecate@^1.0.1, util-deprecate@~1.0.1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" - integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw== - -utils-merge@1.0.1, utils-merge@1.x.x, utils-merge@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713" - integrity sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA== - -uuid@^8.0.0, uuid@^8.3.0, uuid@^8.3.2: - version "8.3.2" - resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" - integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== - -validator@^13.7.0, validator@^13.9.0: - version "13.12.0" - resolved "https://registry.yarnpkg.com/validator/-/validator-13.12.0.tgz#7d78e76ba85504da3fee4fd1922b385914d4b35f" - integrity sha512-c1Q0mCiPlgdTVVVIJIrBuxNicYE+t/7oKeI9MWLj3fh/uq2Pxh/3eeWbVZ4OcGW1TUf53At0njHw5SMdA3tmMg== - -vary@^1, vary@~1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" - integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg== - -webidl-conversions@^3.0.0: - version "3.0.1" - resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871" - integrity sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ== - -whatwg-url@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-5.0.0.tgz#966454e8765462e37644d3626f6742ce8b70965d" - integrity sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw== - dependencies: - tr46 "~0.0.3" - webidl-conversions "^3.0.0" - -which-boxed-primitive@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz#13757bc89b209b049fe5d86430e21cf40a89a8e6" - integrity sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg== - dependencies: - is-bigint "^1.0.1" - is-boolean-object "^1.1.0" - is-number-object "^1.0.4" - is-string "^1.0.5" - is-symbol "^1.0.3" - -which-module@^2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/which-module/-/which-module-2.0.1.tgz#776b1fe35d90aebe99e8ac15eb24093389a4a409" - integrity sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ== - -which-typed-array@^1.1.14, which-typed-array@^1.1.15: - version "1.1.15" - resolved "https://registry.yarnpkg.com/which-typed-array/-/which-typed-array-1.1.15.tgz#264859e9b11a649b388bfaaf4f767df1f779b38d" - integrity sha512-oV0jmFtUky6CXfkqehVvBP/LSWJ2sy4vWMioiENyJLePrBO/yKyV9OyJySfAKosh+RYkIl5zJCNZ8/4JncrpdA== - dependencies: - available-typed-arrays "^1.0.7" - call-bind "^1.0.7" - for-each "^0.3.3" - gopd "^1.0.1" - has-tostringtag "^1.0.2" - -which@2.0.2, which@^2.0.1: - version "2.0.2" - resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1" - integrity sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA== - dependencies: - isexe "^2.0.0" - -wide-align@1.1.3: - version "1.1.3" - resolved "https://registry.yarnpkg.com/wide-align/-/wide-align-1.1.3.tgz#ae074e6bdc0c14a431e804e624549c633b000457" - integrity sha512-QGkOQc8XL6Bt5PwnsExKBPuMKBxnGxWWW3fU55Xt4feHozMUhdUMaBCk290qpm/wG5u/RSKzwdAC4i51YigihA== - dependencies: - string-width "^1.0.2 || 2" - -wide-align@^1.1.2: - version "1.1.5" - resolved "https://registry.yarnpkg.com/wide-align/-/wide-align-1.1.5.tgz#df1d4c206854369ecf3c9a4898f1b23fbd9d15d3" - integrity sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg== - dependencies: - string-width "^1.0.2 || 2 || 3 || 4" - -widest-line@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/widest-line/-/widest-line-3.1.0.tgz#8292333bbf66cb45ff0de1603b136b7ae1496eca" - integrity sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg== - dependencies: - string-width "^4.0.0" - -wkx@^0.5.0: - version "0.5.0" - resolved "https://registry.yarnpkg.com/wkx/-/wkx-0.5.0.tgz#c6c37019acf40e517cc6b94657a25a3d4aa33e8c" - integrity sha512-Xng/d4Ichh8uN4l0FToV/258EjMGU9MGcA0HV2d9B/ZpZB3lqQm7nkOdZdm5GhKtLLhAE7PiVQwN4eN+2YJJUg== - dependencies: - "@types/node" "*" - -workerpool@6.0.0: - version "6.0.0" - resolved "https://registry.yarnpkg.com/workerpool/-/workerpool-6.0.0.tgz#85aad67fa1a2c8ef9386a1b43539900f61d03d58" - integrity sha512-fU2OcNA/GVAJLLyKUoHkAgIhKb0JoCpSjLC/G2vYKxUjVmQwGbRVeoPJ1a8U4pnVofz4AQV5Y/NEw8oKqxEBtA== - -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": - version "7.0.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - -wrap-ansi@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-5.1.0.tgz#1fd1f67235d5b6d0fee781056001bfb694c03b09" - integrity sha512-QC1/iN/2/RPVJ5jYK8BGttj5z83LmSKmvbvrXPNCLZSEb32KKVDJDl/MOt2N01qU2H/FkzEa9PKto1BqDjtd7Q== - dependencies: - ansi-styles "^3.2.0" - string-width "^3.0.0" - strip-ansi "^5.0.0" - -wrap-ansi@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - -wrap-ansi@^8.1.0: - version "8.1.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214" - integrity sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ== - dependencies: - ansi-styles "^6.1.0" - string-width "^5.0.1" - strip-ansi "^7.0.1" - -wrappy@1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" - integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ== - -write-file-atomic@^3.0.0: - version "3.0.3" - resolved "https://registry.yarnpkg.com/write-file-atomic/-/write-file-atomic-3.0.3.tgz#56bd5c5a5c70481cd19c571bd39ab965a5de56e8" - integrity sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q== - dependencies: - imurmurhash "^0.1.4" - is-typedarray "^1.0.0" - signal-exit "^3.0.2" - typedarray-to-buffer "^3.1.5" - -xdg-basedir@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/xdg-basedir/-/xdg-basedir-4.0.0.tgz#4bc8d9984403696225ef83a1573cbbcb4e79db13" - integrity sha512-PSNhEJDejZYV7h50BohL09Er9VaIefr2LMAf3OEmpCkjOi34eYyQYAXUTjEQtZJTKcF0E2UKTh+osDLsgNim9Q== - -xtend@^4.0.0: - version "4.0.2" - resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54" - integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ== - -y18n@^4.0.0: - version "4.0.3" - resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.3.tgz#b5f259c82cd6e336921efd7bfd8bf560de9eeedf" - integrity sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ== - -y18n@^5.0.5: - version "5.0.8" - resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55" - integrity sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA== - -yallist@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72" - integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A== - -yaml@2.0.0-1: - version "2.0.0-1" - resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.0.0-1.tgz#8c3029b3ee2028306d5bcf396980623115ff8d18" - integrity sha512-W7h5dEhywMKenDJh2iX/LABkbFnBxasD27oyXWDS/feDsxiw0dD5ncXdYXgkvAsXIY2MpW/ZKkr9IU30DBdMNQ== - -yargs-parser@13.1.2, yargs-parser@^13.1.2: - version "13.1.2" - resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-13.1.2.tgz#130f09702ebaeef2650d54ce6e3e5706f7a4fb38" - integrity sha512-3lbsNRf/j+A4QuSZfDRA7HRSfWrzO0YjqTJd5kjAq37Zep1CEgaYmrH9Q3GwPiB9cHyd1Y1UwggGhJGoxipbzg== - dependencies: - camelcase "^5.0.0" - decamelize "^1.2.0" - -yargs-parser@^15.0.1: - version "15.0.3" - resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-15.0.3.tgz#316e263d5febe8b38eef61ac092b33dfcc9b1115" - integrity sha512-/MVEVjTXy/cGAjdtQf8dW3V9b97bPN7rNn8ETj6BmAQL7ibC7O1Q9SPJbGjgh3SlwoBNXMzj/ZGIj8mBgl12YA== - dependencies: - camelcase "^5.0.0" - decamelize "^1.2.0" - -yargs-parser@^20.2.2: - version "20.2.9" - resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-20.2.9.tgz#2eb7dc3b0289718fc295f362753845c41a0c94ee" - integrity sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w== - -yargs-unparser@1.6.1: - version "1.6.1" - resolved "https://registry.yarnpkg.com/yargs-unparser/-/yargs-unparser-1.6.1.tgz#bd4b0ee05b4c94d058929c32cb09e3fce71d3c5f" - integrity sha512-qZV14lK9MWsGCmcr7u5oXGH0dbGqZAIxTDrWXZDo5zUr6b6iUmelNKO6x6R1dQT24AH3LgRxJpr8meWy2unolA== - dependencies: - camelcase "^5.3.1" - decamelize "^1.2.0" - flat "^4.1.0" - is-plain-obj "^1.1.0" - yargs "^14.2.3" - -yargs@13.3.2: - version "13.3.2" - resolved "https://registry.yarnpkg.com/yargs/-/yargs-13.3.2.tgz#ad7ffefec1aa59565ac915f82dccb38a9c31a2dd" - integrity sha512-AX3Zw5iPruN5ie6xGRIDgqkT+ZhnRlZMLMHAs8tg7nRruy2Nb+i5o9bwghAogtM08q1dpr2LVoS8KSTMYpWXUw== - dependencies: - cliui "^5.0.0" - find-up "^3.0.0" - get-caller-file "^2.0.1" - require-directory "^2.1.1" - require-main-filename "^2.0.0" - set-blocking "^2.0.0" - string-width "^3.0.0" - which-module "^2.0.0" - y18n "^4.0.0" - yargs-parser "^13.1.2" - -yargs@^14.2.3: - version "14.2.3" - resolved "https://registry.yarnpkg.com/yargs/-/yargs-14.2.3.tgz#1a1c3edced1afb2a2fea33604bc6d1d8d688a414" - integrity sha512-ZbotRWhF+lkjijC/VhmOT9wSgyBQ7+zr13+YLkhfsSiTriYsMzkTUFP18pFhWwBeMa5gUc1MzbhrO6/VB7c9Xg== - dependencies: - cliui "^5.0.0" - decamelize "^1.2.0" - find-up "^3.0.0" - get-caller-file "^2.0.1" - require-directory "^2.1.1" - require-main-filename "^2.0.0" - set-blocking "^2.0.0" - string-width "^3.0.0" - which-module "^2.0.0" - y18n "^4.0.0" - yargs-parser "^15.0.1" - -yargs@^16.2.0: - version "16.2.0" - resolved "https://registry.yarnpkg.com/yargs/-/yargs-16.2.0.tgz#1c82bf0f6b6a66eafce7ef30e376f49a12477f66" - integrity sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw== - dependencies: - cliui "^7.0.2" - escalade "^3.1.1" - get-caller-file "^2.0.5" - require-directory "^2.1.1" - string-width "^4.2.0" - y18n "^5.0.5" - yargs-parser "^20.2.2" - -yocto-queue@^0.1.0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" - integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== - -z-schema@^5.0.1: - version "5.0.6" - resolved "https://registry.yarnpkg.com/z-schema/-/z-schema-5.0.6.tgz#46d6a687b15e4a4369e18d6cb1c7b8618fc256c5" - integrity sha512-+XR1GhnWklYdfr8YaZv/iu+vY+ux7V5DS5zH1DQf6bO5ufrt/5cgNhVO5qyhsjFXvsqQb/f08DWE9b6uPscyAg== - dependencies: - lodash.get "^4.4.2" - lodash.isequal "^4.5.0" - validator "^13.7.0" - optionalDependencies: - commander "^10.0.0" diff --git a/docs/dependency-baseline.md b/docs/dependency-baseline.md new file mode 100644 index 0000000..19d56ec --- /dev/null +++ b/docs/dependency-baseline.md @@ -0,0 +1,69 @@ +# Dependency Baseline + +## Purpose + +This document records the active dependency baseline for the project after upgrading runtime and tooling packages. + +## Active Applications + +The active applications are: + +- `frontend/` +- `backend/` + +Both active applications use npm lockfiles: + +- `frontend/package-lock.json` +- `backend/package-lock.json` + +The root production scripts use npm commands. Do not add Yarn lockfiles back to the active apps unless the package-manager decision is explicitly changed. + +## Frontend Baseline + +The frontend dependency baseline has been updated to current stable npm versions for the active Vite app. + +Key tooling/runtime updates: + +- React 19 +- Vite 8 +- TypeScript 6 +- Tailwind 4 with `@tailwindcss/postcss` +- Vitest 4 +- ESLint 10 +- `@vitejs/plugin-react` +- Playwright for frontend smoke tests + +Verification: + +- `npm run lint` passes. +- `npm run test` passes. +- `npm run test:e2e` passes when a local browser install is available. +- `npm run build` passes and runs typecheck before Vite. +- `npm audit --audit-level=low` reports 0 vulnerabilities. +- `npm outdated` reports no outdated stable dependencies. + +## Backend Baseline + +The backend dependency baseline has been updated to current stable npm versions for the active Express app. + +Key tooling/runtime updates: + +- Express 5 +- bcrypt 6 +- helmet 8 +- jsonwebtoken 9 +- Sequelize 6.37 +- ESLint 10 flat config +- `eslint-plugin-import-x` for unresolved import checks with ESLint 10 + +The backend uses an npm `overrides` entry for `uuid` so transitive dependency trees resolve to the patched stable line. + +Verification: + +- `npm audit --audit-level=low` reports 0 vulnerabilities. +- `npm outdated` reports only `json2csv@6.0.0-alpha.2` above the installed stable `5.0.7`; prerelease packages are not part of the stable baseline. +- `npm run lint` still fails on existing generated/template code debt. The ESLint 10 `.eslintignore` warning is resolved, and the remaining lint failures should be fixed as backend cleanup instead of hidden with broad ignores. + +## Reference Frontend + +`ref-frontend/` is a temporary reference artifact, not the active runtime frontend. Keep it frozen until integration work no longer needs it, then delete it. diff --git a/docs/development-path.md b/docs/development-path.md new file mode 100644 index 0000000..4acc274 --- /dev/null +++ b/docs/development-path.md @@ -0,0 +1,175 @@ +# Development Path + +## Context + +The repository currently contains three relevant parts: + +- `backend/`: generated Node.js/Express backend with PostgreSQL, Sequelize models, JWT auth, roles, permissions, email services, file handling, Swagger, and school-domain entities. +- `frontend/`: customer-approved Vite/React interface with Tailwind, Radix/shadcn UI, React Query, role-based modules, backend API integration, and intentionally static product content. This is the frontend that will be developed going forward. +- `ref-frontend/`: generated Next.js frontend with CRUD pages and Redux slices for the generated backend entities. This is a temporary reference only and can be removed after the working frontend is fully integrated. + +The customer-approved interface defines the expected product experience. The generated backend already covers much of the SaaS infrastructure and core school-chain data model. + +## Decision + +Use `frontend/` as the product frontend base and keep `backend/` as the backend base. + +Do not continue building on the generated Next.js frontend UI in `ref-frontend/`. Its most useful parts are API usage patterns, auth flow, entity coverage, and contracts with the generated backend. The visual layer and user workflows should come from `frontend/`. + +## Rationale + +This path is the least risky and most direct because: + +- The approved UI is already in `frontend/`; rebuilding it inside the generated reference frontend would duplicate work and risk visual drift. +- The generated backend already has entities for organizations, campuses, staff, students, guardians, classes, attendance, assessments, messages, documents, roles, permissions, invoices, payments, and email flows. +- The generated reference frontend uses a different stack from the approved UI: Next.js, MUI, Redux, and i18n. The active approved UI uses Vite, React 19, Tailwind, Radix/shadcn, React Query, and lucide icons. +- The approved UI must use the backend for authentication, tenant scoping, permissions, and persisted data ownership. +- Replacing the generated frontend with the approved UI plus a typed API client preserves the approved UX while keeping SaaS infrastructure centralized in the existing backend. + +## Migration Plan + +### 1. Frontend Replacement + +The generated `frontend/` application has been moved to `ref-frontend/`, and the approved UI has been moved into `frontend/`. + +Keep and adapt: + +- Tailwind/Radix/shadcn UI components. +- Customer-approved module layout and styling. +- Role-based module navigation. +- React Query-based data loading. +- Vite build pipeline unless deployment constraints require Next.js. + +Remove or replace: + +- Direct browser-to-database access from app code. +- Demo role switcher in production mode. +- Silent mock/static data behavior for data that must be persisted. +- Inline `console.error` handling in data functions. +- Directly embedded demo users and static campus assumptions where backend data should be authoritative. + +### 2. Frontend API Layer + +Create a dedicated frontend API layer that talks to `backend/`. + +Recommended structure: + +- `frontend/src/shared/api/httpClient.ts` +- `frontend/src/shared/api/auth.ts` +- `frontend/src/shared/api/campuses.ts` +- `frontend/src/shared/api/staff.ts` +- `frontend/src/shared/api/frameworks.ts` +- `frontend/src/shared/api/attendance.ts` +- `frontend/src/shared/api/messages.ts` +- `frontend/src/shared/api/documents.ts` + +Use aliases with `@` for all imports. Keep response and request types strict. Do not introduce `any` or casts. + +### 3. Backend Alignment + +Keep the generated backend and extend it where the approved UI has modules that do not map cleanly to existing entities. + +Existing backend coverage: + +- Multi-tenant base: `organizations` and organization links across generated entities. +- Campuses: `campuses`. +- Staff and users: `staff`, `users`, `roles`, `permissions`. +- Attendance: `attendance_sessions`, `attendance_records`. +- Communication: `messages`, `message_recipients`. +- Documents and policies: `documents`, `file`. +- Academic records: `classes`, `students`, `guardians`, `assessments`, `assessment_results`. +- Finance-related base: `fee_plans`, `invoices`, `payments`. + +Likely new backend modules needed: + +- FRAME weekly entries. +- Staff zone check-ins and progress. +- Safety quiz results. +- Personality quiz results. +- Walk-through check-ins. +- Campus attendance summaries/configuration if the existing attendance model is not enough. +- Content catalog for classroom strategies, signs, handbook/policies, community services, vocational opportunities, and ESA content if these need admin editing. + +### 4. Multi-Tenancy + +Use `organizations` as the tenant boundary. + +Required work: + +- Ensure every tenant-owned query is scoped by `currentUser.organizationsId` or equivalent organization relation. +- Keep global access only for true platform/admin roles. +- Verify create/update/delete operations cannot cross tenant boundaries. +- Add tests for tenant isolation on high-risk entities: users, staff, campuses, students, attendance, messages, documents, and new customer UI modules. + +### 5. Auth And Roles + +Use backend JWT auth as the source of truth. + +Map approved UI roles to backend roles/permissions: + +- `teacher` +- `para` +- `office` +- `director` +- `superintendent` + +Required work: + +- Keep auth context backed by the backend JWT API. +- Load current user from `/api/auth/me`. +- Derive role and campus from backend user/staff profile. +- Enforce access both in frontend navigation and backend permissions. +- Keep UI route hiding as convenience only; backend authorization must be authoritative. + +### 6. Error Handling + +Backend: + +- Use centralized exception classes instead of ad hoc errors. +- Remove direct logging from business logic where centralized error handling should own observability. + +Frontend: + +- Replace silent returns such as `return []`, `return false`, or `return null` after failed API calls with explicit error states. +- Show user-facing errors using the existing toast/error UI. +- Keep native external-service errors from Gemini/OpenAI unchanged where applicable. + +### 7. Documentation + +For each migrated module: + +- Add or update backend docs in `backend/docs/`. +- Add frontend module docs in `frontend/docs/`. +- Document API contracts, permissions, tenant behavior, and known operational assumptions. + +## Implementation Order + +1. Make the Vite React application in `frontend/` build in place. +2. Add frontend shared constants and aliases. +3. Keep frontend auth integrated with backend JWT auth. +4. Add typed HTTP client and convert one vertical slice end-to-end, starting with campuses/staff/profile. +5. Convert role navigation to backend roles and permissions. +6. Convert FRAME weekly entries. +7. Convert attendance and walk-through modules. +8. Convert messaging, safety quiz results, personality quiz results, progress, and document/policy modules. +9. Add tenant isolation tests and API integration tests. +10. Remove `ref-frontend/` after the product frontend no longer needs the generated reference contracts. + +## Rejected Alternative + +Adapting the generated Next.js frontend in `ref-frontend/` to look and behave like the product frontend is not recommended. + +Reasons: + +- It preserves generated CRUD pages that are not the approved customer experience. +- It requires rebuilding the approved interaction model in a different UI stack. +- It keeps more generated code alive than needed. +- It increases the chance of divergence between the approved interface and the shipped product. + +## Open Questions + +- Should the production frontend remain Vite, or is Next.js required by hosting/deployment constraints? +- Which product frontend modules must be editable by admins versus shipped as static curated content? +- Which product modules should be implemented immediately after auth/profile and staff profile integration? +- What is the final role naming expected in user-facing copy? +- Which email flows are required for launch: invite, verification, password reset, parent communication, staff notifications? diff --git a/docs/full-integration-refactor-plan.md b/docs/full-integration-refactor-plan.md new file mode 100644 index 0000000..25e77d7 --- /dev/null +++ b/docs/full-integration-refactor-plan.md @@ -0,0 +1,478 @@ +# Full Integration Refactor Plan + +## Purpose + +This document is the active cross-application backlog for integrating `frontend/`, `backend/`, and the PostgreSQL database. + +It must track only unfinished integration work and hard implementation rules. Completed migration history belongs in the feature-specific docs under `backend/docs/`, `frontend/docs/`, and supporting audit docs. + +When an item is completed, remove it from this plan and update the owning feature documentation instead of adding a completed checklist entry here. + +## Current Application Shape + +- `frontend/` is the only product frontend. +- `backend/` is the source of truth for auth, tenant ownership, roles, permissions, users, staff profiles, campuses, persisted workflows, content catalogs, email, files, and database access. +- `ref-frontend/` is a temporary generated reference. It is not runtime code and must be deleted after all needed endpoint-contract reference value is exhausted. + +## Non-Negotiable Rules + +- No frontend persisted workflow may use mock, sample, seed, or fallback records. +- No frontend runtime code may import backend seed files. +- Frontend runtime constants may contain only UI config, route/module metadata, query keys, timing values, style tokens, static UI labels, and intentionally static product copy. +- Backend seeds are for database seeding only. +- Frontend seed data is allowed only in tests or `frontend/src/test-seeds/`. +- Backend owns tenant scoping and permissions. Frontend route hiding is UX only. +- All frontend backend calls go through typed modules in `frontend/src/shared/api/`. +- New frontend workflows must follow `View -> Business Logic -> API/Data Access -> Backend`. +- View components must not import `frontend/src/shared/api/`. +- Business logic must not import view components. +- API modules must not import business or view code. +- All imports use the `@` alias. +- No `any`, unsafe casts, disabled TypeScript rules, disabled ESLint rules, compatibility bypasses, silent failures, or legacy re-export surfaces. +- Frontend auth must use backend-owned HttpOnly cookies only. Access and refresh tokens must never be stored in frontend browser storage. +- Secrets live only in backend `.env` or deployment environment variables. Frontend env values must be public browser-safe values only. +- New backend modules must include migration/model/service/route, tenant and role enforcement, docs, and focused verification. +- Every changed workflow must update the relevant docs before it is considered complete. + +## Current Verification Baseline + +Frontend current baseline: + +- `npm run typecheck` passes. +- `npm run lint` passes. +- `npm run test` passes with 51 files and 198 tests. +- `npm run build` passes. +- `npm run test:e2e` passes with 4 backend-free Playwright smoke tests. +- `npm run test:e2e:content` exists for backend-seeded content catalog integration tests. It requires backend migrations, backend seeders, and a running backend server. + +Backend current baseline: + +- Product module files have been syntax-checked with `node -c`. +- `npm audit --audit-level=low` reports 0 vulnerabilities. +- `npm outdated` reports only `json2csv@6.0.0-alpha.2` above the installed stable `5.0.7`; prerelease packages are not part of the stable dependency baseline. +- `npm run lint` still fails on existing generated/template lint debt. +- `npm run db:migrate` still needs a local database verification run with the configured environment. + +## Active Workstreams + +### 1. Backend Migration And Runtime Verification + +Status: open. + +Problem: + +Backend product modules exist, but the configured local database migration/seed run has not been verified as a passing gate in this plan. + +Required work: + +1. Run `npm run db:migrate` against the configured local database. +2. Run `npm run db:seed`. +3. Start backend with the documented env file. +4. Verify public content catalog routes, auth routes, and product module routes respond. +5. Record exact failing migration/seed/runtime errors if any. + +Acceptance criteria: + +- `npm run db:migrate` passes. +- `npm run db:seed` passes. +- Backend starts without generated default secrets. +- `GET /api/public/content-catalog/:contentType` works for the required seeded content catalog types. +- Any remaining backend runtime blocker is captured as a specific follow-up with file/error references. + +### 2. Backend Lint Debt Cleanup + +Status: open. + +Problem: + +Backend ESLint 10 is configured, but `npm run lint` fails on existing generated/template lint debt. + +Required work: + +1. Fix unused imports, unused variables, useless assignments, and generated-template lint failures. +2. Do not add broad ignores to hide debt. +3. Do not disable rules inline unless a documented, reviewed exception is unavoidable. +4. Keep generated-code behavior unchanged while cleaning lint. + +Acceptance criteria: + +- `npm run lint` passes from `backend/`. +- No broad lint-disable blocks are added. +- No generated route/service behavior changes unless required to remove a real defect. + +### 3. Tenant Boundary Audit And Tests + +Status: open and high priority. + +Problem: + +Generated backend code has partial tenant scoping. Multi-tenant correctness cannot rely on frontend filtering. + +Required work: + +1. Audit all tenant-owned generated and product routes for organization scoping. +2. Resolve inconsistent `organizationsId` versus `organizationId` usage where it affects tenant-owned data. +3. Ensure create/update/delete paths cannot accept another tenant's organization or campus. +4. Ensure list/count/autocomplete endpoints are tenant-scoped. +5. Add backend tests proving cross-tenant records are not visible or mutable. + +Acceptance criteria: + +- Tenant isolation tests cover users, campuses, staff, students, attendance, messages, documents, and product module tables. +- A non-global user cannot read or mutate another tenant's data. +- Campus-scoped users cannot mutate another campus unless their backend permission explicitly allows it. + +### 4. Role Model Decision + +Status: open. + +Problem: + +Backend currently maps generated roles to product roles. It is not yet a final product decision whether this mapping is the supported model or product roles should become first-class persisted backend roles/permissions. + +Required decision: + +- Option A: keep generated-role to product-role mapping as the supported model. +- Option B: create first-class persisted product roles: `Teacher`, `Para`, `Office Manager`, `Director`, `Superintendent`. + +Required work after decision: + +1. Document the chosen role model. +2. Align `/api/auth/me` permissions and product role payload with the chosen model. +3. Add backend role/permission tests for staff, director, and superintendent paths. +4. Keep frontend navigation driven by backend role/permissions. + +Acceptance criteria: + +- Role model is documented in backend docs. +- Backend tests prove role access for product modules. +- No frontend role grants capability that backend does not enforce. + +### 5. Product Onboarding Contract + +Status: blocked by customer decision. + +Problem: + +Generated auth signup/profile endpoints exist, but they do not define the product workflow for company creation, campus creation, user creation, staff profile creation, role assignment, campus assignment, or profile updates. + +No-go rules: + +- Do not implement frontend registration. +- Do not implement profile creation/editing UI. +- Do not treat generated signup/profile endpoints as the product onboarding contract. +- Do not add temporary compatibility paths. + +Required customer decisions: + +1. Who creates a company. +2. Who creates campuses. +3. Who invites or creates users. +4. How staff profiles are created and linked to users. +5. How roles and campuses are assigned. +6. Which profile fields users may update themselves. +7. Which profile fields require director/superintendent/admin permissions. + +Required work after decision: + +1. Add backend onboarding contract and docs. +2. Add tenant-scoped backend workflows. +3. Add frontend typed API modules. +4. Add frontend business hooks. +5. Add UI only after backend contract exists. +6. Add authenticated Playwright workflows using backend-seeded fixtures. + +Acceptance criteria: + +- Onboarding workflow is customer-approved and documented. +- Backend owns all creation, assignment, and permission checks. +- Frontend only exposes flows backed by typed backend contracts. + +### 6. Refresh Token Maintenance + +Status: open. + +Problem: + +Cookie auth and refresh rotation exist, but scheduled cleanup for expired/revoked refresh-token rows remains unresolved. + +Required work: + +1. Define refresh-token retention period. +2. Add cleanup job or operational command. +3. Ensure cleanup is observable. +4. Document operational usage. + +Acceptance criteria: + +- Expired and revoked refresh-token rows are cleaned after the approved retention window. +- Cleanup failures are visible and not silent. +- Auth behavior remains unchanged for valid sessions. + +### 7. API Documentation Hardening + +Status: open. + +Problem: + +Markdown docs exist for migrated modules, but Swagger/OpenAPI coverage for product-specific and cookie-session endpoints is incomplete. + +Required work: + +1. Document `/api/auth/signin/local`. +2. Document `/api/auth/refresh`. +3. Document `/api/auth/signout`. +4. Document `/api/auth/me`. +5. Document product module endpoints that are not covered by generated Swagger output. +6. Document response and error shapes per endpoint. + +Acceptance criteria: + +- API docs match actual route payloads and response shapes. +- Cookie auth behavior is explicit. +- Frontend API contract tests remain aligned with docs. + +### 8. Policy And Safety Acknowledgment Persistence + +Status: open pending product contract. + +Problem: + +Policy content is document-backed, but policy/protocol acknowledgments are not yet persisted. + +Required decisions: + +1. Which policies/protocols require acknowledgment. +2. Which roles must acknowledge. +3. Whether acknowledgments are per document version. +4. Who can report acknowledgment status. + +Required work after decision: + +1. Add acknowledgment backend model/migration/service/route. +2. Enforce tenant and role scope. +3. Add frontend typed API and business workflow. +4. Add report views only if required. + +Acceptance criteria: + +- Acknowledgments survive reload. +- Acknowledgment status is tenant-scoped. +- Unauthorized roles cannot view individual acknowledgment records. + +### 9. Attendance Source Contracts + +Status: partially open. + +Current state: + +- Campus attendance daily aggregate summaries are implemented for the current UI. +- Staff attendance snapshot/reporting is read-only. +- Student/class attendance source-of-truth workflow is not defined. + +Open decisions: + +1. Whether campus attendance aggregates are manually entered, imported, or derived from student attendance records. +2. Which external or internal source owns staff attendance writes/imports. +3. Whether student-level attendance UI is required. + +Required work after decision: + +1. Add write/import endpoints only after source contract exists. +2. Keep derived summaries server-side if summaries are derived. +3. Add backend tests for source-of-truth calculations. +4. Add frontend workflows only after backend contracts exist. + +Acceptance criteria: + +- Attendance source of truth is documented. +- UI values can be traced to backend records or server-side derivation. +- No frontend-only attendance source remains in persisted workflows. + +### 10. Generated Audio Provider Contract + +Status: open pending provider decision. + +Problem: + +Classroom timer uses built-in Web Audio sounds. AI-generated audio UI is intentionally not exposed because no backend audio provider contract exists. + +No-go rules: + +- Do not add generated audio UI. +- Do not call external audio providers from frontend runtime. +- Do not add frontend API keys or provider secrets. + +Required work after decision: + +1. Define backend provider contract. +2. Keep provider secrets in backend env. +3. Add backend service with native provider errors where required. +4. Add typed frontend API and explicit loading/error states. + +Acceptance criteria: + +- Generated audio is backend-mediated. +- No provider secret reaches the browser. +- Provider failures remain visible. + +### 11. File Upload And Download Permissions + +Status: open. + +Problem: + +Backend file and document routes exist. Product file workflows need explicit permission verification before new upload/download UI is added. + +Required work: + +1. Audit upload permissions. +2. Audit download permissions. +3. Verify tenant and document ownership checks. +4. Add typed frontend upload client only for approved workflows. + +Acceptance criteria: + +- Unauthorized users cannot access another tenant's files. +- Upload/download behavior is documented and tested before new UI is added. + +### 12. Public Backend Route Audit + +Status: open. + +Problem: + +Public routes must be intentionally public. This includes public content catalog routes and any generated/template public integrations. + +Required work: + +1. List all routes without auth middleware. +2. Mark each as public-by-design or requiring auth. +3. Add auth where required. +4. Document intentionally public routes. + +Acceptance criteria: + +- Every unauthenticated backend route is documented. +- No tenant-owned data is exposed through accidental public routes. + +### 13. Backend-Seeded Authenticated E2E + +Status: blocked by onboarding/profile fixtures. + +Problem: + +Current Playwright coverage includes backend-free smoke tests and backend-seeded content catalog tests. Authenticated persisted workflows need backend-seeded users, roles, campuses, staff profiles, and known credentials. + +Required prerequisites: + +1. Product onboarding/profile contract. +2. Backend-seeded auth fixtures. +3. Tenant-scoped campus/staff fixtures. +4. Stable test credentials stored only in ignored local env or dedicated test seed config. + +Required workflows after prerequisites: + +1. Login to dashboard. +2. Director creates or edits a FRAME entry and sees it after reload. +3. Staff completes QBS quiz and director/superintendent sees compliance. +4. Office enters campus attendance and superintendent sees aggregate. +5. Director submits walkthrough and sees summary update. +6. Staff marks a sign learned and progress persists after reload. + +Acceptance criteria: + +- Authenticated e2e tests use backend seeds, not frontend mock data. +- Tests do not require production secrets. +- Tests are documented and repeatable. + +### 14. Accessibility Test Coverage + +Status: open. + +Required work: + +1. Add axe/Playwright accessibility checks for login. +2. Add axe/Playwright checks for dashboard. +3. Add checks for sidebar navigation. +4. Add checks for modal dialogs. +5. Add checks for forms with validation. +6. Add checks for tables/reports. + +Acceptance criteria: + +- Accessibility tests run in a documented command. +- Critical violations block completion of the refactor. + +### 15. `ref-frontend/` Removal + +Status: open. + +Required work: + +1. Confirm no active docs require `ref-frontend/` for endpoint contract reference. +2. Confirm no scripts import or run `ref-frontend/`. +3. Delete `ref-frontend/`. +4. Update root docs and any setup docs. + +Acceptance criteria: + +- Only `frontend/` and `backend/` remain as active application code. +- No docs describe `ref-frontend/` as needed for normal development. + +### 16. OAuth Provider Strategy Modernization + +Status: open. Deferred — run after the backend TypeScript/ESM migration (see `backend/docs/typescript-esm-migration-plan.md`, decision 0.7). + +Problem: + +Social login uses `passport-google-oauth2` (0.2.0, last published 2022) and `passport-microsoft` (2.1.0). The Google strategy is low-maintenance and should be modernized, ideally consolidating both providers on a single maintained OAuth/OIDC library. + +Required work: + +1. Choose target: minimal swap to `passport-google-oauth20`, or consolidate both providers on `openid-client`. +2. Replace the Google (and optionally Microsoft) passport strategy in `backend/src/auth/auth.ts`. +3. Keep the existing cookie-based session and JWT issuance unchanged. +4. Verify callback URLs, scopes, and the social-signup user flow end to end. +5. Update auth docs (`backend/docs/auth-profile.md`, `cookie-auth.md`). + +Acceptance criteria: + +- Google and Microsoft sign-in work end to end with the chosen library. +- No deprecated/unmaintained OAuth strategy remains in dependencies. +- Cookie/JWT auth behavior is unchanged for existing sessions. +- This change is isolated from the language/module migration (separate PR/task). + +## Strict Implementation Sequence + +Use this order unless the user explicitly reprioritizes: + +1. Backend migration and seed verification. +2. Backend lint debt cleanup. +3. Tenant boundary audit and tests. +4. Role model decision and enforcement tests. +5. Public route audit. +6. API documentation hardening. +7. Product onboarding after customer decision. +8. Authenticated backend-seeded e2e after onboarding/profile fixtures exist. +9. Remaining optional product contracts: acknowledgments, attendance imports, generated audio, file upload/download UI. +10. Accessibility test coverage. +11. Remove `ref-frontend/`. +12. OAuth provider strategy modernization (after backend TS/ESM migration). + +## Definition Of Done + +The integration refactor is complete only when all of the following are true: + +- Backend migrations and seeders pass on the configured local database. +- Backend lint passes without broad ignores. +- Backend tenant isolation tests prove cross-tenant data is not visible or mutable. +- Backend role/permission tests cover product staff, director, and superintendent paths. +- Every unauthenticated backend route is documented as public-by-design. +- Product onboarding contract is customer-approved or explicitly excluded from release scope. +- Every visible frontend workflow is backed by a typed backend API contract. +- No frontend runtime workflow depends on mock, sample, seed, or fallback records for persisted behavior. +- Frontend `typecheck`, `lint`, `test`, `build`, `test:e2e`, and documented seeded e2e suites pass in their required environments. +- Backend auth/API docs match actual route behavior. +- Required accessibility checks pass. +- `ref-frontend/` is deleted or a dated, explicit exception explains why it is still needed. diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..dea30c0 --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,2 @@ +# Public browser-safe runtime configuration only. +VITE_BACKEND_API_URL=http://localhost:8080/api diff --git a/frontend/.gitignore b/frontend/.gitignore index fdc0491..ade4183 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -1,33 +1,26 @@ -# dependencies -/node_modules -/.pnp -.pnp.js - -# testing -/coverage - -# next.js -/.next/ -/out/ - -# production -/build - -# misc -.DS_Store -*.pem - -# debug +# Logs +logs +*.log npm-debug.log* yarn-debug.log* yarn-error.log* +pnpm-debug.log* +lerna-debug.log* -# local.js env files -.env.local -.env.development.local -.env.test.local -.env.production.local +node_modules +dist +dist-ssr +test-results +playwright-report +*.local -# vercel -.vercel -/.idea/ +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/frontend/README.md b/frontend/README.md index 66c4e8c..7d857d3 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1,92 +1,14 @@ -# School Chain Manager +# School Chain Manager Frontend -## This project was generated by Flatlogic Platform. -## Install +This is the customer-approved product frontend that will be developed going forward. -`cd` to project's dir and run `npm install` +The generated Flatlogic/Next.js frontend has been moved to `../ref-frontend/` and should be used only as a temporary reference for backend API contracts, authentication flow, generated CRUD behavior, roles, and permissions. -### Builds - -Build are handled by Next.js CLI — [Info](https://nextjs.org/docs/api-reference/cli) - -### Hot-reloads for development - -``` -npm run dev -``` - -### Builds and minifies for production - -``` -npm run build -``` - -### Exports build for static hosts - -``` -npm run export -``` - -### Lint - -``` -npm run lint -``` - -### Format with prettier - -``` -npm run format -``` - -## Support -For any additional information please refer to [Flatlogic homepage](https://flatlogic.com). - - -## To start the project with Docker: -### Description: - -The project contains the **docker folder** and the `Dockerfile`. - -The `Dockerfile` is used to Deploy the project to Google Cloud. - -The **docker folder** contains a couple of helper scripts: - -- `docker-compose.yml` (all our services: web, backend, db are described here) -- `start-backend.sh` (starts backend, but only after the database) -- `wait-for-it.sh` (imported from https://github.com/vishnubob/wait-for-it) - - > To avoid breaking the application, we recommend you don't edit the following files: everything that includes the **docker folder** and `Dokerfile`. - - - -### Run services: - -1. Install docker compose (https://docs.docker.com/compose/install/) - -2. Move to `docker` folder. All next steps should be done from this folder. - - ``` cd docker ``` - -3. Make executables from `wait-for-it.sh` and `start-backend.sh`: - - ``` chmod +x start-backend.sh && chmod +x wait-for-it.sh ``` - -4. Download dependend projects for services. - -5. Review the docker-compose.yml file. Make sure that all services have Dockerfiles. Only db service doesn't require a Dockerfile. - -6. Make sure you have needed ports (see them in `ports`) available on your local machine. - -7. Start services: - - 7.1. With an empty database `rm -rf data && docker-compose up` - - 7.2. With a stored (from previus runs) database data `docker-compose up` - -8. Check http://localhost:3000 - -9. Stop services: - - 9.1. Just press `Ctr+C` +## Stack +- React +- TypeScript +- Vite +- Tailwind CSS +- Radix/shadcn UI +- React Query diff --git a/frontend/components.json b/frontend/components.json new file mode 100644 index 0000000..62e1011 --- /dev/null +++ b/frontend/components.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "tailwind.config.ts", + "css": "src/index.css", + "baseColor": "slate", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + } +} diff --git a/frontend/docs/auth-integration.md b/frontend/docs/auth-integration.md new file mode 100644 index 0000000..9c492c1 --- /dev/null +++ b/frontend/docs/auth-integration.md @@ -0,0 +1,84 @@ +# Auth Integration + +## Purpose + +The product frontend authenticates through backend-owned session cookies. + +The backend transport details are documented in `backend/docs/cookie-auth.md`. + +## Runtime Configuration + +The frontend reads only public browser-safe API configuration from: + +- `VITE_BACKEND_API_URL` + +Local values live in ignored `frontend/.env` files; the template value is documented in `frontend/.env.example`. + +Do not add secrets to frontend env files. Vite exposes `VITE_*` values to the browser bundle. + +## Auth Flow + +1. `AuthContext` delegates auth state to `useAuthSession` from `frontend/src/business/auth/hooks.ts`. +2. `useAuthSession.signIn` calls `POST /api/auth/signin/local` through `frontend/src/shared/api/auth.ts`. +3. The backend sets an HttpOnly auth cookie and returns the current user profile. +4. `useAuthSession` restores the session with `GET /api/auth/me`. +5. The backend returns the current product profile, including `productRole`, `campus`, `staffProfile`, and `permissions`. +6. UI-facing `StaffProfile` is derived in `frontend/src/business/auth/mappers.ts`. +7. `useAuthSession.signOut` calls `POST /api/auth/signout`; the backend clears the auth cookie. +8. `SignInModal` delegates modal mode, form draft state, validation, and submit workflow to `useAuthModalWorkflow`. + +## Refresh Tokens + +The refresh flow keeps tokens backend-owned: + +1. Backend sign-in sets short-lived access and long-lived refresh HttpOnly cookies. +2. Protected API requests use the access cookie only. +3. `POST /api/auth/refresh` uses the refresh cookie only, rotates it server-side, sets fresh cookies, and returns the current user profile. +4. `httpClient` performs one controlled refresh-and-retry after an access-expiry `401`. +5. If refresh fails because both access and refresh credentials are expired or invalid, the business layer clears the user and redirects to `/login`. +6. Non-auth backend failures remain observable errors; no infinite retry and no silent fallback. + +The frontend must not read, store, or receive access or refresh token values. + +## Login Route + +The app exposes `/login` as the deterministic destination for expired sessions. Sign-in also remains available as a modal for in-app guest prompts. + +Rules: + +- `/login` must reuse the existing auth business hook and API functions. +- Auth-expired redirects must use `replace: true`. +- Safe return paths may be preserved only for same-origin product routes. +- Tokens and raw session failure details must never be placed in the URL. +- Expired access plus valid refresh must restore the session without redirecting. +- Expired access plus expired refresh must redirect to `/login` without showing a raw error. + +## Layering + +- View/provider: `frontend/src/contexts/AuthContext.tsx`, `frontend/src/components/frameworks/SignInModal.tsx`, and `frontend/src/components/sign-in-modal/` +- Business logic: `frontend/src/business/auth/` +- API/data access: `frontend/src/shared/api/auth.ts` +- Backend contract types: `frontend/src/shared/types/auth.ts` + +## Deferred Product Onboarding + +Registration, company creation, campus creation, user creation, staff profile creation, and profile updates are intentionally deferred. + +The backend has generated auth signup/profile endpoints, but the customer has not approved the product workflow for creating companies, campuses, users, role assignments, campus assignments, and staff profiles. + +Rules: + +- Do not implement frontend registration or profile creation flows until the backend product contract is defined. +- Do not treat generated auth signup/profile endpoints as the product onboarding contract. +- Track the cross-application onboarding task in `docs/full-integration-refactor-plan.md`. +- New persisted workflows must use typed backend API modules and business-layer hooks. + +## Standards + +- Do not duplicate backend role mapping in the frontend. +- Do not add frontend secrets. +- Do not store auth tokens in frontend browser storage. +- Do not expose auth tokens in URLs or API responses. +- Do not add frontend refresh-token storage; refresh uses only backend-owned HttpOnly cookies. +- New persisted workflows must use `frontend/src/shared/api/` through `frontend/src/business//`. +- New server state should use business-layer hooks built on top of shared API modules. diff --git a/frontend/docs/campus-attendance-integration.md b/frontend/docs/campus-attendance-integration.md new file mode 100644 index 0000000..28fabc4 --- /dev/null +++ b/frontend/docs/campus-attendance-integration.md @@ -0,0 +1,57 @@ +# Campus Attendance Frontend Integration + +## Purpose + +Campus attendance config and daily aggregate summaries follow the frontend three-layer architecture. + +```text +View -> Business Logic -> API/Data Access -> Backend +``` + +## Files + +View layer: + +- `frontend/src/components/frameworks/CampusAttendance.tsx` +- `frontend/src/components/campus-attendance/AttendanceSummaryCard.tsx` +- `frontend/src/components/campus-attendance/CampusAttendanceEntryForm.tsx` +- `frontend/src/components/campus-attendance/CampusAttendanceHeader.tsx` +- `frontend/src/components/campus-attendance/CampusAttendanceLinkConfig.tsx` +- `frontend/src/components/campus-attendance/CampusAttendanceStatus.tsx` +- `frontend/src/components/campus-attendance/IndividualCampusAttendanceView.tsx` +- `frontend/src/components/campus-attendance/SuperintendentAttendanceView.tsx` +- `frontend/src/components/campus-attendance/styles.ts` +- `frontend/src/components/campus-attendance/types.ts` + +Business logic layer: + +- `frontend/src/business/campus-attendance/hooks.ts` +- `frontend/src/business/campus-attendance/mappers.ts` +- `frontend/src/business/campus-attendance/printReport.ts` +- `frontend/src/business/campus-attendance/selectors.ts` +- `frontend/src/business/campus-attendance/types.ts` + +API/data access layer: + +- `frontend/src/shared/api/campusAttendance.ts` +- `frontend/src/shared/types/campusAttendance.ts` +- `frontend/src/shared/constants/campusAttendance.ts` + +## Behavior + +- Attendance links load from `GET /api/campus_attendance/configs`. +- Attendance links save through `PUT /api/campus_attendance/configs/:campusKey`. +- Daily campus summaries load from `GET /api/campus_attendance/summaries`. +- Daily campus summaries save through `PUT /api/campus_attendance/summaries/:campusKey/:date`. +- The backend calculates the attendance percentage. +- `CampusAttendance.tsx` is a thin composition wrapper. +- CampusAttendance uses typed business hooks/selectors for role access, form state, today, weekly, campus, overall summary calculations, and print report generation. +- Print report generation escapes dynamic strings before writing report HTML. +- Blocked print popups return an explicit print result and show a visible attendance status error. + +## Verification + +- `frontend/src/business/campus-attendance/selectors.test.ts` covers attendance calculations and summary selectors. +- `frontend/src/business/campus-attendance/printReport.test.ts` covers printable report generation. +- `frontend/src/business/campus-attendance/printReport.test.ts` covers blocked-popup handling for attendance report printing. +- `frontend/src/business/campus-attendance/mappers.test.ts` covers API DTO mapping. diff --git a/frontend/docs/campus-catalog.md b/frontend/docs/campus-catalog.md new file mode 100644 index 0000000..19d1c56 --- /dev/null +++ b/frontend/docs/campus-catalog.md @@ -0,0 +1,19 @@ +# Campus Catalog + +## Purpose + +Campus records and campus branding come from the backend database through `GET /api/public/campuses`. + +The frontend does not define campus names, campus IDs, mascot labels, online status, descriptions, or per-campus branding in runtime constants. + +`frontend/src/shared/constants/campusDisplay.ts` contains only shared helpers and generic labels. +`frontend/tailwind.config.ts` safelists allowed branding utility classes so backend-controlled campus branding can render after production builds. + +## Frontend Flow + +- Data access: `frontend/src/shared/api/campuses.ts` +- Business mapping and React Query hook: `frontend/src/business/campuses/` +- Runtime consumers use `useCampusCatalog()` and pass mapped `CampusInfo` view models into components. +- Tests use `frontend/src/test-seeds/campuses.ts`. + +Runtime code must not import campus records from test seeds or define campus rows in `appData.ts`. diff --git a/frontend/docs/classroom-support-integration.md b/frontend/docs/classroom-support-integration.md new file mode 100644 index 0000000..8b3ff86 --- /dev/null +++ b/frontend/docs/classroom-support-integration.md @@ -0,0 +1,68 @@ +# Classroom Support Integration + +## Purpose + +`ClassroomSupport` renders editable classroom strategy content from the backend content catalog. The frontend owns only UI state, filtering rules, style tokens, and presentation. + +Runtime strategy records, images, descriptions, and implementation tips belong to the backend seed payload and can be managed through content catalog APIs. + +## Frontend Layers + +View: + +- `frontend/src/components/frameworks/ClassroomSupport.tsx` +- `frontend/src/components/classroom-support/ClassroomSupportView.tsx` +- `frontend/src/components/classroom-support/ClassroomSupportHeader.tsx` +- `frontend/src/components/classroom-support/ClassroomSupportTryToday.tsx` +- `frontend/src/components/classroom-support/ClassroomSupportFilters.tsx` +- `frontend/src/components/classroom-support/ClassroomStrategyGrid.tsx` +- `frontend/src/components/classroom-support/ClassroomStrategyCard.tsx` +- `frontend/src/components/classroom-support/ClassroomStrategyDetailModal.tsx` + +Business logic: + +- `frontend/src/business/classroom-support/hooks.ts` +- `frontend/src/business/classroom-support/selectors.ts` +- `frontend/src/business/classroom-support/types.ts` + +Shared contracts and UI config: + +- `frontend/src/shared/types/app.ts` +- `frontend/src/shared/constants/classroomSupport.ts` +- `frontend/src/shared/constants/contentCatalog.ts` + +## Backend Contract + +The page reads: + +- `GET /api/public/content-catalog/classroom-strategies` + +The content payload is seeded in: + +- `backend/src/db/seeders/content-catalog-data/content-catalog-seed-payloads.js` + +Each strategy payload supports: + +- `id` +- `title` +- `description` +- `category` +- `ageGroup` +- `zone` +- `image` +- `implementationTip` + +## Behavior + +- `useClassroomSupportPage` loads strategies through the shared content catalog hook. +- Selectors handle search, category, age, zone, favorites-only filtering, favorite toggling, and the daily strategy selection. +- View components receive a prepared page model and do not call API/data access modules. +- Loading, empty, and error states are explicit through `StatePanel`. +- The detail modal displays `implementationTip` only when the backend payload provides it. + +## Data Ownership Rules + +- Do not add classroom strategy records to frontend constants. +- Do not add frontend fallback strategy payloads. +- Keep frontend constants limited to filter options, style classes, query-independent timing values, and UI labels. +- Test-only fixtures may live in selector tests or `frontend/src/test-seeds/`. diff --git a/frontend/docs/classroom-timer-integration.md b/frontend/docs/classroom-timer-integration.md new file mode 100644 index 0000000..d5407d3 --- /dev/null +++ b/frontend/docs/classroom-timer-integration.md @@ -0,0 +1,27 @@ +# Classroom Timer Integration + +## Purpose + +The classroom timer uses local Web Audio API sounds. Generated timer audio is not exposed because the backend does not define an audio-generation provider contract. + +## Current Behavior + +- `ClassroomTimer` renders visual timer controls, sensory backgrounds, preset/custom durations, fullscreen projection, and built-in timer sounds. +- `frontend/src/components/frameworks/ClassroomTimer.tsx` is a thin composition wrapper. +- Timer state, fullscreen coordination, custom time parsing, progress formatting, urgency color selection, particles, and Web Audio orchestration live in `frontend/src/business/classroom-timer/`. +- Timer catalogs are backend-owned content catalog records loaded through the shared content catalog API. `frontend/src/shared/constants/classroomTimer.ts` keeps only timing and particle-count configuration. +- Timer view pieces live under `frontend/src/components/classroom-timer/`. +- Built-in sounds are generated in-browser through the Web Audio API. +- Missing timer catalog data renders an explicit backend content error instead of falling back to frontend seed records. +- AI-generated timer sounds are not exposed in the UI until a backend audio provider contract exists. +- Remote audio must be added through a typed backend API module and business hook after the backend provider contract exists. + +## Verification + +- `npm run typecheck` passes. +- `npm run lint` passes without Fast Refresh warnings. +- `npm run test` passes. + +## Remaining Work + +- Add a typed backend/API/business slice for generated audio only after the backend provider contract is defined. diff --git a/frontend/docs/communications-integration.md b/frontend/docs/communications-integration.md new file mode 100644 index 0000000..470b7f0 --- /dev/null +++ b/frontend/docs/communications-integration.md @@ -0,0 +1,47 @@ +# Communications Frontend Integration + +## Purpose + +Parent communication, internal alerts, and dashboard upcoming events follow the frontend three-layer architecture. + +```text +View -> Business Logic -> API/Data Access -> Backend +``` + +## Files + +View layer: + +- `frontend/src/components/parent-communication/ParentCommunicationModule.tsx` +- `frontend/src/components/internal-alerts/InternalAlertsModule.tsx` +- `frontend/src/components/safety-protocols/SafetyProtocolsModule.tsx` +- `frontend/src/components/frameworks/MoreModules.tsx` as a module export hub +- `frontend/src/components/frameworks/Dashboard.tsx` +- `frontend/src/components/dashboard/DashboardUpcomingEvents.tsx` + +Business logic layer: + +- `frontend/src/business/communications/hooks.ts` +- `frontend/src/business/communications/selectors.ts` +- `frontend/src/business/dashboard/hooks.ts` + +API/data access layer: + +- `frontend/src/shared/api/communications.ts` +- `frontend/src/shared/types/communications.ts` +- `frontend/src/shared/constants/communications.ts` + +## Behavior + +- Parent message logs load from `GET /api/communications/parent-messages`. +- Parent messages save through `POST /api/communications/parent-messages`. +- Internal alerts load from `GET /api/communications/events`. +- Director/superintendent users create alerts through `POST /api/communications/events`. +- Dashboard upcoming events read the same backend event data as the internal alerts module through `useDashboardPage`. +- Safety protocols are loaded through the content catalog backend contract and rendered in a focused view module. +- Approved parent templates and event display constants live in shared constants. +- Parent communication, internal alerts, and safety protocols use shared UI primitives for buttons, form controls, and status panels. + +## Remaining Related Work + +Alert acknowledgments are still local UI state. Add a backend acknowledgment table when acknowledgments must survive reloads or feed compliance reporting. diff --git a/frontend/docs/community-service.md b/frontend/docs/community-service.md new file mode 100644 index 0000000..64579d3 --- /dev/null +++ b/frontend/docs/community-service.md @@ -0,0 +1,48 @@ +# Community Service Frontend Slice + +## Purpose + +Community Service & School Partnerships follows the frontend three-layer architecture and loads organization records from the backend content catalog. + +```text +View -> Business Logic -> API/Data Access -> Backend +``` + +The frontend keeps only presentation configuration in shared constants. Community organization records are backend-owned runtime content. + +## Files + +View layer: + +- `frontend/src/components/frameworks/CommunityService.tsx` +- `frontend/src/components/community-service/` + +Business logic layer: + +- `frontend/src/business/community/hooks.ts` +- `frontend/src/business/community/selectors.ts` + +Shared constants and types: + +- `frontend/src/shared/constants/contentCatalog.ts` +- `frontend/src/shared/constants/community.ts` +- `frontend/src/shared/types/community.ts` + +API/data access layer: + +- `frontend/src/shared/api/contentCatalog.ts` +- `frontend/src/shared/types/contentCatalog.ts` + +## Behavior + +- The framework component is a thin wrapper around `useCommunityService`. +- Organization records load from `GET /api/public/content-catalog/community-organizations`. +- Shared constants own partnership labels/classes, age group labels, and category icon keys. +- Business selectors own category extraction, typed select value normalization, organization filtering, and stats. +- Business hook owns content loading, search text, category/type/age filters, expanded organization state, saved organization state, and filter panel state. +- View components render header, filters, stats, organization cards, details, and empty state. +- Views render explicit loading and error states from the content catalog query. + +## Management Contract + +If community organizations become tenant-owned or admin-editable beyond content catalog management, add a typed backend management contract and keep the frontend integration on the same path: `shared/api` + `business/community` + thin views. diff --git a/frontend/docs/content-catalog-integration.md b/frontend/docs/content-catalog-integration.md new file mode 100644 index 0000000..3737195 --- /dev/null +++ b/frontend/docs/content-catalog-integration.md @@ -0,0 +1,134 @@ +# Content Catalog Integration + +## Purpose + +Frontend content catalogs are loaded from the backend through the shared content catalog API. + +Editable runtime product/content records must not live in `frontend/src/shared/constants/`. The frontend may keep only non-secret config, query keys, labels, timing values, stable training copy, and style tokens. + +## Files + +- Constants: `frontend/src/shared/constants/contentCatalog.ts` +- API: `frontend/src/shared/api/contentCatalog.ts` +- API type: `frontend/src/shared/types/contentCatalog.ts` +- Business hook: `frontend/src/business/content-catalog/hooks.ts` +- API test: `frontend/src/shared/api/contentCatalog.test.ts` + +## API + +Runtime read: + +- `GET /api/public/content-catalog/:contentType` + +Authenticated management: + +- `GET /api/content-catalog` +- `POST /api/content-catalog` +- `GET /api/content-catalog/:contentType` +- `PUT /api/content-catalog/:contentType` +- `DELETE /api/content-catalog/:contentType` + +## Current Consumers + +- classroom support strategies +- QBS safety quiz +- sign language catalog +- sign language page content +- regulation zones +- zones of regulation page content +- dashboard quote, compliance items, and sign of the week +- parent message templates +- community organizations +- vocational opportunities +- emotional intelligence assessment content, weekly focus, and team content +- personality quiz questions and personality type directory +- personality workplace sidebar content +- ESA funding content +- safety protocols +- classroom timer backgrounds, sounds, presets, and tips +- personality quiz intro feature cards + +## Error Handling + +Views must render explicit loading and error states when content catalog requests are pending or fail. + +Do not treat an empty payload default as a fallback for a failed request. The default payload exists only to keep rendering code type-safe while the query is loading. + +## Test Data + +Frontend tests should use local test fixtures in test files or `frontend/src/test-seeds/`. Do not import backend product content payloads into frontend runtime code. + +Runtime testing should use database seeds through backend APIs. Frontend runtime code should not carry production seed records as fallback data. + +## Backend-Seeded E2E Coverage + +Backend-seeded content browser tests are separate from the backend-free app shell smoke tests. + +Commands: + +```bash +cd backend +npm run db:migrate +npm run db:seed +npm run watch +``` + +In another terminal: + +```bash +cd frontend +VITE_BACKEND_API_URL=http://localhost:8080/api npm run test:e2e:content +``` + +`VITE_BACKEND_API_URL` must match the running backend API. If it is omitted, the frontend default is `http://localhost:8080/api`. + +The content e2e suite lives in `frontend/tests/e2e/content-catalog.seeded.e2e.ts` and runs through `frontend/playwright.content.config.ts`. The default `npm run test:e2e` config ignores `*.seeded.e2e.ts` so backend-free smoke tests stay deterministic. + +Minimum backend seed set for content e2e: + +- `classroom-timer-backgrounds` +- `classroom-timer-sounds` +- `classroom-timer-presets` +- `classroom-timer-tips` +- `classroom-strategies` +- `sign-language-items` +- `sign-language-page-content` +- `regulation-zones` +- `zones-of-regulation-page-content` +- `dashboard-encouraging-quotes` +- `dashboard-compliance-items` +- `dashboard-sign-of-week` + +The seeded e2e suite first verifies this minimum set through `GET /api/public/content-catalog/:contentType`, then renders high-value content-backed routes and asserts UI text taken from the live backend response. It does not import backend seed files or duplicate seed records in frontend tests. + +## Editable Quiz Content + +QBS quiz title, weekly focus, key reminders, questions, answer choices, correct answers, and explanations are part of the `safety-qbs-quiz` content catalog payload. The frontend renders this payload and does not keep quiz content or reminder copy in shared constants. + +## Editable Classroom Strategy Content + +Classroom strategy titles, descriptions, images, categories, age groups, regulation zones, and implementation tips are part of the `classroom-strategies` content catalog payload. The frontend may keep filter labels and style tokens, but it must not keep strategy records or implementation copy in shared constants. + +## Editable Sign Language Content + +Sign records, teaching tips, video URLs, GIF URLs, step instructions, and page-level teaching reminders are part of the `sign-language-items` and `sign-language-page-content` content catalog payloads. The frontend renders these payloads and does not keep sign content or reminder copy in shared constants. + +## Editable Zones Of Regulation Content + +Regulation zone records, behaviors, strategies, matching signs, QBS safety connection copy, and quick de-escalation flow content are part of the `regulation-zones` and `zones-of-regulation-page-content` content catalog payloads. The frontend renders these payloads and does not keep zone content or flow copy in shared constants. + +## Editable Dashboard Content + +Dashboard quotes, compliance items, and sign-of-week content are part of the `dashboard-encouraging-quotes`, `dashboard-compliance-items`, and `dashboard-sign-of-week` content catalog payloads. The dashboard business layer renders these payloads and does not keep dashboard content records in shared constants. + +## Editable ESA Funding Content + +ESA funding approved uses, key points, state checklist items, school impact items, staff role guidance, parent conversation script, and resource records are part of the `esa-funding-content` content catalog payload. Static ESA intro copy and FAQs live in `frontend/src/shared/constants/esaFunding.ts` because they are stable training copy, not editable runtime records. + +## Editable Community Organization Content + +Community organization records are part of the `community-organizations` content catalog payload. The frontend may keep partnership labels/classes, age group labels, category icon keys, and style tokens, but it must not keep organization records in shared constants. + +## Editable Vocational Opportunity Content + +Vocational opportunity records are part of the `vocational-opportunities` content catalog payload. The frontend may keep zip search timing, category preview labels, category icon keys, and style tokens, but it must not keep opportunity records in shared constants. diff --git a/frontend/docs/dashboard-integration.md b/frontend/docs/dashboard-integration.md new file mode 100644 index 0000000..1e0b06a --- /dev/null +++ b/frontend/docs/dashboard-integration.md @@ -0,0 +1,66 @@ +# Dashboard Integration + +## Purpose + +`Dashboard` composes current-user operational data from backend APIs and content catalog payloads through the three-layer frontend architecture. + +```text +View -> Business Logic -> API/Data Access -> Backend +``` + +Runtime quote, compliance, sign-of-week, FRAME, communication event, and zone check-in data comes from backend-backed hooks. The frontend owns only UI state, navigation config, zone option styling, and presentation. + +## Frontend Layers + +View: + +- `frontend/src/components/frameworks/Dashboard.tsx` +- `frontend/src/components/dashboard/DashboardView.tsx` +- `frontend/src/components/dashboard/DashboardHero.tsx` +- `frontend/src/components/dashboard/DashboardQuotePanel.tsx` +- `frontend/src/components/dashboard/DashboardZoneCheckIn.tsx` +- `frontend/src/components/dashboard/DashboardFramePreview.tsx` +- `frontend/src/components/dashboard/DashboardUpcomingEvents.tsx` +- `frontend/src/components/dashboard/DashboardWeeklyProgress.tsx` +- `frontend/src/components/dashboard/DashboardSignOfWeek.tsx` +- `frontend/src/components/dashboard/DashboardQuickActions.tsx` + +Business logic: + +- `frontend/src/business/dashboard/hooks.ts` +- `frontend/src/business/dashboard/selectors.ts` +- `frontend/src/business/dashboard/types.ts` + +Shared contracts and UI config: + +- `frontend/src/shared/types/dashboard.ts` +- `frontend/src/shared/constants/dashboard.ts` +- `frontend/src/shared/constants/contentCatalog.ts` + +## Backend Contracts + +Content catalog: + +- `GET /api/public/content-catalog/dashboard-encouraging-quotes` +- `GET /api/public/content-catalog/dashboard-compliance-items` +- `GET /api/public/content-catalog/dashboard-sign-of-week` + +Feature APIs: + +- F.R.A.M.E. entries through `useFrameEntries` +- Communication events through `useCommunicationEvents` +- Current-user zone check-in through `useZoneCheckIn` + +## Behavior + +- `useDashboardPage` composes all dashboard data sources into one page model. +- Selectors handle greeting calculation, day-based quote selection, zone normalization, event limiting, and role-filtered quick actions. +- View components receive prepared props and do not call API/data access modules. +- Loading, empty, and error states remain explicit for each dashboard section. +- The existing `Dashboard` props API is preserved while the framework component becomes a thin wrapper. + +## Data Ownership Rules + +- Do not add dashboard quote, compliance, sign-of-week, FRAME, event, or zone progress records to frontend constants. +- Keep frontend constants limited to quick-action navigation config, zone button style config, and display limits. +- Test-only fixtures may live in selector tests or `frontend/src/test-seeds/`. diff --git a/frontend/docs/director-dashboard-integration.md b/frontend/docs/director-dashboard-integration.md new file mode 100644 index 0000000..e5b406c --- /dev/null +++ b/frontend/docs/director-dashboard-integration.md @@ -0,0 +1,46 @@ +# Director Dashboard Frontend Integration + +## Purpose + +Director dashboard follows the frontend three-layer architecture and aggregates backend-backed FRAME, QBS safety quiz, and staff attendance data. + +```text +View -> Business Logic -> API/Data Access -> Backend +``` + +## Files + +View layer: + +- `frontend/src/components/frameworks/DirectorDashboard.tsx` +- `frontend/src/components/director-dashboard/` + +Business logic layer: + +- `frontend/src/business/director-dashboard/hooks.ts` +- `frontend/src/business/director-dashboard/selectors.ts` +- `frontend/src/business/director-dashboard/types.ts` + +API/data access layer: + +- `frontend/src/shared/api/frame.ts` +- `frontend/src/shared/api/safetyQuizResults.ts` +- `frontend/src/shared/api/staffAttendance.ts` + +Constants: + +- `frontend/src/shared/constants/directorDashboard.ts` + +## Behavior + +- The framework component calls `useDirectorDashboardPage` and renders `DirectorDashboardView`. +- FRAME entries load through `useFrameEntries`. +- Safety quiz results load through `useSafetyQuizResults`. +- Staff attendance records and summary load through staff attendance business hooks. +- Overview metrics, risk areas, and FRAME previews are derived in business selectors. +- View components use shared `Button`, `Table`, `StatePanel`, and `ModuleHeader` primitives. +- Loading, empty, and error states are explicit. + +## Remaining Related Work + +Time range tabs currently control UI state only. Add backend-supported date filtering before wiring these tabs to query filters. diff --git a/frontend/docs/error-handling.md b/frontend/docs/error-handling.md new file mode 100644 index 0000000..bd9c508 --- /dev/null +++ b/frontend/docs/error-handling.md @@ -0,0 +1,24 @@ +# Frontend Error Handling + +## Purpose + +Frontend backend errors use a shared path: + +1. `frontend/src/shared/api/httpClient.ts` parses failed HTTP responses. +2. `ApiError` preserves backend `message`, `code`, `details`, and HTTP `status`. +3. `AuthExpiredError` represents expired or invalid cookie sessions. +4. `frontend/src/shared/errors/errorMessages.ts` converts unknown errors into user-facing strings. + +Views and business hooks should use `getErrorMessage` or `getOptionalErrorMessage` instead of reading `.message` directly. + +## Rules + +- Do not create local `getErrorMessage` helpers in feature components or hooks. +- Do not read `query.error.message` or `mutation.error.message` directly in JSX. +- Do not silently swallow backend failures or replace failed persisted workflows with fallback product data. +- Auth expiration should redirect through the auth session flow, not display raw backend errors. +- Runtime invariant errors, such as context hooks used outside providers, may still throw native `Error` instances. + +## Verification + +`frontend/src/shared/errors/errorMessages.test.ts` covers the formatter contract for `ApiError`, `AuthExpiredError`, regular `Error`, and unknown values. diff --git a/frontend/docs/esa-funding-integration.md b/frontend/docs/esa-funding-integration.md new file mode 100644 index 0000000..e59edec --- /dev/null +++ b/frontend/docs/esa-funding-integration.md @@ -0,0 +1,68 @@ +# ESA Funding Integration + +## Purpose + +`ESAFunding` renders backend-owned ESA funding training content through the three-layer frontend architecture. + +```text +View -> Business Logic -> API/Data Access -> Backend +``` + +Static ESA explanatory copy and FAQs live in dedicated frontend constants. Editable ESA lists, staff role guidance, parent conversation script, and resource records belong to the backend content catalog. The frontend also owns local UI state, URL validation, acknowledgement interaction state, and presentation. + +## Frontend Layers + +View: + +- `frontend/src/components/frameworks/ESAFunding.tsx` +- `frontend/src/components/esa-funding/EsaFundingView.tsx` +- `frontend/src/components/esa-funding/EsaFundingHeader.tsx` +- `frontend/src/components/esa-funding/EsaFundingHero.tsx` +- `frontend/src/components/esa-funding/EsaFundingStateNotice.tsx` +- `frontend/src/components/esa-funding/EsaFundingKeyPoints.tsx` +- `frontend/src/components/esa-funding/EsaFundingApprovedUses.tsx` +- `frontend/src/components/esa-funding/EsaFundingImpactRoles.tsx` +- `frontend/src/components/esa-funding/EsaFundingFaq.tsx` +- `frontend/src/components/esa-funding/EsaFundingQuickReference.tsx` +- `frontend/src/components/esa-funding/EsaFundingResources.tsx` +- `frontend/src/components/esa-funding/EsaFundingAcknowledgement.tsx` +- `frontend/src/components/esa-funding/EsaFundingIcon.tsx` + +Business logic: + +- `frontend/src/business/esa-funding/hooks.ts` +- `frontend/src/business/esa-funding/selectors.ts` +- `frontend/src/business/esa-funding/types.ts` + +Shared contracts: + +- `frontend/src/shared/types/esaFunding.ts` +- `frontend/src/shared/constants/esaFunding.ts` +- `frontend/src/shared/constants/contentCatalog.ts` + +## Backend Contract + +The page reads: + +- `GET /api/public/content-catalog/esa-funding-content` + +Content payload is seeded in: + +- `backend/src/db/seeders/content-catalog-data/content-catalog-seed-payloads.js` + +## Behavior + +- `useEsaFundingPage` loads ESA funding content and owns local FAQ expansion plus acknowledgement state. +- Static ESA intro, state notice copy, and FAQs are read from `frontend/src/shared/constants/esaFunding.ts`. +- Selectors handle FAQ toggling and resource URL validation. +- View components receive a prepared page model and do not call API/data access modules. +- Loading and error states are explicit through `StatePanel`. +- Resource records with valid `http` or `https` URLs render as external links. Invalid or placeholder URLs render as unavailable instead of using no-op click handlers. + +## Data Ownership Rules + +- Static ESA explanatory copy and FAQs may live in `frontend/src/shared/constants/esaFunding.ts`. +- Do not add editable ESA funding records such as approved uses, key points, checklist items, role guidance, conversation scripts, or resource records to frontend constants. +- Do not add frontend fallback ESA content payloads. +- Keep frontend logic limited to workflow state, resource URL validation, icon mapping, and presentation. +- Test-only fixtures may live in selector tests or `frontend/src/test-seeds/`. diff --git a/frontend/docs/frame-integration.md b/frontend/docs/frame-integration.md new file mode 100644 index 0000000..836081e --- /dev/null +++ b/frontend/docs/frame-integration.md @@ -0,0 +1,55 @@ +# FRAME Frontend Integration + +## Purpose + +FRAME follows the frontend three-layer architecture for persisted action-plan entries. + +```text +View -> Business Logic -> API/Data Access -> Backend +``` + +## Files + +View layer: + +- `frontend/src/components/frameworks/FrameModule.tsx` +- `frontend/src/components/frame/` +- `frontend/src/components/frameworks/Dashboard.tsx` +- `frontend/src/components/dashboard/DashboardFramePreview.tsx` +- `frontend/src/components/frameworks/DirectorDashboard.tsx` +- `frontend/src/components/director-dashboard/DirectorRecentFramePanel.tsx` + +Business logic layer: + +- `frontend/src/business/frame/hooks.ts` +- `frontend/src/business/frame/mappers.ts` +- `frontend/src/business/frame/selectors.ts` +- `frontend/src/business/frame/types.ts` +- `frontend/src/business/dashboard/hooks.ts` +- `frontend/src/business/director-dashboard/hooks.ts` +- `frontend/src/business/director-dashboard/selectors.ts` + +API/data access layer: + +- `frontend/src/shared/api/frame.ts` +- `frontend/src/shared/types/frame.ts` + +Constants: + +- `frontend/src/shared/constants/frame.ts` + +## Behavior + +- FRAME entries load from `GET /api/frame_entries`. +- Create/update workflows use typed API calls and React Query mutations. +- `FrameModule.tsx` is a thin wrapper that calls `useFrameModule` and renders focused FRAME view components. +- FRAME view components use shared UI primitives: `Button`, `Input`, `Textarea`, and `StatePanel`. +- Static FRAME sample entries are not used as runtime persisted-data substitutes. +- Empty and error states are rendered explicitly. +- Dynamic F/R/A/M/E field access is typed through `FrameSectionKey`. +- Director dashboard renders recent FRAME previews through director dashboard selectors instead of deriving preview rows in JSX. +- Home dashboard renders the latest FRAME entry through `useDashboardPage` and `DashboardFramePreview`. + +## Remaining Related Work + +Dashboard zone check-ins are owned by the dashboard business layer and user progress API, not by FRAME. diff --git a/frontend/docs/frontend-architecture.md b/frontend/docs/frontend-architecture.md new file mode 100644 index 0000000..25dec85 --- /dev/null +++ b/frontend/docs/frontend-architecture.md @@ -0,0 +1,222 @@ +# Frontend Architecture + +## Purpose + +The frontend uses a three-layer architecture: + +- View layer +- Business logic layer +- API/data access layer + +The goal is to keep UI components thin, keep business rules testable, and keep all backend communication centralized. + +## Layer 1: View + +Location: + +- `frontend/src/components/` +- `frontend/src/pages/` + +Responsibilities: + +- Render UI. +- Own visual composition and layout. +- Call business-layer hooks/actions. +- Show loading, empty, and error states from business-layer state. +- Keep form markup and user interaction wiring close to the component. +- Compose shared UI primitives from `frontend/src/components/ui/` for common controls and repeated states. + +View components must not: + +- Call `fetch` directly. +- Import backend API modules directly. +- Contain tenant, role, permission, or workflow rules. +- Transform backend DTOs into product state when that transformation belongs to business logic. +- Hide failed persisted workflows with static data. + +## Layer 2: Business Logic + +Location: + +- `frontend/src/business/` +- `frontend/src/shared/business/` + +Responsibilities: + +- Own React Query hooks for server state. +- Own local workflow state when it is not purely visual. +- Transform backend DTOs into UI-facing view models. +- Apply role, permission, tenant, campus, and workflow rules received from backend contracts. +- Own calculations, filters, sorting, validation helpers, and derived state. +- Coordinate multiple API calls for one user workflow. + +Business logic may import: + +- `frontend/src/shared/api/` +- `frontend/src/shared/types/` +- `frontend/src/shared/constants/` +- `frontend/src/shared/business/` + +Business logic must not: + +- Render JSX. +- Read or write secrets. +- Duplicate backend authorization or tenant enforcement. +- Create another HTTP client. + +Shared business helpers should be used for repeated cross-module mechanics. `frontend/src/shared/business/queryMutations.ts` centralizes React Query mutation invalidation, `frontend/src/shared/business/apiListRows.ts` centralizes `ApiListResponse.rows` extraction and mapping, and `frontend/src/shared/business/queryState.ts` centralizes multi-query loading/error aggregation. + +## Layer 3: API/Data Access + +Location: + +- `frontend/src/shared/api/` +- `frontend/src/shared/types/` + +Responsibilities: + +- Own all HTTP calls to `backend/`. +- Send backend-owned auth cookies through the shared HTTP client. +- Define request and response types. +- Preserve backend errors and expose them to business logic. +- Keep endpoint paths and payload shapes in one place. + +API/data access modules must not: + +- Render UI. +- Own component state. +- Apply product workflow decisions that belong to business logic. +- Return mock/static data for persisted workflows. +- Swallow backend failures. + +## Import Direction + +Allowed direction: + +```text +View -> Business Logic -> API/Data Access -> Backend +``` + +Shared constants and shared types may be imported by any layer. + +## Error Handling + +Backend/API errors are parsed in `frontend/src/shared/api/httpClient.ts` and normalized for UI rendering through `frontend/src/shared/errors/errorMessages.ts`. + +Feature hooks and components must use the shared error formatter instead of reading `.message` directly or creating local formatter helpers. Detailed rules live in `frontend/docs/error-handling.md`. + +`npm run test` includes `frontend/src/shared/architecture/import-boundaries.test.ts`, which enforces these import boundaries and keeps runtime data access centralized in the shared API layer. + +Disallowed direction: + +```text +API/Data Access -> Business Logic +API/Data Access -> View +Business Logic -> View +``` + +## Feature Structure + +For new or updated product modules, use this shape: + +```text +frontend/src/business// + hooks.ts + mappers.ts + selectors.ts + validators.ts + types.ts + +frontend/src/shared/api/.ts +frontend/src/shared/types/.ts +frontend/src/components// +``` + +Only create files that are actually needed for the module. + +## Routing + +The frontend uses React Router. Top-level URL route declarations live in a typed object route config instead of inline JSX in `App.tsx`. + +Target structure: + +```text +frontend/src/app/AppProviders.tsx +frontend/src/app/AppRouter.tsx +frontend/src/app/appRoutes.tsx +frontend/src/app/ModuleRouteGuard.tsx +frontend/src/app/shellOutletContext.ts +frontend/src/shared/constants/routes.ts +frontend/src/shared/constants/moduleRoutes.ts +``` + +Rules: + +- `App.tsx` composes providers and renders the router only. +- `appRoutes.tsx` owns top-level and product URL route objects. +- Product routes are lazy-loaded route elements under the shared app shell layout. +- Route page adapters stay thin and delegate product behavior to business hooks. +- `AppRouter.tsx` uses `useRoutes(appRoutes)`. +- `APP_ROUTE_PATHS` owns path constants and module route metadata maps each `ModuleId` to exactly one route path. +- The browser URL is the source of truth for the active product module. +- Sidebar, footer, dashboard actions, and other module navigation should navigate by route path instead of storing active module state. +- `/login` remains the deterministic destination for expired access plus expired refresh sessions. +- Restricted module routes redirect to `/dashboard`. + +## Update Rule + +When updating an existing module: + +1. Add or update backend API endpoints first. +2. Add typed API functions in `frontend/src/shared/api/`. +3. Add business hooks and mappers in `frontend/src/business//`. +4. Update view components to call the business layer. +5. Remove direct data access from the component. +6. Add or update module documentation. + +## Current Baseline + +The active frontend already has: + +- React 19, Vite 8, TypeScript 6, Tailwind 4, Vitest 4, and ESLint 10 as the current active tooling baseline. +- Current top-level URL routes are `/`, `/login`, and `*`; product module routes are nested under the shared shell layout and lazy-loaded from `frontend/src/pages/modules/`. +- View components under `frontend/src/components/` and `frontend/src/pages/`. +- Shared backend API foundation under `frontend/src/shared/api/`. +- Shared frontend constants and types under `frontend/src/shared/`. +- Cross-module UI-facing product types under `frontend/src/shared/types/app.ts`. +- Static app navigation/media config under `frontend/src/shared/constants/appData.ts` and static personality catalog metadata under `frontend/src/shared/constants/personalityCatalog.ts`. +- Backend-owned campus records and branding load through `frontend/src/shared/api/campuses.ts` and `frontend/src/business/campuses/`; frontend keeps only generic campus helpers and allowed Tailwind branding tokens. +- Test-only seed records under `frontend/src/test-seeds/`; runtime code must not import that directory. +- Theme names, default theme values, CSS class names, and media query constants under `frontend/src/shared/constants/theme.ts`; global light/dark CSS tokens remain in `frontend/src/index.css` and Tailwind maps to those variables in `frontend/tailwind.config.ts`. +- React Query keys, UI timing values, storage keys, and sidebar runtime constants live in dedicated files under `frontend/src/shared/constants/`. +- Auth/profile session logic under `frontend/src/business/auth/`, with `AuthContext` acting as a thin provider. The auth transport is backend-owned HttpOnly cookie auth documented in `backend/docs/cookie-auth.md` and `frontend/docs/auth-integration.md`. +- App shell state, access selection, campus display lookup, mobile overlay visibility, shell outlet context, and prepared Sidebar/TopBar/GuestBanner/Footer props live under `frontend/src/business/app-shell/`. The shared shell layout remains a thin view composition in `frontend/src/components/AppLayout.tsx`. +- Top bar shell state under `frontend/src/business/top-bar/`, with search, badges, notifications, profile menu, and sign-in modal composition split under `frontend/src/components/top-bar/`. +- FRAME entries under `frontend/src/business/frame/`, with typed API calls in `frontend/src/shared/api/frame.ts` and explicit empty/error states in the view. +- Current-user progress under `frontend/src/business/user-progress/`, with typed API calls in `frontend/src/shared/api/userProgress.ts` for learned signs and zone check-ins. +- Safety quiz results under `frontend/src/business/safety-quiz/`, with typed API calls in `frontend/src/shared/api/safetyQuizResults.ts`. +- Walk-through check-ins under `frontend/src/business/walkthrough/`, with typed API calls in `frontend/src/shared/api/walkthrough.ts`, shared constants in `frontend/src/shared/constants/walkthrough.ts`, and summary calculations in typed selectors. +- Communications under `frontend/src/business/communications/`, with typed API calls in `frontend/src/shared/api/communications.ts` for parent messages, internal alerts, and dashboard upcoming events. +- EI/personality results under `frontend/src/business/personality/`, with typed API calls in `frontend/src/shared/api/personality.ts`, DTO mappers, distribution selectors, workflow-specific hook files, and explicit loading/error states in the view. +- Campus attendance config and daily summaries under `frontend/src/business/campus-attendance/`, with typed API calls in `frontend/src/shared/api/campusAttendance.ts`, DTO mappers, summary selectors, and explicit loading/error states in the view. +- Staff attendance snapshot and director staff counts under `frontend/src/business/staff-attendance/`, with typed API calls in `frontend/src/shared/api/staffAttendance.ts`, DTO mappers, rollup selectors, and explicit loading/error states in the view. +- Handbook policies under `frontend/src/business/policies/`, with typed document API calls in `frontend/src/shared/api/documents.ts`, DTO mappers, selectors, and explicit loading/error states in the view. +- Classroom timer built-in sounds are local Web Audio behavior. AI-generated sounds are not exposed until a backend audio provider contract exists. +- UI component variants live in dedicated non-component files such as `frontend/src/components/ui/button-variants.ts`, `badge-variants.ts`, `toggle-variants.ts`, and `navigation-menu-variants.ts`. +- Loading, empty, and error state panels are centralized through `frontend/src/components/ui/state-panel.tsx`, with tone/size/alignment variants in `frontend/src/components/ui/state-panel-variants.ts`. +- Repeated module headings use `frontend/src/components/ui/module-header.tsx`; simple native dropdowns use `frontend/src/components/ui/native-select.tsx`. +- Reusable UI/context hooks live outside provider component files, including `frontend/src/components/theme-context.ts`, `frontend/src/components/ui/form-field-context.ts`, `frontend/src/components/ui/sidebar-context.ts`, and `frontend/src/contexts/auth-context.ts`. +- The tracked large framework components have been split into thin wrappers and focused view pieces backed by business hooks/selectors. +- Frontend TypeScript runs in strict mode through `npm run typecheck`; `npm run build` runs typecheck before Vite. +- Frontend unit tests run through `npm run test` with Vitest. Current coverage includes business-layer selectors and mappers for app-shell/sidebar, auth, campuses, campus attendance, classroom support, classroom timer, communications, community, dashboard, director dashboard, ESA funding, FRAME, personality, policies, safety quiz, sign language, staff attendance, top bar, user progress, vocational, walk-through check-in/summary/form workflows, and zones. +- `npm run test` also enforces API/business/view import boundaries through `frontend/src/shared/architecture/import-boundaries.test.ts`. +- Frontend backend-free smoke tests run through `npm run test:e2e` with Playwright. Current smoke coverage verifies teacher, director, and superintendent guest navigation/access paths. +- Frontend backend-seeded content tests run through `npm run test:e2e:content` with Playwright after backend migrations, seeders, and the backend server are running. +- Frontend dependency verification is clean: `npm run lint`, `npm run test`, `npm run build`, `npm audit --audit-level=low`, and `npm outdated` pass for stable package releases. + +## Known Remaining Gaps + +- New or changed framework wrappers should follow the same thin-view plus business-hook pattern. +- New product routes should be added to module route metadata, `frontend/src/app/appRoutes.tsx`, and covered by route metadata tests. +- TypeScript compiler strictness is enabled for the current baseline. Keep future slices compatible with `strict`, `noUnusedLocals`, and `noUnusedParameters`. +- Unit test coverage exists for route config, module route metadata, API/data-access behavior, auth refresh/retry behavior, business-layer selector/mapper/report slices, and import-boundary guardrails. Guest-role Playwright smoke tests cover the current backend-free staff/director/superintendent paths. diff --git a/frontend/docs/index.md b/frontend/docs/index.md new file mode 100644 index 0000000..f67aa62 --- /dev/null +++ b/frontend/docs/index.md @@ -0,0 +1,59 @@ +# Frontend Documentation Index + +## Start Here + +- Repository working rules: [`../../AGENTS.md`](../../AGENTS.md) +- Frontend architecture: [`frontend-architecture.md`](frontend-architecture.md) +- Frontend architecture review: [`frontend-architecture-review.md`](frontend-architecture-review.md) +- Remaining frontend issues plan: [`remaining-frontend-issues-fix-plan.md`](remaining-frontend-issues-fix-plan.md) +- Object router rules: [`object-router.md`](object-router.md) +- Error handling: [`error-handling.md`](error-handling.md) +- Test coverage rules: [`test-coverage.md`](test-coverage.md) + +Read the repository rules first, then use the frontend architecture document as the default development contract for frontend work. + +## Architecture And Shared Foundations + +- [`frontend-architecture.md`](frontend-architecture.md): three-layer frontend architecture, import direction, routing, and update rules. +- [`frontend-architecture-review.md`](frontend-architecture-review.md): latest frontend architecture and implementation quality review. +- [`remaining-frontend-issues-fix-plan.md`](remaining-frontend-issues-fix-plan.md): phased plan for closing remaining frontend issues. +- [`object-router.md`](object-router.md): React Router object-route configuration. +- [`ui-kit.md`](ui-kit.md): shared view-layer primitives and consolidation rules. +- [`theme.md`](theme.md): centralized theme constants and CSS token ownership. +- [`static-app-data.md`](static-app-data.md): static app navigation and media configuration. +- [`shared-app-types.md`](shared-app-types.md): cross-module UI-facing product types. +- [`test-seeds.md`](test-seeds.md): frontend test seed boundaries. +- [`test-coverage.md`](test-coverage.md): current unit and smoke test coverage. + +## Backend Integration + +- [`auth-integration.md`](auth-integration.md): HttpOnly cookie auth and refresh behavior. +- [`campus-catalog.md`](campus-catalog.md): backend-owned campus records and branding. +- [`content-catalog-integration.md`](content-catalog-integration.md): backend-owned editable content catalog. + +## Shell And Navigation + +- [`sidebar-integration.md`](sidebar-integration.md): sidebar navigation, role access, and campus branding. +- [`top-bar-integration.md`](top-bar-integration.md): top bar search, notifications, profile menu, and sign-in modal. + +## Product Slices + +- [`campus-attendance-integration.md`](campus-attendance-integration.md) +- [`classroom-support-integration.md`](classroom-support-integration.md) +- [`classroom-timer-integration.md`](classroom-timer-integration.md) +- [`communications-integration.md`](communications-integration.md) +- [`community-service.md`](community-service.md) +- [`dashboard-integration.md`](dashboard-integration.md) +- [`director-dashboard-integration.md`](director-dashboard-integration.md) +- [`esa-funding-integration.md`](esa-funding-integration.md) +- [`frame-integration.md`](frame-integration.md) +- [`personality-catalog.md`](personality-catalog.md) +- [`personality-integration.md`](personality-integration.md) +- [`policies-integration.md`](policies-integration.md) +- [`safety-quiz-integration.md`](safety-quiz-integration.md) +- [`sign-language-integration.md`](sign-language-integration.md) +- [`staff-attendance-integration.md`](staff-attendance-integration.md) +- [`user-progress-integration.md`](user-progress-integration.md) +- [`vocational-opportunities.md`](vocational-opportunities.md) +- [`walkthrough-integration.md`](walkthrough-integration.md) +- [`zones-of-regulation-integration.md`](zones-of-regulation-integration.md) diff --git a/frontend/docs/object-router.md b/frontend/docs/object-router.md new file mode 100644 index 0000000..557962f --- /dev/null +++ b/frontend/docs/object-router.md @@ -0,0 +1,38 @@ +# Object Router + +## Purpose + +Top-level frontend URL routes are declared through React Router object configuration instead of inline JSX route declarations. + +## Files + +- `frontend/src/app/appRoutes.tsx` +- `frontend/src/app/ModuleRouteGuard.tsx` +- `frontend/src/app/shellOutletContext.ts` +- `frontend/src/app/AppRouter.tsx` +- `frontend/src/app/AppProviders.tsx` +- `frontend/src/shared/constants/routes.ts` +- `frontend/src/shared/constants/moduleRoutes.ts` +- `frontend/src/pages/modules/` + +## Runtime Shape + +- `App.tsx` renders provider composition and the router only. +- `AppProviders` owns global providers, including `BrowserRouter`. +- `AppRouter` renders `useRoutes(appRoutes)`. +- `appRoutes` owns top-level route objects and nested product module routes. +- `APP_ROUTE_PATHS` owns route path constants that are reused outside the router. +- `MODULES` owns module metadata, including each module route path. +- Product route pages are loaded with `React.lazy`. +- `AppLayout` is the shared shell route element and passes shell props through outlet context. +- Module navigation uses route navigation instead of local active-module state. + +## Rules + +- Do not add individual `` declarations to `App.tsx`. +- Keep route elements thin; product behavior belongs in business hooks. +- Reuse `APP_ROUTE_PATHS.login` for auth-expired redirects. +- Add route-config and module-route metadata tests when routes change. +- New product modules must define a route path and a lazy page adapter. +- Restricted product routes should redirect to `/dashboard` unless a specific access-state UX is implemented and tested. +- Use object routes as the default pattern unless React Router data APIs require a later move to `createBrowserRouter`. diff --git a/frontend/docs/personality-catalog.md b/frontend/docs/personality-catalog.md new file mode 100644 index 0000000..1bb6675 --- /dev/null +++ b/frontend/docs/personality-catalog.md @@ -0,0 +1,23 @@ +# Personality Catalog + +## Purpose + +Static emotional-intelligence personality quiz content lives in `frontend/src/shared/constants/personalityCatalog.ts`. + +## Contents + +- `PERSONALITY_QUIZ_QUESTIONS` +- `PERSONALITY_TYPES` +- `calculateMBTI` +- `getPersonalityType` +- static catalog types for quiz questions and personality descriptions + +## Boundary + +This file is product-static catalog content. Persisted user personality results remain in: + +- API layer: `frontend/src/shared/api/personality.ts` +- DTO types: `frontend/src/shared/types/personality.ts` +- Business layer: `frontend/src/business/personality/` + +Do not store user answers, quiz results, or tenant-owned personality data in the static catalog. diff --git a/frontend/docs/personality-integration.md b/frontend/docs/personality-integration.md new file mode 100644 index 0000000..dcfb523 --- /dev/null +++ b/frontend/docs/personality-integration.md @@ -0,0 +1,66 @@ +# Personality Frontend Integration + +## Purpose + +EI/personality result loading, saving, and aggregate distribution follow the frontend three-layer architecture. + +```text +View -> Business Logic -> API/Data Access -> Backend +``` + +## Files + +View layer: + +- `frontend/src/components/frameworks/EmotionalIntelligence.tsx` +- `frontend/src/components/emotional-intelligence/AssessmentTab.tsx` +- `frontend/src/components/emotional-intelligence/EmotionalIntelligenceHeader.tsx` +- `frontend/src/components/emotional-intelligence/EmotionalIntelligenceTabs.tsx` +- `frontend/src/components/emotional-intelligence/PersonalityDistributionPanel.tsx` +- `frontend/src/components/emotional-intelligence/PersonalityQuizTab.tsx` +- `frontend/src/components/emotional-intelligence/SavedPersonalityBanner.tsx` +- `frontend/src/components/emotional-intelligence/WeeklyFocusBanner.tsx` +- `frontend/src/components/emotional-intelligence/groupStyles.ts` +- `frontend/src/components/emotional-intelligence/types.ts` +- `frontend/src/components/frameworks/PersonalityQuiz.tsx` +- `frontend/src/components/personality-quiz/` +- `frontend/src/components/frameworks/PersonalityDirectory.tsx` +- `frontend/src/components/personality-directory/` + +Business logic layer: + +- `frontend/src/business/personality/queryHooks.ts` +- `frontend/src/business/personality/directoryHooks.ts` +- `frontend/src/business/personality/emotionalIntelligenceHooks.ts` +- `frontend/src/business/personality/quizWorkflowHooks.ts` +- `frontend/src/business/personality/mappers.ts` +- `frontend/src/business/personality/selectors.ts` +- `frontend/src/business/personality/types.ts` +- `frontend/src/shared/constants/emotionalIntelligence.ts` +- `frontend/src/shared/types/emotionalIntelligence.ts` + +API/data access layer: + +- `frontend/src/shared/api/personality.ts` +- `frontend/src/shared/types/personality.ts` + +## Behavior + +- The current user's saved personality result loads from `GET /api/personality_quiz_results/me`. +- Quiz completion saves through `PUT /api/personality_quiz_results/me`. +- Director and superintendent aggregate distribution loads from `GET /api/personality_quiz_results/distribution`. +- Backend errors are surfaced as UI error states instead of being swallowed. +- `EmotionalIntelligence.tsx` is a thin composition wrapper. +- `PersonalityQuiz.tsx` is a thin composition wrapper. +- `PersonalityDirectory.tsx` is a thin composition wrapper. +- EI self-assessment state, score calculation, level selection, personality distribution grouping, and personality save coordination live in `frontend/src/business/personality/`. +- Personality quiz flow state, saved-result hydration, result tab state, progress calculation, relationship tips, workplace language strengths, and result formatting live in `frontend/src/business/personality/`. +- Personality directory search, group filtering, expanded type state, and active detail section state live in `frontend/src/business/personality/`. +- Personality business hooks are split by workflow: backend query/mutation hooks, directory workflow, EI page workflow, and quiz workflow. Imports use the workflow-specific files directly; there is no legacy re-export surface. +- EI questions, topics, growth tips, MBTI dimensions, and workplace tips live in shared constants. +- Personality type directory records load from the backend content catalog. +- The frontend does not write personality type to staff profile records. + +## Verification + +Focused personality selector tests cover distribution totals/grouping, EI level thresholds, saved-date formatting, quiz progress, dimension progress, type breakdowns, relationship tips, communication strengths, communication growth guidance, and personality directory filtering. These tests also guard the S/J and S/P grouping behavior. diff --git a/frontend/docs/policies-integration.md b/frontend/docs/policies-integration.md new file mode 100644 index 0000000..29eae34 --- /dev/null +++ b/frontend/docs/policies-integration.md @@ -0,0 +1,51 @@ +# Policies Integration + +## Purpose + +The handbook and policies workflow reads and mutates policy documents through the backend `documents` API. + +## Frontend Structure + +- Framework wrapper: `frontend/src/components/frameworks/HandbookPolicy.tsx` +- Focused view components: `frontend/src/components/policies/` +- Business hooks: `frontend/src/business/policies/hooks.ts` +- Business page workflow hook: `frontend/src/business/policies/pageHooks.ts` +- Business mappers: `frontend/src/business/policies/mappers.ts` +- Business selectors: `frontend/src/business/policies/selectors.ts` +- API layer: `frontend/src/shared/api/documents.ts` +- DTO types: `frontend/src/shared/types/documents.ts` +- Policy types/constants: `frontend/src/shared/types/policies.ts`, `frontend/src/shared/constants/policies.ts` + +## Backend Contract + +The slice uses the existing backend route: + +- `GET /api/documents?category=policy` +- `POST /api/documents` +- `PUT /api/documents/:id` +- `DELETE /api/documents/:id` + +Policy records are represented as `documents` rows: + +- `category`: fixed to `policy` +- `entity_type`: fixed to `organization` +- `entity_reference`: policy category displayed in the handbook +- `name`: policy title +- `notes`: policy content +- `uploaded_at`: recorded mutation timestamp + +## Behavior + +- `HandbookPolicy` is a thin wrapper that calls `usePoliciesPage` and renders `PoliciesView`. +- Policy UI is split into focused components for the hero, create form, filters, status panels, list, cards, and empty state. +- Policy forms and filters use shared UI primitives: `Button`, `Input`, `Textarea`, `NativeSelect`, and `StatePanel`. +- Policy list/create/update/delete flows use React Query hooks and backend error states. +- Director and superintendent roles can manage policies in the frontend. +- Acknowledgement state remains session-local because the backend does not yet expose a policy acknowledgement contract. + +## Verification + +- `npm run build` passes. +- `npm run lint` passes for the current frontend baseline. +- `npm run typecheck` passes. +- `npm run test` passes. diff --git a/frontend/docs/safety-quiz-integration.md b/frontend/docs/safety-quiz-integration.md new file mode 100644 index 0000000..452dab9 --- /dev/null +++ b/frontend/docs/safety-quiz-integration.md @@ -0,0 +1,57 @@ +# Safety Quiz Frontend Integration + +## Purpose + +Safety/QBS quiz results follow the frontend three-layer architecture. + +```text +View -> Business Logic -> API/Data Access -> Backend +``` + +## Files + +View layer: + +- `frontend/src/components/frameworks/QBSSafety.tsx` +- `frontend/src/components/safety-quiz/` +- `frontend/src/components/frameworks/DirectorDashboard.tsx` +- `frontend/src/components/director-dashboard/DirectorQuizResultsPanel.tsx` +- `frontend/src/components/director-dashboard/DirectorRiskList.tsx` +- `frontend/src/components/safety-quiz/SafetyQuizContentEditorPanel.tsx` + +Business logic layer: + +- `frontend/src/business/safety-quiz/hooks.ts` +- `frontend/src/business/safety-quiz/mappers.ts` +- `frontend/src/business/safety-quiz/selectors.ts` +- `frontend/src/business/safety-quiz/types.ts` +- `frontend/src/business/director-dashboard/hooks.ts` +- `frontend/src/business/director-dashboard/selectors.ts` + +API/data access layer: + +- `frontend/src/shared/api/safetyQuizResults.ts` +- `frontend/src/shared/types/safetyQuiz.ts` + +Constants: + +- `frontend/src/shared/constants/safetyQuiz.ts` + +## Behavior + +- Quiz submission uses `POST /api/safety_quiz_results`. +- Staff completion and director dashboard rows load from `GET /api/safety_quiz_results`. +- QBS quiz content loads from `GET /api/public/content-catalog/safety-qbs-quiz`. +- Directors and superintendents can edit the QBS quiz content through the authenticated content catalog endpoint `PUT /api/content-catalog/safety-qbs-quiz`. +- Editable QBS quiz payloads are JSON-validated in the business layer before saving. +- Compliance views render empty and error states explicitly instead of substituting static staff rows. +- Result ownership is derived by the backend from the authenticated session. +- `QBSSafety.tsx` is a thin wrapper that calls `useSafetyQuizPage` and renders focused safety quiz view components. +- Quiz score, progress, result feedback, and compliance summary are derived in business selectors. +- Weekly focus and key reminders are backend content payload fields, not frontend constants. +- Safety quiz view components use shared `Button`, `StatePanel`, and `ModuleHeader` primitives. +- Director dashboard derives QBS completion metrics and risk rows in business selectors. + +## Remaining Related Work + +If compliance needs pending/overdue rows for all staff, add a backend summary endpoint that joins staff membership with submitted results. Do not recreate pending staff rows in frontend static data. diff --git a/frontend/docs/shared-app-types.md b/frontend/docs/shared-app-types.md new file mode 100644 index 0000000..abaaeb9 --- /dev/null +++ b/frontend/docs/shared-app-types.md @@ -0,0 +1,26 @@ +# Shared App Types + +## Purpose + +UI-facing product types live in `frontend/src/shared/types/app.ts`. + +## Contents + +`frontend/src/shared/types/app.ts` contains cross-module product types such as: + +- `UserRole` +- `CampusId` +- `CampusInfo` +- `StaffProfile` +- `ModuleId` +- `Module` +- `ZoneColor` +- cross-module static catalog item types used by the current UI + +Backend DTO contracts remain in dedicated files under `frontend/src/shared/types/`, for example `auth.ts`, `frame.ts`, `campusAttendance.ts`, and `documents.ts`. + +## Rules + +- New shared UI/product types should go in `frontend/src/shared/types/app.ts` only when they are genuinely cross-module. +- Backend request/response DTOs should stay in feature-specific shared type files. +- Feature-local view model types should stay in the relevant `frontend/src/business//types.ts` or component `types.ts` file. diff --git a/frontend/docs/sidebar-integration.md b/frontend/docs/sidebar-integration.md new file mode 100644 index 0000000..bf121dc --- /dev/null +++ b/frontend/docs/sidebar-integration.md @@ -0,0 +1,50 @@ +# Sidebar Integration + +## Purpose + +`Sidebar` renders app-shell navigation, module access, campus branding, and the current role badge through the three-layer frontend architecture. + +```text +View -> Business Logic -> API/Data Access -> Backend +``` + +Sidebar does not own product content records. It reads module metadata from shared app constants and uses backend-owned campus branding received from the app shell. + +## Frontend Layers + +View: + +- `frontend/src/components/frameworks/Sidebar.tsx` +- `frontend/src/components/sidebar/SidebarView.tsx` +- `frontend/src/components/sidebar/SidebarBrand.tsx` +- `frontend/src/components/sidebar/SidebarNavigation.tsx` +- `frontend/src/components/sidebar/SidebarCampusRolePanel.tsx` +- `frontend/src/components/sidebar/SidebarIcons.tsx` + +Business logic: + +- `frontend/src/business/app-shell/hooks.ts` +- `frontend/src/business/app-shell/selectors.ts` +- `frontend/src/business/app-shell/types.ts` + +Shared config: + +- `frontend/src/shared/constants/appData.ts` + +## Behavior + +- `Sidebar.tsx` is a thin wrapper that prepares the page model through `useSidebarPage`. +- `AppLayout` receives prepared `sidebarProps` from `useAppShell` and does not assemble sidebar contracts inline. +- `useSidebarPage` filters available modules by role and prepares role/campus display values. +- `useAppShell` resolves backend-owned campus branding into `campusInfo` before passing it to Sidebar. +- Selectors handle module access, role labels, and campus initials outside JSX. +- Sidebar navigation calls the app-shell module navigation action, which navigates to the selected module route path. +- The active sidebar item is derived from the current URL route through the app-shell business layer. +- View components receive a prepared page model and do not call API/data access modules. +- Sidebar navigation uses the local `Button` primitive instead of raw controls. + +## Data Ownership Rules + +- Do not add seeded campus records or module content to Sidebar components. +- Campus branding is backend-owned data and should keep flowing through `campusInfo`. +- Module IDs, route paths, and role access metadata stay centralized in `frontend/src/shared/constants/appData.ts` until backend-owned module configuration is introduced. diff --git a/frontend/docs/sign-language-integration.md b/frontend/docs/sign-language-integration.md new file mode 100644 index 0000000..abb4d37 --- /dev/null +++ b/frontend/docs/sign-language-integration.md @@ -0,0 +1,70 @@ +# Sign Language Integration + +## Purpose + +`SignLanguage` renders backend-owned sign catalog content and current-user learned progress through the three-layer frontend architecture. + +```text +View -> Business Logic -> API/Data Access -> Backend +``` + +Runtime sign records, teaching tips, video URLs, GIF URLs, step instructions, and page-level teaching reminders belong to backend content catalog payloads. The frontend owns only UI state, filter config, style tokens, and progress interaction wiring. + +## Frontend Layers + +View: + +- `frontend/src/components/frameworks/SignLanguage.tsx` +- `frontend/src/components/frameworks/SignLanguageVideoModal.tsx` +- `frontend/src/components/sign-language/SignLanguageView.tsx` +- `frontend/src/components/sign-language/SignLanguageHeader.tsx` +- `frontend/src/components/sign-language/SignLanguageRememberPanel.tsx` +- `frontend/src/components/sign-language/SignLanguageProgressPanel.tsx` +- `frontend/src/components/sign-language/SignLanguageFilters.tsx` +- `frontend/src/components/sign-language/SignLanguageGrid.tsx` +- `frontend/src/components/sign-language/SignLanguageCard.tsx` +- `frontend/src/components/sign-language/SignLanguageVideoModal.tsx` + +Business logic: + +- `frontend/src/business/sign-language/hooks.ts` +- `frontend/src/business/sign-language/selectors.ts` +- `frontend/src/business/sign-language/types.ts` + +Shared contracts and UI config: + +- `frontend/src/shared/types/app.ts` +- `frontend/src/shared/constants/signLanguage.ts` +- `frontend/src/shared/constants/contentCatalog.ts` + +## Backend Contracts + +The page reads content from: + +- `GET /api/public/content-catalog/sign-language-items` +- `GET /api/public/content-catalog/sign-language-page-content` + +Learned progress uses: + +- `GET /api/user_progress?progress_type=sign_learned` +- `POST /api/user_progress` +- `DELETE /api/user_progress/by-item` + +Content payloads are seeded in: + +- `backend/src/db/seeders/content-catalog-data/content-catalog-seed-payloads.js` + +## Behavior + +- `useSignLanguagePage` loads sign items, page content, and learned sign progress. +- Selectors handle category counts, search/category filtering, progress percentage, video duration, filter normalization, and YouTube search URL construction. +- View components receive a prepared page model and do not call API/data access modules. +- The video modal uses `useSignLanguageVideoModalState` for GIF/video mode, GIF loading state, and step-guide expansion. +- Loading, empty, and error states are explicit through `StatePanel`. + +## Data Ownership Rules + +- Do not add sign records, teaching tips, page reminders, video URLs, GIF URLs, or step instructions to frontend constants. +- Do not add frontend fallback sign payloads. +- Keep frontend constants limited to filter options, category style classes, external URL templates, and UI view modes. +- Test-only fixtures may live in selector tests or `frontend/src/test-seeds/`. diff --git a/frontend/docs/staff-attendance-integration.md b/frontend/docs/staff-attendance-integration.md new file mode 100644 index 0000000..b829862 --- /dev/null +++ b/frontend/docs/staff-attendance-integration.md @@ -0,0 +1,47 @@ +# Staff Attendance Frontend Integration + +## Purpose + +Staff attendance snapshot and director dashboard staff counts follow the frontend three-layer architecture. + +```text +View -> Business Logic -> API/Data Access -> Backend +``` + +## Files + +View layer: + +- `frontend/src/components/staff-attendance/AttendanceModule.tsx` +- `frontend/src/components/frameworks/MoreModules.tsx` as a module export hub +- `frontend/src/components/frameworks/DirectorDashboard.tsx` +- `frontend/src/components/director-dashboard/DirectorOverviewCards.tsx` +- `frontend/src/components/director-dashboard/DirectorRiskList.tsx` + +Business logic layer: + +- `frontend/src/business/staff-attendance/hooks.ts` +- `frontend/src/business/staff-attendance/mappers.ts` +- `frontend/src/business/staff-attendance/selectors.ts` +- `frontend/src/business/staff-attendance/types.ts` +- `frontend/src/business/director-dashboard/hooks.ts` +- `frontend/src/business/director-dashboard/selectors.ts` + +API/data access layer: + +- `frontend/src/shared/api/staffAttendance.ts` +- `frontend/src/shared/types/staffAttendance.ts` +- `frontend/src/shared/constants/staffAttendance.ts` + +## Behavior + +- Staff attendance records load from `GET /api/staff_attendance/records`. +- Staff attendance summary loads from `GET /api/staff_attendance/summary`. +- Regular staff receive their own records from the backend. +- Director dashboard receives scoped report records and active staff count from the backend. +- Attendance UI uses shared table and status panel primitives while keeping data loading in the business layer. +- Director dashboard derives attendance metrics and absence risk rows in business selectors. + +## Remaining Related Work + +Add staff attendance import/write UI only after the external attendance source contract is defined. diff --git a/frontend/docs/static-app-data.md b/frontend/docs/static-app-data.md new file mode 100644 index 0000000..2428f56 --- /dev/null +++ b/frontend/docs/static-app-data.md @@ -0,0 +1,21 @@ +# Static App Data + +## Purpose + +Static UI configuration lives in `frontend/src/shared/constants/appData.ts`. + +## Current Contents + +The file contains only non-secret frontend configuration and static UI assets: + +- module navigation metadata +- hero and handbook images + +## Rules + +- Keep only UI configuration and static UI assets in this file. +- Do not use this file for tenant-owned persisted records or product/content catalogs. +- Move newly persisted workflows to typed backend APIs and business hooks. +- Further domain split is allowed when UI configuration becomes large enough to justify its own shared constant file. +- Campus records and branding are backend-owned and are loaded through `GET /api/public/campuses`; frontend campus helpers must not define campus rows, names, mascot labels, descriptions, or per-campus branding. +- Product/content catalogs are backend-owned and are loaded through `GET /api/public/content-catalog/:contentType`. diff --git a/frontend/docs/test-coverage.md b/frontend/docs/test-coverage.md new file mode 100644 index 0000000..ebaebbf --- /dev/null +++ b/frontend/docs/test-coverage.md @@ -0,0 +1,127 @@ +# Frontend Test Coverage + +## Purpose + +Frontend tests protect the three-layer architecture by testing API/data-access and business logic directly, without rendering full product modules when rendering is not needed. + +## Test Runner + +- `npm run test` runs `vitest run`. +- `npm run test:e2e` runs backend-free Playwright smoke tests against a production build served by Vite preview. +- `npm run test:e2e:content` runs backend-seeded content catalog Playwright tests against a production build served by Vite preview. +- `npm run typecheck` remains the TypeScript gate. +- `npm run build` runs typecheck before Vite. + +## Current Coverage + +Current coverage includes architecture guardrails, API/data-access behavior, and pure business-layer behavior in these files: + +- `frontend/src/app/appRoutes.test.ts` +- `frontend/src/business/app-shell/selectors.test.ts` +- `frontend/src/business/auth/mappers.test.ts` +- `frontend/src/business/auth/selectors.test.ts` +- `frontend/src/business/campus-attendance/mappers.test.ts` +- `frontend/src/business/campus-attendance/printReport.test.ts` +- `frontend/src/business/campus-attendance/selectors.test.ts` +- `frontend/src/business/campuses/mappers.test.ts` +- `frontend/src/business/classroom-support/selectors.test.ts` +- `frontend/src/business/classroom-timer/selectors.test.ts` +- `frontend/src/business/communications/selectors.test.ts` +- `frontend/src/business/community/selectors.test.ts` +- `frontend/src/business/dashboard/selectors.test.ts` +- `frontend/src/business/director-dashboard/selectors.test.ts` +- `frontend/src/business/esa-funding/selectors.test.ts` +- `frontend/src/business/frame/mappers.test.ts` +- `frontend/src/business/frame/selectors.test.ts` +- `frontend/src/business/personality/mappers.test.ts` +- `frontend/src/business/personality/selectors.test.ts` +- `frontend/src/business/policies/mappers.test.ts` +- `frontend/src/business/policies/selectors.test.ts` +- `frontend/src/business/safety-quiz/mappers.test.ts` +- `frontend/src/business/safety-quiz/selectors.test.ts` +- `frontend/src/business/sign-language/selectors.test.ts` +- `frontend/src/business/staff-attendance/mappers.test.ts` +- `frontend/src/business/staff-attendance/selectors.test.ts` +- `frontend/src/business/top-bar/selectors.test.ts` +- `frontend/src/business/user-progress/mappers.test.ts` +- `frontend/src/business/vocational/selectors.test.ts` +- `frontend/src/business/walkthrough/mappers.test.ts` +- `frontend/src/business/walkthrough/selectors.test.ts` +- `frontend/src/business/walkthrough/validators.test.ts` +- `frontend/src/business/zones/selectors.test.ts` +- `frontend/src/shared/api/auth.test.ts` +- `frontend/src/shared/api/campusAttendance.test.ts` +- `frontend/src/shared/api/campuses.test.ts` +- `frontend/src/shared/api/communications.test.ts` +- `frontend/src/shared/api/contentCatalog.test.ts` +- `frontend/src/shared/api/documents.test.ts` +- `frontend/src/shared/api/frame.test.ts` +- `frontend/src/shared/api/httpClient.test.ts` +- `frontend/src/shared/api/personality.test.ts` +- `frontend/src/shared/api/safetyQuizResults.test.ts` +- `frontend/src/shared/api/staffAttendance.test.ts` +- `frontend/src/shared/api/userProgress.test.ts` +- `frontend/src/shared/api/walkthrough.test.ts` +- `frontend/src/shared/architecture/import-boundaries.test.ts` +- `frontend/src/shared/business/apiListRows.test.ts` +- `frontend/src/shared/business/queryState.test.ts` +- `frontend/src/shared/constants/moduleRoutes.test.ts` +- `frontend/src/shared/errors/errorMessages.test.ts` + +These tests verify import-boundary enforcement, centralized network access through `shared/api/httpClient`, alias-only source imports, required API contract-test coverage, HTTP client request normalization, cookie-backed auth refresh behavior, JSON body serialization, empty response handling, backend error propagation, API wrapper endpoint/query/body contracts, shared list/query/error helpers, route config, module route metadata, module access correction, sidebar navigation selectors, mobile sidebar overlay visibility, auth display/profile mapping, signup validation, campus mapping, dashboard and director dashboard selectors, classroom support selectors, timer formatting/progress/threshold parsing, DTO mapping, FRAME edit access, campus attendance mapping, calculations, and printable report output, staff attendance mapping and rollups, walkthrough create DTO mapping, walkthrough filtering, walkthrough check-in stats/history mapping, walkthrough scoring, generated summaries, repeated low-rating flags, communication role visibility, community catalog filtering and stats, ESA funding selectors, policy mapping/filtering/validation, safety quiz compliance mapping and editable payload validation, sign language selectors, top bar display selectors, user progress normalization, vocational zip/category/search/stat calculations, zones selectors, personality DTO mapping, personality distribution grouping, EI thresholds, personality quiz progress, and MBTI-derived communication guidance. + +The personality selector tests caught and now guard against a grouping defect where S/J and S/P types were classified from the third code character instead of the fourth code character. + +The import-boundary tests enforce the three-layer dependency direction: + +- API modules must not import business or view modules. +- Business modules must not import view modules. +- View modules must not call `frontend/src/shared/api/` directly. +- Source imports must use the configured `@` alias instead of relative paths. +- Direct `fetch` calls must stay centralized in `frontend/src/shared/api/httpClient.ts`. +- Runtime data access must stay centralized in the shared API layer. +- Every runtime module in `frontend/src/shared/api/` must have a colocated `.test.ts` contract test. + +## Current Smoke Coverage + +Playwright smoke tests live under: + +- `frontend/tests/e2e/app-shell.e2e.ts` + +The current smoke suite covers: + +- teacher guest access to staff modules and absence of director-only modules; +- restricted executive route redirect for teacher guests; +- director guest access to director and walk-through navigation; +- superintendent guest access persistence after navigating between executive modules. + +## Backend-Seeded Content E2E Coverage + +Backend-seeded content tests live under: + +- `frontend/tests/e2e/content-catalog.seeded.e2e.ts` + +The seeded suite is intentionally excluded from default `npm run test:e2e` through `frontend/playwright.config.ts`. Run it with: + +```bash +VITE_BACKEND_API_URL=http://localhost:8080/api npm run test:e2e:content +``` + +Prerequisites: + +- backend database migrations have run; +- backend database seeders have run; +- backend server is running at `VITE_BACKEND_API_URL`. + +The seeded suite verifies the minimum content catalog seed set through the public backend API, then renders classroom timer, classroom support, sign language, and zones routes with UI assertions based on the live backend response. + +## Rules For New Tests + +- Prefer testing selectors, mappers, validators, and calculations before rendering full components. +- Cover shared API/client behavior with mocked `fetch`; do not call a live backend from unit tests. +- Keep import-boundary tests updated when adding a new top-level layer directory. +- Keep tests deterministic: no live backend, no network, and no current-time dependency unless the current date is injected. +- Use typed fixtures instead of `any` or unsafe casts. +- Add React/hook tests only when a workflow cannot be verified through pure functions. +- Keep backend-free Playwright smoke tests focused on high-value role/navigation workflows. +- Put live backend/database assertions only in explicit seeded suites such as `test:e2e:content`. diff --git a/frontend/docs/test-seeds.md b/frontend/docs/test-seeds.md new file mode 100644 index 0000000..4386fed --- /dev/null +++ b/frontend/docs/test-seeds.md @@ -0,0 +1,24 @@ +# Test Seeds + +## Purpose + +Runtime constants must not contain mock users, sample staff, seeded persisted records, fake people names, or test-only dates. Test-only records live in dedicated seed files so production code cannot accidentally depend on them. + +## Location + +Frontend test seeds live under: + +- `frontend/src/test-seeds/` + +Current seed files: + +- `frontend/src/test-seeds/walkthrough.ts` +- `frontend/src/test-seeds/campusAttendance.ts` +- `frontend/src/test-seeds/campuses.ts` + +## Rules + +- Do not import `frontend/src/test-seeds/` from runtime code. +- Use test seeds only from `.test.ts` or `.test.tsx` files. +- Keep product-static catalogs in `frontend/src/shared/constants/` only when they are real product content, not persisted sample records. +- Do not add fake staff, fake users, fake campuses, sample dates, or seeded persisted rows to shared constants. diff --git a/frontend/docs/theme.md b/frontend/docs/theme.md new file mode 100644 index 0000000..cd4cfd2 --- /dev/null +++ b/frontend/docs/theme.md @@ -0,0 +1,21 @@ +# Theme + +## Purpose + +Frontend theme configuration is centralized so visual tokens, runtime theme state, and Tailwind mappings do not drift apart. + +## Files + +- `frontend/src/shared/constants/theme.ts` owns theme names, default theme values, CSS class names, and media query constants. +- `frontend/src/shared/constants/storage.ts` owns the local storage key used to persist the user theme preference. +- `frontend/src/components/theme-context.ts` owns the React context type and hook only. +- `frontend/src/components/theme-provider.tsx` owns runtime theme application to `document.documentElement`. +- `frontend/src/index.css` owns global CSS variables for light and dark theme tokens. +- `frontend/tailwind.config.ts` maps Tailwind color tokens to the CSS variables from `index.css`. + +## Rules + +- Do not hardcode theme names such as `dark`, `light`, or `system` outside `frontend/src/shared/constants/theme.ts`. +- Do not hardcode the theme storage key outside `frontend/src/shared/constants/storage.ts`. +- Add new design tokens as CSS variables in `frontend/src/index.css`, then expose them through `frontend/tailwind.config.ts` when Tailwind classes need them. +- Keep one-off module status colors in module constants only when they are domain semantics, not global theme tokens. diff --git a/frontend/docs/top-bar-integration.md b/frontend/docs/top-bar-integration.md new file mode 100644 index 0000000..0e946ff --- /dev/null +++ b/frontend/docs/top-bar-integration.md @@ -0,0 +1,47 @@ +# Top Bar Integration + +## Purpose + +`TopBar` renders app-shell search, auth entry, campus/role badges, notifications, and profile menu through the three-layer frontend architecture. + +```text +View -> Business Logic -> API/Data Access -> Backend +``` + +TopBar does not own product content records. It owns shell UI state and delegates auth actions to the auth session. + +## Frontend Layers + +View: + +- `frontend/src/components/frameworks/TopBar.tsx` +- `frontend/src/components/top-bar/TopBarView.tsx` +- `frontend/src/components/top-bar/TopBarSearch.tsx` +- `frontend/src/components/top-bar/TopBarBadges.tsx` +- `frontend/src/components/top-bar/TopBarNotifications.tsx` +- `frontend/src/components/top-bar/TopBarProfileMenu.tsx` +- `frontend/src/components/top-bar/TopBarSignInModal.tsx` + +Business logic: + +- `frontend/src/business/top-bar/hooks.ts` +- `frontend/src/business/top-bar/selectors.ts` +- `frontend/src/business/top-bar/types.ts` + +Shared config: + +- `frontend/src/shared/constants/topBar.ts` + +## Behavior + +- `TopBar.tsx` is a thin wrapper that reads auth session state and passes it into `useTopBarPage`. +- `useTopBarPage` owns profile menu state, notifications menu state, sign-in modal state, search query state, and sign-out error state. +- Selectors handle initials, campus label fallback, shared role labels, and unread notification count. +- View components receive a prepared page model and do not call API/data access modules. +- Profile and settings menu items are explicitly disabled until product workflows exist, instead of rendering silent no-op buttons. + +## Data Ownership Rules + +- Do not add notification seed data to frontend constants. +- Keep TopBar constants limited to role badge classes and static menu labels. +- Future persisted notifications should use a backend API and a dedicated business hook. diff --git a/frontend/docs/ui-kit.md b/frontend/docs/ui-kit.md new file mode 100644 index 0000000..db49d57 --- /dev/null +++ b/frontend/docs/ui-kit.md @@ -0,0 +1,72 @@ +# UI Kit + +## Purpose + +The frontend uses the local UI kit in `frontend/src/components/ui/` as the shared view-layer foundation. Product components should compose these primitives instead of duplicating low-level button, input, table, and status markup. + +## Current Foundation + +- `button.tsx` with variants in `button-variants.ts`; it also owns shared loading and leading-icon button behavior. +- `input.tsx`, `textarea.tsx`, `native-select.tsx`, `select.tsx`, `checkbox.tsx`, `radio-group.tsx`, and `form.tsx` for form controls. +- `table.tsx` for structured tables. +- `card.tsx`, `alert.tsx`, `badge.tsx`, `tabs.tsx`, `dialog.tsx`, `tooltip.tsx`, and other shadcn-style primitives. +- `state-panel.tsx` with variants in `state-panel-variants.ts` for loading, error, and empty states. +- `module-header.tsx` for repeated module title, icon, and description headers. + +## StatePanel Usage + +Use `StatePanel` when a feature needs a standard loading, error, or empty block. + +```tsx + + Loading assessment content... + +``` + +```tsx + + Assessment content could not be loaded from the backend. + +``` + +Feature-specific wrapper components may stay in their module folder when they carry product copy or workflow-specific props. Those wrappers should delegate the repeated shell to `StatePanel`. + +## Button Usage + +Use `Button` for new clickable commands. Prefer `leadingIcon`, `loading`, and `loadingLabel` instead of duplicating inline spinner branches. + +```tsx + +``` + +Feature components may pass product-specific `className` values while the shared component owns disabled and busy behavior. + +## Table Usage + +Use `Table`, `TableHeader`, `TableBody`, `TableRow`, `TableHead`, and `TableCell` for structured table markup. Preserve feature-specific density and colors with `className`; do not duplicate native `` shells in new code. + +## Form Controls + +Use `Input` for text-like controls and `NativeSelect` for simple browser-native select fields. Use the Radix `Select` primitive only when the product requires custom dropdown behavior. + +## Module Headers + +Use `ModuleHeader` for repeated module-level title blocks with a square icon and short description. Feature headers may still render additional callouts or actions around it. + +## Rules + +- Keep UI variants and class maps in dedicated non-component files. +- Use `StatePanel` for repeated loading, error, and empty panels instead of copying Tailwind containers. +- Use existing `Button`, `Input`, `Textarea`, `Select`, and `Table` primitives for new view code. +- Use `NativeSelect` for simple select fields where native browser behavior is sufficient. +- Use `ModuleHeader` for repeated page/module headings instead of duplicating title/icon markup. +- Do not add another external UI kit unless the existing primitives cannot support a concrete product requirement. +- Do not move business rules into UI primitives. UI kit components should stay presentation-focused. + +## Remaining Consolidation Candidates + +- Repeated statistic cards can be promoted to a shared metric component after at least two active modules need the same prop shape. +- New structured table views should use `table.tsx`; existing feature tables can switch to it when product work touches those modules. +- Module-specific wrapper components should stay local until the same component contract is repeated across active modules. diff --git a/frontend/docs/user-progress-integration.md b/frontend/docs/user-progress-integration.md new file mode 100644 index 0000000..8b2a097 --- /dev/null +++ b/frontend/docs/user-progress-integration.md @@ -0,0 +1,55 @@ +# User Progress Frontend Integration + +## Purpose + +User progress follows the frontend three-layer architecture for sign language progress and dashboard zone check-ins. + +```text +View -> Business Logic -> API/Data Access -> Backend +``` + +## Files + +View layer: + +- `frontend/src/components/frameworks/SignLanguage.tsx` +- `frontend/src/components/sign-language/SignLanguageProgressPanel.tsx` +- `frontend/src/components/sign-language/SignLanguageVideoModal.tsx` +- `frontend/src/components/frameworks/Dashboard.tsx` +- `frontend/src/components/dashboard/DashboardZoneCheckIn.tsx` +- `frontend/src/components/frameworks/ZonesOfRegulation.tsx` +- `frontend/src/components/zones-of-regulation/ZonesOfRegulationView.tsx` + +Business logic layer: + +- `frontend/src/business/dashboard/hooks.ts` +- `frontend/src/business/dashboard/selectors.ts` +- `frontend/src/business/sign-language/hooks.ts` +- `frontend/src/business/sign-language/selectors.ts` +- `frontend/src/business/user-progress/hooks.ts` +- `frontend/src/business/user-progress/mappers.ts` +- `frontend/src/business/user-progress/types.ts` + +API/data access layer: + +- `frontend/src/shared/api/userProgress.ts` +- `frontend/src/shared/types/userProgress.ts` + +Constants: + +- `frontend/src/shared/constants/userProgress.ts` + +## Behavior + +- Learned sign IDs load from `GET /api/user_progress?progress_type=sign_learned`. +- Marking a sign learned uses `POST /api/user_progress`. +- Unmarking a sign uses `DELETE /api/user_progress/by-item`. +- The sign language page combines user progress with backend content catalog records in `useSignLanguagePage`. +- Dashboard zone check-in uses `item_id=current` and `progress_type=zone_checkin`; dashboard page composition lives in `useDashboardPage`. +- The zones of regulation page currently renders content catalog records only. It does not persist check-ins; adding that interaction requires a dedicated UX task. +- Views render explicit backend errors from React Query state. +- User progress ownership is derived by the backend from the authenticated session. + +## Remaining Related Work + +Other module progress types should reuse this API only when the data is truly current-user progress. Aggregate director reporting should use separate backend summary endpoints. diff --git a/frontend/docs/vocational-opportunities.md b/frontend/docs/vocational-opportunities.md new file mode 100644 index 0000000..fe93fe8 --- /dev/null +++ b/frontend/docs/vocational-opportunities.md @@ -0,0 +1,48 @@ +# Vocational Opportunities Frontend Slice + +## Purpose + +Vocational Opportunities follows the frontend three-layer architecture and loads opportunity records from the backend content catalog. + +```text +View -> Business Logic -> API/Data Access -> Backend +``` + +The frontend keeps only presentation configuration in shared constants. Vocational opportunity records are backend-owned runtime content. + +## Files + +View layer: + +- `frontend/src/components/frameworks/VocationalOpportunities.tsx` +- `frontend/src/components/vocational-opportunities/` + +Business logic layer: + +- `frontend/src/business/vocational/hooks.ts` +- `frontend/src/business/vocational/selectors.ts` + +Shared constants and types: + +- `frontend/src/shared/constants/contentCatalog.ts` +- `frontend/src/shared/constants/vocational.ts` +- `frontend/src/shared/types/vocational.ts` + +API/data access layer: + +- `frontend/src/shared/api/contentCatalog.ts` +- `frontend/src/shared/types/contentCatalog.ts` + +## Behavior + +- The framework component is a thin wrapper around `useVocationalOpportunities`. +- Opportunity records load from `GET /api/public/content-catalog/vocational-opportunities`. +- Shared constants own zip search configuration, category previews, category icon keys, and style tokens. +- Business selectors own zip normalization, local search result derivation, filtering, category lists, and stats. +- Business hook owns content loading, zip input, search state, search text, category filter, expanded card state, and saved opportunity state. +- View components render header, zip search, pre-search state, loading state, filters, stats, result cards, and empty results. +- Views render explicit loading and error states from the content catalog query. + +## Management Contract + +If vocational opportunities become tenant-owned or admin-editable beyond content catalog management, add a typed backend management contract and keep the frontend integration on the same path: `shared/api` + `business/vocational` + thin views. diff --git a/frontend/docs/walkthrough-integration.md b/frontend/docs/walkthrough-integration.md new file mode 100644 index 0000000..a49cc94 --- /dev/null +++ b/frontend/docs/walkthrough-integration.md @@ -0,0 +1,55 @@ +# Walk-Through Frontend Integration + +## Purpose + +Walk-through check-ins follow the frontend three-layer architecture. + +```text +View -> Business Logic -> API/Data Access -> Backend +``` + +## Files + +View layer: + +- `frontend/src/components/frameworks/WalkThroughCheckIn.tsx` +- `frontend/src/components/frameworks/WalkThroughForm.tsx` +- `frontend/src/components/frameworks/WalkThroughSummary.tsx` +- `frontend/src/components/walkthrough-checkin/` +- `frontend/src/components/walkthrough-form/` +- `frontend/src/components/walkthrough-summary/` + +Business logic layer: + +- `frontend/src/business/walkthrough/hooks.ts` +- `frontend/src/business/walkthrough/formHooks.ts` +- `frontend/src/business/walkthrough/mappers.ts` +- `frontend/src/business/walkthrough/selectors.ts` +- `frontend/src/business/walkthrough/types.ts` +- `frontend/src/business/walkthrough/validators.ts` + +API/data access layer: + +- `frontend/src/shared/api/walkthrough.ts` +- `frontend/src/shared/types/walkthrough.ts` +- `frontend/src/shared/constants/walkthrough.ts` + +## Behavior + +- Check-in history and summary data load from `GET /api/walkthrough_checkins`. +- New check-ins are saved through `POST /api/walkthrough_checkins`. +- Delete support exists in the typed API/business layer for future view controls. +- Shared constants own rating categories, staff options, time ranges, and display labels. +- `useWalkthroughCheckInPage` owns check-in page tabs, refresh, submit orchestration, loading/submitting/error state, stats, and history rows. +- Business selectors own rating lookup, check-in stats, history row mapping, averages, trends, flags, recognitions, and status colors. +- Business validators own walk-through form submission readiness. +- `WalkThroughCheckIn.tsx` is a thin wrapper that renders focused check-in components under `frontend/src/components/walkthrough-checkin/`. +- `useWalkthroughForm` owns form draft state, rating/comment updates, submit DTO construction, and submitted/reset state. +- `WalkThroughForm.tsx` is a thin wrapper that renders focused form components under `frontend/src/components/walkthrough-form/`. +- Walk-through form controls use shared UI primitives: `Button`, `Input`, `Textarea`, and `StatePanel` where applicable. +- `useWalkthroughSummary` owns summary filters, presentation/report mode, growth plan draft state, filtered check-ins, category averages, trend data, teacher frequencies, and generated summary text. +- `WalkThroughSummary.tsx` is a thin wrapper that renders loading, empty, presentation, printable report, or main summary views. + +## Remaining Related Work + +If summaries need server-side aggregation for reporting, add a backend summary endpoint. Keep the frontend selector calculations for local display only. diff --git a/frontend/docs/zones-of-regulation-integration.md b/frontend/docs/zones-of-regulation-integration.md new file mode 100644 index 0000000..c84aa56 --- /dev/null +++ b/frontend/docs/zones-of-regulation-integration.md @@ -0,0 +1,65 @@ +# Zones Of Regulation Integration + +## Purpose + +`ZonesOfRegulation` renders backend-owned regulation zone content through the three-layer frontend architecture. + +```text +View -> Business Logic -> API/Data Access -> Backend +``` + +Runtime zone records, behaviors, strategies, matching signs, safety connections, and quick de-escalation flow content belong to backend content catalog payloads. The frontend owns only UI state, tab config, style-token mappings, and presentation. + +## Frontend Layers + +View: + +- `frontend/src/components/frameworks/ZonesOfRegulation.tsx` +- `frontend/src/components/zones-of-regulation/ZonesOfRegulationView.tsx` +- `frontend/src/components/zones-of-regulation/ZonesHeader.tsx` +- `frontend/src/components/zones-of-regulation/ZonesTabs.tsx` +- `frontend/src/components/zones-of-regulation/ZonesOverviewGrid.tsx` +- `frontend/src/components/zones-of-regulation/ZoneOverviewCard.tsx` +- `frontend/src/components/zones-of-regulation/ZoneDetailPanel.tsx` +- `frontend/src/components/zones-of-regulation/ZoneDetailListPanel.tsx` +- `frontend/src/components/zones-of-regulation/ZoneSignsPanel.tsx` +- `frontend/src/components/zones-of-regulation/ZoneSafetyConnectionPanel.tsx` +- `frontend/src/components/zones-of-regulation/ZonesQuickFlow.tsx` + +Business logic: + +- `frontend/src/business/zones/hooks.ts` +- `frontend/src/business/zones/selectors.ts` +- `frontend/src/business/zones/types.ts` + +Shared contracts and UI config: + +- `frontend/src/shared/types/app.ts` +- `frontend/src/shared/constants/zonesOfRegulation.ts` +- `frontend/src/shared/constants/contentCatalog.ts` + +## Backend Contracts + +The page reads: + +- `GET /api/public/content-catalog/regulation-zones` +- `GET /api/public/content-catalog/zones-of-regulation-page-content` + +Content payloads are seeded in: + +- `backend/src/db/seeders/content-catalog-data/content-catalog-seed-payloads.js` + +## Behavior + +- `useZonesOfRegulationPage` loads zone records and page-level content. +- Selectors handle expanded-zone toggling, selected-zone lookup, safety connection lookup, and active-tab wording. +- View components receive a prepared page model and do not call API/data access modules. +- Loading and error states are explicit through `StatePanel`. +- The module preserves the current behavior: selecting a zone expands details; zone check-in persistence remains owned by the dashboard check-in flow until a dedicated UX task adds it here. + +## Data Ownership Rules + +- Do not add zone records, QBS safety connection copy, or de-escalation flow content to frontend constants. +- Do not add frontend fallback zone payloads. +- Keep frontend constants limited to tab labels, default UI state, gradients, and ring classes. +- Test-only fixtures may live in selector tests or `frontend/src/test-seeds/`. diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 0000000..e67846f --- /dev/null +++ b/frontend/eslint.config.js @@ -0,0 +1,29 @@ +import js from "@eslint/js"; +import globals from "globals"; +import reactHooks from "eslint-plugin-react-hooks"; +import reactRefresh from "eslint-plugin-react-refresh"; +import tseslint from "typescript-eslint"; + +export default tseslint.config( + { ignores: ["dist"] }, + { + extends: [js.configs.recommended, ...tseslint.configs.recommended], + files: ["**/*.{ts,tsx}"], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + plugins: { + "react-hooks": reactHooks, + "react-refresh": reactRefresh, + }, + rules: { + ...reactHooks.configs.recommended.rules, + "react-refresh/only-export-components": [ + "warn", + { allowConstantExport: true }, + ], + "@typescript-eslint/no-unused-vars": "off", + }, + } +); diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..140c2fd --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,24 @@ + + + + + + + FRAMEworks School Manager + + + + + + + + + + + + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..e8896c1 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,5902 @@ +{ + "name": "vite_react_shadcn_ts", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "vite_react_shadcn_ts", + "version": "0.0.0", + "dependencies": { + "@hookform/resolvers": "^5.4.0", + "@radix-ui/react-accordion": "^1.2.13", + "@radix-ui/react-alert-dialog": "^1.1.16", + "@radix-ui/react-aspect-ratio": "^1.1.9", + "@radix-ui/react-avatar": "^1.1.12", + "@radix-ui/react-checkbox": "^1.3.4", + "@radix-ui/react-collapsible": "^1.1.13", + "@radix-ui/react-context-menu": "^2.3.0", + "@radix-ui/react-dialog": "^1.1.16", + "@radix-ui/react-dropdown-menu": "^2.1.17", + "@radix-ui/react-hover-card": "^1.1.16", + "@radix-ui/react-label": "^2.1.9", + "@radix-ui/react-menubar": "^1.1.17", + "@radix-ui/react-navigation-menu": "^1.2.15", + "@radix-ui/react-popover": "^1.1.16", + "@radix-ui/react-progress": "^1.1.9", + "@radix-ui/react-radio-group": "^1.4.0", + "@radix-ui/react-scroll-area": "^1.2.11", + "@radix-ui/react-select": "^2.3.0", + "@radix-ui/react-separator": "^1.1.9", + "@radix-ui/react-slider": "^1.4.0", + "@radix-ui/react-slot": "^1.2.5", + "@radix-ui/react-switch": "^1.3.0", + "@radix-ui/react-tabs": "^1.1.14", + "@radix-ui/react-toast": "^1.2.16", + "@radix-ui/react-toggle": "^1.1.11", + "@radix-ui/react-toggle-group": "^1.1.12", + "@radix-ui/react-tooltip": "^1.2.9", + "@tanstack/react-query": "^5.101.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "cmdk": "^1.1.1", + "date-fns": "^4.4.0", + "embla-carousel-react": "^8.6.0", + "highlight.js": "^11.11.1", + "input-otp": "^1.4.2", + "lucide-react": "^1.17.0", + "marked": "^18.0.5", + "next-themes": "^0.4.6", + "react": "^19.2.7", + "react-day-picker": "^10.0.1", + "react-dom": "^19.2.7", + "react-hook-form": "^7.78.0", + "react-resizable-panels": "^4.11.2", + "react-router-dom": "^7.17.0", + "recharts": "^3.8.1", + "sonner": "^2.0.7", + "tailwind-merge": "^3.6.0", + "tailwindcss-animate": "^1.0.7", + "uuid": "^14.0.0", + "vaul": "^1.1.2", + "zod": "^4.4.3" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@playwright/test": "^1.60.0", + "@tailwindcss/postcss": "^4.3.0", + "@tailwindcss/typography": "^0.5.20", + "@types/node": "^25.9.2", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.2", + "autoprefixer": "^10.5.0", + "eslint": "^10.4.1", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17.6.0", + "postcss": "^8.5.15", + "tailwindcss": "^4.3.0", + "typescript": "^6.0.3", + "typescript-eslint": "^8.60.1", + "vite": "^8.0.16", + "vitest": "^4.1.8" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@date-fns/tz": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.5.0.tgz", + "integrity": "sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==", + "license": "MIT" + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", + "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz", + "integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.6" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT" + }, + "node_modules/@hookform/resolvers": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.4.0.tgz", + "integrity": "sha512-EIsqr/t/qbinPIhGjMdtvutIN1Kk4uwbROE9/UQ93CAVGR7GkA7Y92+fX80OzXi/OB67jVFYwKGO1WzkxmkFZw==", + "license": "MIT", + "dependencies": { + "@standard-schema/utils": "^0.3.0" + }, + "peerDependencies": { + "react-hook-form": "^7.55.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.133.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", + "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@playwright/test": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", + "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@radix-ui/number": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.2.tgz", + "integrity": "sha512-ceTwaxc4I5IOi97DgCotl3pqiyRGvffcc0oOsE2dQYaJOFIDsDt4VWG6xEbg1QePv9QWausCEIppud/tJ1wNig==", + "license": "MIT" + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.4.tgz", + "integrity": "sha512-7AdCK9PQyiljKoBDbN8OuctCbd/esdwZPQ8RtOE3SsyQtUpiPb+ND75q0jEhC1m1ecBI0MFNeLJvwIh9iKHRcQ==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-accordion": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.13.tgz", + "integrity": "sha512-xITxBB2p5m5tAe7M0F95kb4uAh7jSIKGlExMEm93HlW+XxZHV2eXFbPWLktd4JhRiwcnXNbO7iekcrbZy6ZCvA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-collapsible": "1.1.13", + "@radix-ui/react-collection": "1.1.9", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-use-controllable-state": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-alert-dialog": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.16.tgz", + "integrity": "sha512-vPaIgo0mxYlvcFaM9jB2Uot9TjGXMuAPEvrc6BOLeV+I5U8s1dkIoouYaa6lmSfc5SPMo5x5djOTOTvaigdGMQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-dialog": "1.1.16", + "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-slot": "1.2.5" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.9.tgz", + "integrity": "sha512-yqHW5WQ/cTpU/un7dqqIKNy2iRU8BC0JB78PEzTfCCYvZu1U6W9KwObAniMk9nhSfyotKPQTYaUD/HB0f5muig==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.5" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-aspect-ratio": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-aspect-ratio/-/react-aspect-ratio-1.1.9.tgz", + "integrity": "sha512-Xy+Dpxt/5n9rVTdPrNFmf8GwG1NlT1pzCF/z1MgOGZMLZWdWl+km+ZRWGQAPEhbkzSwYEsfYmTca8NhUtVxqnw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.5" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-avatar": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.1.12.tgz", + "integrity": "sha512-NQCQyWC7QrDPhjMn8hUqFeU0lUrprIgm1AyMgLbzuQJibNnatdc3SSMo3/UGFu/eUkJUU1cEcKCnyhXTQzq6tA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-is-hydrated": "0.1.1", + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.4.tgz", + "integrity": "sha512-m3JmIOAX5ZzZ6VPjxEU2dbTOhoHi0nT5riwcDwe8idocsWf4a5DXJLDtZ6LfJwMBx7W+A2b7kp2TgPEKtaiF6A==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-use-controllable-state": "1.2.3", + "@radix-ui/react-use-previous": "1.1.2", + "@radix-ui/react-use-size": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.13.tgz", + "integrity": "sha512-F0s8+p2XNpfc3k02zBfB0jPWbkHVG162+p7BdUMyJ2308QMqZ+oaclX+FAzKFovgL5OqRU+Rvy6f/vbdlJVaqA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-use-controllable-state": "1.2.3", + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.9.tgz", + "integrity": "sha512-zuSVi7ziP7uQRqc+yGxsKJfNkdyHv3ZKDaHe0gzg4dRgws96TPKWIiz84tVHP4GEcEl8bC0mdt17NkcxaJHmaQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-slot": "1.2.5" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.3.tgz", + "integrity": "sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.4.tgz", + "integrity": "sha512-QwH4PO5urrbO+FaGd5Aglg+YJgWTyyuZ3g/6mKvsqraLkglDdckw9JafgL5McL5VEJ6EPNduPaT3ZE9BttDAqg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context-menu": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.3.0.tgz", + "integrity": "sha512-d7CouXhAW+CGmFOqmB+IEvd3E9GcaqfgvfjCc3hfulp2pkaUCEVEGa0SN5nNWYA+IvQ6g1Pt+S5dpNn1AoY9hg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-menu": "2.1.17", + "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-use-controllable-state": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.16.tgz", + "integrity": "sha512-l9ok83YBclEZhbjgzt76Hw733e6cvRKPNgO6GJ/IETlufXG9p+fRu2wlvpImQvR6xdJ8h7J8J2DBvsPEiEsKMw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-dismissable-layer": "1.1.12", + "@radix-ui/react-focus-guards": "1.1.4", + "@radix-ui/react-focus-scope": "1.1.9", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-portal": "1.1.11", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-slot": "1.2.5", + "@radix-ui/react-use-controllable-state": "1.2.3", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.2.tgz", + "integrity": "sha512-C3vFhbyi4SW3PmbAi6Awpu4OzJtd0MxGurvSsYtr7p7nM8RNB3VAF3CUmnp2j50knpkrRcB7+ycVXzgLgF6yNA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.12.tgz", + "integrity": "sha512-MhoruH6xEzsbvOmo4TNgMfmtvRGyDZw4MDSdf4ybMHfezjqwzv6hyd4lsMzBp8K9Sn6sGzCF62x1I7BYUECXOg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-escape-keydown": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu": { + "version": "2.1.17", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.17.tgz", + "integrity": "sha512-S6b3Jm57sY5EdDyOMLkacbB0qMnKhy1RCKZCt795ZkmtUOAvojYIZ5p7dXHIh5Cyr3jCLLI5/g64V3FKLudZmw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-menu": "2.1.17", + "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-use-controllable-state": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.4.tgz", + "integrity": "sha512-cot/aB/mOm0IYVYTTmQcEEK1M48lZWi8FlYe5nDPQQ8NYZUlXEFgncJ9p2Kzer3RKSrY7cTTpEMLZKNo9QoP5Q==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.9.tgz", + "integrity": "sha512-9Se8t+Zry+1rEOL7Y6l/4ANYU/TOtAtf8O2fKdwLltcaMcm6kOqYGbzO4tMFQ0bvzO920pRAoHpFZ4W85S3keQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-use-callback-ref": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-hover-card": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.16.tgz", + "integrity": "sha512-hAileDBtd6CX7nlZOarOnISQ6PP4q0e16BX51ulzdZ+7IzjL0sDTVpFdmSYrIjw6zVNsfQBao5gG6AWr3qwfvA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-dismissable-layer": "1.1.12", + "@radix-ui/react-popper": "1.3.0", + "@radix-ui/react-portal": "1.1.11", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-use-controllable-state": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.2.tgz", + "integrity": "sha512-orBC88futVpqCmhX1p4cvquNHsELQ+w+vBJnuj3ftETI5bJb0bZn3Tqu3SWN2IOcPycTnMGnhwoermvISt72sA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-label": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.9.tgz", + "integrity": "sha512-rDoTeMbCwRVcnmo7NGT9IlPo1yXmEI+xc1URP3oeewwZEV4mdTp1dYUhYbQdo4D1q2SjKVvv4N1gNY77QAQtjA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.5" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu": { + "version": "2.1.17", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.17.tgz", + "integrity": "sha512-fmbNnFyf+JYCN0DhhWnEdUTDnZD1mXaPQWivdsPIb8oOSbARfD3LIQJbLCG8a8QLCwoMxiJ7GVPIFcC8Dw8v2Q==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-collection": "1.1.9", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.12", + "@radix-ui/react-focus-guards": "1.1.4", + "@radix-ui/react-focus-scope": "1.1.9", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-popper": "1.3.0", + "@radix-ui/react-portal": "1.1.11", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-roving-focus": "1.1.12", + "@radix-ui/react-slot": "1.2.5", + "@radix-ui/react-use-callback-ref": "1.1.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menubar": { + "version": "1.1.17", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menubar/-/react-menubar-1.1.17.tgz", + "integrity": "sha512-AKtZ4O782yO7qwIyq73WpulYt1IHhQ0htDb6wNcxzxnSDCcSWMVBiU9ycpcA90XzQO4IVIxIErtak6Kg/Vt0rQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-collection": "1.1.9", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-menu": "2.1.17", + "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-roving-focus": "1.1.12", + "@radix-ui/react-use-controllable-state": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-navigation-menu": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.15.tgz", + "integrity": "sha512-/fS8hKCcRt4DwCGa5QIB3juRXmfYSOk4a2AEe/BDIyy7Hm+eje2Y13oUx5zejl+wFt1owrM7E8NWlbaEl5EGpg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-collection": "1.1.9", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.12", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-controllable-state": "1.2.3", + "@radix-ui/react-use-layout-effect": "1.1.2", + "@radix-ui/react-use-previous": "1.1.2", + "@radix-ui/react-visually-hidden": "1.2.5" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.16.tgz", + "integrity": "sha512-8brVpAU5Uq7Bh0c8EFc4ZTf2JJTYn0o+1L+CUJB3UYIOkTjKGMgoHvduylrahdmNlr3DfH0rFq2DrbNZXgaspw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-dismissable-layer": "1.1.12", + "@radix-ui/react-focus-guards": "1.1.4", + "@radix-ui/react-focus-scope": "1.1.9", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-popper": "1.3.0", + "@radix-ui/react-portal": "1.1.11", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-slot": "1.2.5", + "@radix-ui/react-use-controllable-state": "1.2.3", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.3.0.tgz", + "integrity": "sha512-9PB589e1aWZbrlFUHdz6WiPCL+xLZHQFX7oibqG/6Q0SwOkxDyQX9W/cyPa+sAPPKuC8cpLCpRczE5a/1DiwVQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.9", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.2", + "@radix-ui/react-use-rect": "1.1.2", + "@radix-ui/react-use-size": "1.1.2", + "@radix-ui/rect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.11.tgz", + "integrity": "sha512-UEytdjgEh2tJGgD/gZK4FUx6t1rNIlM3U0DENhSrG7I75FGm1DnaDuVUWF1pWAWUwGmn1sCJ1VGHn8LhN1aTOw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.6.tgz", + "integrity": "sha512-zdTk4PlUO0E18HnZ3wYbW0KkJJxWCdiNYp6g6X1PtONFhxVkg01vliTJAmwIszU6mHiyBOoW9P0rAugl5/hULQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.5.tgz", + "integrity": "sha512-zifXeB8Y88qCYx8PLZ5oQb32KwZub+s925mMoZsBBq9KUQqWKkREubTfs6ASjRPPBe7Jt9O8OHH89+95VG+grA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.5" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-progress": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.9.tgz", + "integrity": "sha512-+EOkvg1Zn1vI1+fRDfRSAiJ7BWfcDAo5ASMmbqrcLZ4s4USk2FGkoHgeb2X+CkUgo2zJMiyObwf1k44CrRWsyw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-primitive": "2.1.5" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-radio-group": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.4.0.tgz", + "integrity": "sha512-eHdV5bLx9sH+tBnbDjkIBdvQEH/c6MEtQYhTbxkaDK9qsIFFLtmJYEQFVdwhnruWotLfQmIuWEL/J+L3utE8rQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-roving-focus": "1.1.12", + "@radix-ui/react-use-controllable-state": "1.2.3", + "@radix-ui/react-use-previous": "1.1.2", + "@radix-ui/react-use-size": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.12.tgz", + "integrity": "sha512-FvgPt1bRmg8Xt2QpF7NUZW3dE0ZQHGm41dAdgT2J2GJPoIXz+9Em3NobAxf4fupcxhgHu03E5CRiU2MWvObXyg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-collection": "1.1.9", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-controllable-state": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.11.tgz", + "integrity": "sha512-DS39ziOgea75U/TrXKU2/oKp0be2jrDHnzFLvahg/0iNAT1Zq16e4Uw0WXwyXvsK+mG3BRyMb7A3NRZMDuEXtQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.2", + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.3.0.tgz", + "integrity": "sha512-mENc7WpJvJcW8hlMpzfFcHcEhTvYS5JMBmi9HVC1Q00uhBwML086MHYUV8QQdQv6lcu0Wg8dzd1RB8AFADcG/g==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.2", + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-collection": "1.1.9", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.12", + "@radix-ui/react-focus-guards": "1.1.4", + "@radix-ui/react-focus-scope": "1.1.9", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-popper": "1.3.0", + "@radix-ui/react-portal": "1.1.11", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-slot": "1.2.5", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-controllable-state": "1.2.3", + "@radix-ui/react-use-layout-effect": "1.1.2", + "@radix-ui/react-use-previous": "1.1.2", + "@radix-ui/react-visually-hidden": "1.2.5", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-separator": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.9.tgz", + "integrity": "sha512-gvgW+JV/Mbjj6darztTetnmElpQEzZrXpJvfj+dOxNAxiyHEAyUvEjjl4zxblvmjmKmi3jfPoy7ZdxzCuUBJSA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.5" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slider": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.4.0.tgz", + "integrity": "sha512-RHcPlLOThRJM51DSIC33ZnpDEBYhyEFroVWkd2P54PGGjkmAt14RboYUU9E1MFst666zFHM0tGtWvMjSOtU1pw==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.2", + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-collection": "1.1.9", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-use-controllable-state": "1.2.3", + "@radix-ui/react-use-layout-effect": "1.1.2", + "@radix-ui/react-use-previous": "1.1.2", + "@radix-ui/react-use-size": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.5.tgz", + "integrity": "sha512-rCMO3QsIVKv5JTY5CVbo2MvO77SpEqqYc8AvRE7OWqRDOIqAKjsp+DrmnY9uc8NPdxB5E2z47HTYGeE2+NTptg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-switch": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.3.0.tgz", + "integrity": "sha512-GP1EZwhoZO/GGnhM1P5/2Vpm8iN8EnngyU0oezn2l78kN8tj25pyrvjIaT7azBhK615KSt+P2w39y57YV5jVkA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-use-controllable-state": "1.2.3", + "@radix-ui/react-use-previous": "1.1.2", + "@radix-ui/react-use-size": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.14.tgz", + "integrity": "sha512-D5jwp9JNuwDeCw3CYD2Fz+sSHo0droQjC8u75dJHe4aWr5q6yBiXZU+hurXnKudRgEpUkD5TsI6bjHPo5ThUxA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-roving-focus": "1.1.12", + "@radix-ui/react-use-controllable-state": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toast": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.16.tgz", + "integrity": "sha512-WUymDDiN2DpoGudRN1aW4wF5O3BNQjZZO/5nngPoNiEVqjyOzirvZZNO0R6dC1ifucSINVaSv8JX1aq47VGgiA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-collection": "1.1.9", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-dismissable-layer": "1.1.12", + "@radix-ui/react-portal": "1.1.11", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-controllable-state": "1.2.3", + "@radix-ui/react-use-layout-effect": "1.1.2", + "@radix-ui/react-visually-hidden": "1.2.5" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.11.tgz", + "integrity": "sha512-FikrKJemoBGZQ6uRID0HJqSPBP6D7OppdD2OhLl0ZYLlAyPXI7MezoYGmumwNkrAoRm35xXkb4C8JPfJZZzcaw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-use-controllable-state": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle-group": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.12.tgz", + "integrity": "sha512-TEgECgJaWGAHJJZGzNNEYTNBdIXqX7LchANycpyP7DkfjmuiSN7ISt1k/ZRGVJgVJonsgP4vwaiKMn5utrcwWQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-roving-focus": "1.1.12", + "@radix-ui/react-toggle": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.9.tgz", + "integrity": "sha512-u6F9MmTtBSLkiXNVDrtB/yPCZarM9smNswC24YYLV/M+bth6J3Gs3vlJezEoFwKZvPvxhCpUYdUnOsNG/0XOlA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-dismissable-layer": "1.1.12", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-popper": "1.3.0", + "@radix-ui/react-portal": "1.1.11", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-slot": "1.2.5", + "@radix-ui/react-use-controllable-state": "1.2.3", + "@radix-ui/react-visually-hidden": "1.2.5" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.2.tgz", + "integrity": "sha512-xCso9j1/u8sEgP1RNHjFrXJLApL8LiqOkI1R4ywuN00rxWdYg4oQXuwKLS3i0j5NWLromUD27/4nlxj2UFVvIw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.3.tgz", + "integrity": "sha512-PLzC90MS+ReootmjC597dvopoelpZ8Q61HJkDXZSExitIq7PL55vHNnesAHwguHK0aPfBnpdNzQtv1uliaqQrA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.3", + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.3.tgz", + "integrity": "sha512-6c8ZqvPTWILEKnyVkP53EGRCcpnJiKTC21sS/6R1GF5xKyHJJWQEPfkqlcgUkdRQivd6tb23abUwe4ngWmY0JA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.2.tgz", + "integrity": "sha512-2uVLvLjgO7NZCWw01/FdqRwmA42J0BcjPMUCA+koFEOAb+zjqIP7SiFz/7zWPrKnVmSqr76Omq2ALyCuX4dhLw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-is-hydrated": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.1.tgz", + "integrity": "sha512-qwOiz4Tjo8CNnrOLAYUMXeZwDzXgXpvK4TKQPmWLECM9XoWvA6+0Z2/7Ag3A4ivjS4ovbLJPbskkxioFyBhr8A==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.2.tgz", + "integrity": "sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-previous": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.2.tgz", + "integrity": "sha512-IGBQPtRFdhN6MQ8dbegVmBq1LVZluya3F1jWY+puIcQC3MHctRwTDSBWCkL/3ZcnMJLTMJ++Z+ktmvg0F89iCw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.2.tgz", + "integrity": "sha512-d8a+bBY/FxikNPlgJJoaBHZX+zKVbWHYJGTLnLvveQgFSTntkGdEKv3JDtHrMS0DNYpllz2nRsTLGLKYttbpmw==", + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.2.tgz", + "integrity": "sha512-giWQp+4mxjBPt4KZ0MmyuykFNWfbDxKt4x+fPkRYmgRFJSbCZFzUglvMb/Kjn38tm10YP4ufiQZDx3zna4LU6w==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.5.tgz", + "integrity": "sha512-tPcHNI3FajdDBFpl/Ez1m2WL0ufJqBKyHxMDBvKitopamK36WwBGOMicuMEZKkM5Wce41QxUyv6BsiqfrWBiGg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.5" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/rect": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.2.tgz", + "integrity": "sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA==", + "license": "MIT" + }, + "node_modules/@reduxjs/toolkit": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz", + "integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@reduxjs/toolkit/node_modules/immer": { + "version": "11.1.8", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.8.tgz", + "integrity": "sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", + "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", + "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", + "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", + "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", + "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", + "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", + "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", + "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", + "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", + "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", + "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", + "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", + "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", + "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", + "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.0.tgz", + "integrity": "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.21.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.0" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.0.tgz", + "integrity": "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.0", + "@tailwindcss/oxide-darwin-arm64": "4.3.0", + "@tailwindcss/oxide-darwin-x64": "4.3.0", + "@tailwindcss/oxide-freebsd-x64": "4.3.0", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.0", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.0", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.0", + "@tailwindcss/oxide-linux-x64-musl": "4.3.0", + "@tailwindcss/oxide-wasm32-wasi": "4.3.0", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.0", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.0" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.0.tgz", + "integrity": "sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.0.tgz", + "integrity": "sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.0.tgz", + "integrity": "sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.0.tgz", + "integrity": "sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.0.tgz", + "integrity": "sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.0.tgz", + "integrity": "sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.0.tgz", + "integrity": "sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.0.tgz", + "integrity": "sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.0.tgz", + "integrity": "sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.0.tgz", + "integrity": "sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.10.0", + "@emnapi/runtime": "^1.10.0", + "@emnapi/wasi-threads": "^1.2.1", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.0.tgz", + "integrity": "sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.0.tgz", + "integrity": "sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/postcss": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.3.0.tgz", + "integrity": "sha512-Jm05Tjx+9yCLGv5qw1c+84Psds8MnyrEQYCB+FFk2lgGiUjlRqdxke4mVTuYrj2xnVZqKim2Apr5ySuQRYAw/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "@tailwindcss/node": "4.3.0", + "@tailwindcss/oxide": "4.3.0", + "postcss": "^8.5.10", + "tailwindcss": "4.3.0" + } + }, + "node_modules/@tailwindcss/typography": { + "version": "0.5.20", + "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.20.tgz", + "integrity": "sha512-hwbzQuNUfcPvbegQFatVPl/MY/tcM9KLl963hQ5laJKPh81TEZ1+dNG9PirGvcaDBkp+BCshExAyKVPW91dozw==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "6.0.10" + }, + "peerDependencies": { + "tailwindcss": ">=3.0.0 || >=4.0.0 || insiders" + } + }, + "node_modules/@tanstack/query-core": { + "version": "5.101.0", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.0.tgz", + "integrity": "sha512-cQetA74EB+seWySv1TTKr828TnP0u39m6LykwDXIo84SNortpDkp30TMEjkqtYCNP9c40uT/iwl6MLiufEt0Ow==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.101.0", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.0.tgz", + "integrity": "sha512-rLlJXSpkqfizLWgkR5+eLeIk0MvTx/meEIR7LRjxic+qxiQP8zVjq7BqQkiCMNLQBlLfuOLqqr6KO5GtrDlmSg==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.101.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.9.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.2.tgz", + "integrity": "sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@types/react": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.1.tgz", + "integrity": "sha512-JQ4S5GB0tfjO8BuJ4fcX+HodkzJjYBV+7OJ+wLygaX7OGQ7FudyHL4NSCA6ob+w3Yn+5MkKIozOwQhXeM7opVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.60.1", + "@typescript-eslint/type-utils": "8.60.1", + "@typescript-eslint/utils": "8.60.1", + "@typescript-eslint/visitor-keys": "8.60.1", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.60.1", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.60.1.tgz", + "integrity": "sha512-A0M6ua6H252bVjPvvtSgl2QA4+ET9S5Mtkb2GDyTxIhH/C4qDItT7RQNO5PhMC6NXGYXOR9dIalcDDgBKT7oFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.60.1", + "@typescript-eslint/types": "8.60.1", + "@typescript-eslint/typescript-estree": "8.60.1", + "@typescript-eslint/visitor-keys": "8.60.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.60.1.tgz", + "integrity": "sha512-eXkTH2bxmXlqD1RnOPmLZ9ZM9D3VwSx04JOwBnP9RQ+yUA5a2Mu7SfW8uaV2Aon53NJzZlZYuX7tn91Izf+xaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.60.1", + "@typescript-eslint/types": "^8.60.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.60.1.tgz", + "integrity": "sha512-gvI5OQoptnxQnchOirukCuQ55svJSTuD/4k5+pC267xyBtYry748R9/c3tYUzb/iE6RZfllRz2lVulLCHkTm4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.60.1", + "@typescript-eslint/visitor-keys": "8.60.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.60.1.tgz", + "integrity": "sha512-nh8w4qAteiKuZu3pSSzG/yGKpw0OlkrKnzFmbVRenKaD4qc+7i1GrmZaLVkr8rk4uipiPGMOW4YsM6WmKZ5CvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.60.1.tgz", + "integrity": "sha512-sdwTrpjosW7ANQYJ39ZBF1ZyEMEGVB2UsikrserVM/30a/F1dTLnu9bGxEdosugyu5caigjLrR2qiD11asjI1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.60.1", + "@typescript-eslint/typescript-estree": "8.60.1", + "@typescript-eslint/utils": "8.60.1", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.60.1.tgz", + "integrity": "sha512-4h0tY8ppCkdCzcrl2YM5M3my0xsE1Tf8om3owEu5oPWmXwkKRmk0j0LGDzYBGUcAlesEbxBhazqu/K4cu3Ug7w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.60.1.tgz", + "integrity": "sha512-alpRkfG8hlVE5kdJW2GkfgDgXxold3e8e4l6EnmhRmRLbekgAPCCGDVD++sABy9FcgPFroq+uFcCSM1vR57Cew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.60.1", + "@typescript-eslint/tsconfig-utils": "8.60.1", + "@typescript-eslint/types": "8.60.1", + "@typescript-eslint/visitor-keys": "8.60.1", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.2.tgz", + "integrity": "sha512-c8jsqUZm3omBOI66G90z1Dyw5z622G8oLG+omfsHBJf3CWQTlOcwOjvOG6wtiNfW6anKm/eA39LMwMtMez2TiQ==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.60.1.tgz", + "integrity": "sha512-h2MPBLoNtjc3qZWfY3Tl51yPorQ2McHn8pJfcMNTcIvrrZrr90Ykffit0yjrPFWQcRcUxzH20+6OcVdW4yHtUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.60.1", + "@typescript-eslint/types": "8.60.1", + "@typescript-eslint/typescript-estree": "8.60.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.60.1.tgz", + "integrity": "sha512-EbGRQg4FhrmwLodl+t3JNAnXHWVr9Vp+Zl1QBZVPY4ByfkzIT8cX3K6QWODHtkIZqqJVEWvhHSx3v5PDHsaQag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.60.1", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.2.tgz", + "integrity": "sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.8.tgz", + "integrity": "sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.8", + "@vitest/utils": "4.1.8", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.8.tgz", + "integrity": "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.8.tgz", + "integrity": "sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.8", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.8.tgz", + "integrity": "sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.8", + "@vitest/utils": "4.1.8", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.8.tgz", + "integrity": "sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.8.tgz", + "integrity": "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.8", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/autoprefixer": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz", + "integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.2", + "caniuse-lite": "^1.0.30001787", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.34", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.34.tgz", + "integrity": "sha512-IMDedajPifLnHNY0X9n8hKxRTQ6/eTHwr5bDo04WnuqxyKw6LYtQywCuuqPZwhl3aBXMvQpJov42GLCwRRdQzw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001797", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001797.tgz", + "integrity": "sha512-l8xKG+gwAIExZGl9FrF7KUwuOmk6wbEPC9Xoy/RtnWv1XG0Q4LFlagaLpUv3Kiza3W/wm27zy0yWJEieYKAP6w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/cmdk": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz", + "integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "^1.1.1", + "@radix-ui/react-dialog": "^1.1.6", + "@radix-ui/react-id": "^1.1.0", + "@radix-ui/react-primitive": "^2.0.2" + }, + "peerDependencies": { + "react": "^18 || ^19 || ^19.0.0-rc", + "react-dom": "^18 || ^19 || ^19.0.0-rc" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/date-fns": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.4.0.tgz", + "integrity": "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.368", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.368.tgz", + "integrity": "sha512-7RckJJK4uESJF9PxvfMWd3TGqIiieUTG4HxnKaKuIpGbcr+r2ZEB3g2gAhCP3Fqm42vJSzLfgab9eva/C4/XVw==", + "dev": true, + "license": "ISC" + }, + "node_modules/embla-carousel": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz", + "integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==", + "license": "MIT" + }, + "node_modules/embla-carousel-react": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/embla-carousel-react/-/embla-carousel-react-8.6.0.tgz", + "integrity": "sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA==", + "license": "MIT", + "dependencies": { + "embla-carousel": "8.6.0", + "embla-carousel-reactive-utils": "8.6.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, + "node_modules/embla-carousel-reactive-utils": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/embla-carousel-reactive-utils/-/embla-carousel-reactive-utils-8.6.0.tgz", + "integrity": "sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A==", + "license": "MIT", + "peerDependencies": { + "embla-carousel": "8.6.0" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.23.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.23.0.tgz", + "integrity": "sha512-yJN/BOOLxcOW2aQgeif9mSnaUB8KtvmMMp56oA1kx1CRfBKbhZm2pJ+NBY+3eOboHxix8lfjWpHE0Ei5U8RbSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-toolkit": { + "version": "1.47.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.47.0.tgz", + "integrity": "sha512-n1GuoD0WEQZMBk5tttoZSqwgyLx01oqa5XsBmCHwPyNe1S9jPBEmtR2pSgp2kJuWE3ciFZ6yRHmY4pM4C3OOkw==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.4.1.tgz", + "integrity": "sha512-AyIKhnOBuOAdueD7RB3xB+YeAWScb9jHsJBgH2Hcde8InP5JYhqrRR6iTMHyTEwgENK54Cp44e4v8BwNhsuHuw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.6.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.2", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", + "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.2.tgz", + "integrity": "sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": "^9 || ^10" + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "17.6.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.6.0.tgz", + "integrity": "sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/highlight.js": { + "version": "11.11.1", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", + "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/immer": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/input-otp": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/input-otp/-/input-otp-1.4.2.tgz", + "integrity": "sha512-l3jWwYNvrEa6NTCt7BECfCm48GvwuZzkoeG3gBL2w4CHeOXW3eKFmf9UNYkNfYc3mxMrthMnxjIE07MT0zLBQA==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.17.0.tgz", + "integrity": "sha512-9FA9evdox/JQL5PT57fdA1x/yg8T7knJ98+zjTL3UfKza6pflQUUh3XtaQIHKvnsJw1lmsEyHVlt5jchYxOQ5w==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/marked": { + "version": "18.0.5", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.5.tgz", + "integrity": "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/next-themes": { + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz", + "integrity": "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" + } + }, + "node_modules/node-releases": { + "version": "2.0.47", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz", + "integrity": "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/obug": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.2.tgz", + "integrity": "sha512-AWGB9WFcRXOQs48Z/udjI5ZcZMHXwX8XPByNpOydgcGsDLIzjGizhoMWJyKAWze7AVW/2W1i+/gPX4YtKe5cyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.0.10", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz", + "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-day-picker": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-10.0.1.tgz", + "integrity": "sha512-eNh6BlwcYInWaJtRv18mXQ06Ys/H6rdTZAnTaSdOYJuTpwP1JMCHNd1FDRadA+gbeinq+psdULN5Xnowy9mV8w==", + "license": "MIT", + "dependencies": { + "@date-fns/tz": "^1.4.1", + "date-fns": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/gpbl" + }, + "peerDependencies": { + "@types/react": ">=16.8.0", + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.7" + } + }, + "node_modules/react-hook-form": { + "version": "7.78.0", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.78.0.tgz", + "integrity": "sha512-EEZqc+N23moyzTlz61Pj+JvcXo76ICkpfOZo8JZw+sM4+wLQGh6nI2Ms+PdMOYNluFu0ghlM7B8mCzhRYtJCnA==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT", + "peer": true + }, + "node_modules/react-redux": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz", + "integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-resizable-panels": { + "version": "4.11.2", + "resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-4.11.2.tgz", + "integrity": "sha512-+kfFbDZ8mygc7g0vxOcDzCVGuwiIUOnILqPoUHo6/uP+Mmyx6HzZU+kj1aOPDlktXuobYbr6BtQekvJwHRX4Eg==", + "license": "MIT", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-router": { + "version": "7.17.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.17.0.tgz", + "integrity": "sha512-FDELK7rTMlCHO5+reyXsPlmfr7N1F91lPHsWYfMEGQm/KQ+F4JFM8jGoeQDmDvdTs93Fw9aSilH+uKRb4/jXvQ==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.17.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.17.0.tgz", + "integrity": "sha512-fyU2yjGups/hE6Xz0I5ZYbVL8Gx29eCjgpHaRaTaVU+OOAdfRX05KsvyRm0GO8YQwOkhpU3MurW1jyMUJn+zSw==", + "license": "MIT", + "dependencies": { + "react-router": "7.17.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/recharts": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz", + "integrity": "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "^1.9.0 || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^10.1.1", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.1.1", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, + "node_modules/rolldown": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", + "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.133.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.3", + "@rolldown/binding-darwin-arm64": "1.0.3", + "@rolldown/binding-darwin-x64": "1.0.3", + "@rolldown/binding-freebsd-x64": "1.0.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", + "@rolldown/binding-linux-arm64-gnu": "1.0.3", + "@rolldown/binding-linux-arm64-musl": "1.0.3", + "@rolldown/binding-linux-ppc64-gnu": "1.0.3", + "@rolldown/binding-linux-s390x-gnu": "1.0.3", + "@rolldown/binding-linux-x64-gnu": "1.0.3", + "@rolldown/binding-linux-x64-musl": "1.0.3", + "@rolldown/binding-openharmony-arm64": "1.0.3", + "@rolldown/binding-wasm32-wasi": "1.0.3", + "@rolldown/binding-win32-arm64-msvc": "1.0.3", + "@rolldown/binding-win32-x64-msvc": "1.0.3" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/sonner": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", + "integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==", + "license": "MIT", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tailwind-merge": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz", + "integrity": "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz", + "integrity": "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==", + "license": "MIT" + }, + "node_modules/tailwindcss-animate": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/tailwindcss-animate/-/tailwindcss-animate-1.0.7.tgz", + "integrity": "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==", + "license": "MIT", + "peerDependencies": { + "tailwindcss": ">=3.0.0 || insiders" + } + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.60.1.tgz", + "integrity": "sha512-6m5hkkRAp8lKvhVpcprAIn5KkehQEh+47oHH2VGnExEh7dhNxXlg6GPAOIu6TxbVQxhebrJDvjl3020ooiWCMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.60.1", + "@typescript-eslint/parser": "8.60.1", + "@typescript-eslint/typescript-estree": "8.60.1", + "@typescript-eslint/utils": "8.60.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/uuid": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.0.tgz", + "integrity": "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, + "node_modules/vaul": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vaul/-/vaul-1.1.2.tgz", + "integrity": "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-dialog": "^1.1.1" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" + } + }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, + "node_modules/vite": { + "version": "8.0.16", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", + "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.15", + "rolldown": "1.0.3", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.8.tgz", + "integrity": "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.8", + "@vitest/mocker": "4.1.8", + "@vitest/pretty-format": "4.1.8", + "@vitest/runner": "4.1.8", + "@vitest/snapshot": "4.1.8", + "@vitest/spy": "4.1.8", + "@vitest/utils": "4.1.8", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.8", + "@vitest/browser-preview": "4.1.8", + "@vitest/browser-webdriverio": "4.1.8", + "@vitest/coverage-istanbul": "4.1.8", + "@vitest/coverage-v8": "4.1.8", + "@vitest/ui": "4.1.8", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/@vitest/mocker": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.8.tgz", + "integrity": "sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.8", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json index 7eb7de1..d6b8b20 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,79 +1,92 @@ { + "name": "vite_react_shadcn_ts", "private": true, + "version": "0.0.0", + "type": "module", "scripts": { - "dev": "cross-env PORT=${FRONT_PORT:-3000} next dev --turbopack", - "build": "next build", - "start": "next start", - "lint": "eslint . --ext .ts,.tsx", - "format": "prettier '{components,pages,src,interfaces,hooks}/**/*.{tsx,ts,js}' --write" + "dev": "vite", + "typecheck": "tsc -b --pretty false", + "build": "npm run typecheck && vite build", + "build:dev": "vite build --mode development", + "lint": "eslint .", + "test": "vitest run", + "test:e2e": "env -u NO_COLOR -u FORCE_COLOR playwright test", + "test:e2e:content": "env -u NO_COLOR -u FORCE_COLOR playwright test --config playwright.content.config.ts", + "preview": "vite preview" }, "dependencies": { - "@emotion/react": "^11.11.3", - "@emotion/styled": "^11.11.0", - "@mdi/js": "^7.4.47", - "@mui/material": "^6.3.0", - "@mui/x-data-grid": "^6.19.2", - "@reduxjs/toolkit": "^2.1.0", - "@tailwindcss/typography": "^0.5.13", - "@tinymce/tinymce-react": "^4.3.2", - "apexcharts": "^3.45.2", - "axios": "^1.8.4", - "chart.js": "^4.4.1", - "chroma-js": "^2.4.2", - "dayjs": "^1.11.10", - "file-saver": "^2.0.5", - "formik": "^2.4.5", - "html2canvas": "^1.4.1", - "i18next": "^25.1.2", - "i18next-browser-languagedetector": "^8.1.0", - "i18next-http-backend": "^3.0.2", - "intro.js": "^7.2.0", - "intro.js-react": "^1.0.0", - "jsonwebtoken": "^9.0.2", - "jwt-decode": "^3.1.2", - "lodash": "^4.17.21", - "moment": "^2.30.1", - "next": "15.5.12", - "next-i18next": "15.4.3", - "numeral": "^2.0.6", - "query-string": "^8.1.0", - "react": "19.0.0", - "react-apexcharts": "^1.4.1", - "react-big-calendar": "^1.10.3", - "react-chartjs-2": "^4.3.1", - "react-datepicker": "^4.10.0", - "react-dnd": "^16.0.1", - "react-dnd-html5-backend": "^16.0.1", - "react-dom": "19.0.0", - "react-i18next": "^15.5.1", - "react-redux": "^8.0.2", - "react-select": "^5.7.0", - "react-select-async-paginate": "^0.7.9", - "react-switch": "^7.0.0", - "react-toastify": "^11.0.2", - "swr": "1.3.0", - "uuid": "^9.0.0" + "@hookform/resolvers": "^5.4.0", + "@radix-ui/react-accordion": "^1.2.13", + "@radix-ui/react-alert-dialog": "^1.1.16", + "@radix-ui/react-aspect-ratio": "^1.1.9", + "@radix-ui/react-avatar": "^1.1.12", + "@radix-ui/react-checkbox": "^1.3.4", + "@radix-ui/react-collapsible": "^1.1.13", + "@radix-ui/react-context-menu": "^2.3.0", + "@radix-ui/react-dialog": "^1.1.16", + "@radix-ui/react-dropdown-menu": "^2.1.17", + "@radix-ui/react-hover-card": "^1.1.16", + "@radix-ui/react-label": "^2.1.9", + "@radix-ui/react-menubar": "^1.1.17", + "@radix-ui/react-navigation-menu": "^1.2.15", + "@radix-ui/react-popover": "^1.1.16", + "@radix-ui/react-progress": "^1.1.9", + "@radix-ui/react-radio-group": "^1.4.0", + "@radix-ui/react-scroll-area": "^1.2.11", + "@radix-ui/react-select": "^2.3.0", + "@radix-ui/react-separator": "^1.1.9", + "@radix-ui/react-slider": "^1.4.0", + "@radix-ui/react-slot": "^1.2.5", + "@radix-ui/react-switch": "^1.3.0", + "@radix-ui/react-tabs": "^1.1.14", + "@radix-ui/react-toast": "^1.2.16", + "@radix-ui/react-toggle": "^1.1.11", + "@radix-ui/react-toggle-group": "^1.1.12", + "@radix-ui/react-tooltip": "^1.2.9", + "@tanstack/react-query": "^5.101.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "cmdk": "^1.1.1", + "date-fns": "^4.4.0", + "embla-carousel-react": "^8.6.0", + "highlight.js": "^11.11.1", + "input-otp": "^1.4.2", + "lucide-react": "^1.17.0", + "marked": "^18.0.5", + "next-themes": "^0.4.6", + "react": "^19.2.7", + "react-day-picker": "^10.0.1", + "react-dom": "^19.2.7", + "react-hook-form": "^7.78.0", + "react-resizable-panels": "^4.11.2", + "react-router-dom": "^7.17.0", + "recharts": "^3.8.1", + "sonner": "^2.0.7", + "tailwind-merge": "^3.6.0", + "tailwindcss-animate": "^1.0.7", + "uuid": "^14.0.0", + "vaul": "^1.1.2", + "zod": "^4.4.3" }, "devDependencies": { - "@tailwindcss/forms": "^0.5.7", - "@tailwindcss/line-clamp": "^0.4.4", - "@types/node": "18.7.16", - "@types/numeral": "^2.0.2", - "@types/react-big-calendar": "^1.8.8", - "@types/react-redux": "^7.1.24", - "@typescript-eslint/eslint-plugin": "^5.37.0", - "@typescript-eslint/parser": "^5.37.0", - "autoprefixer": "^10.4.0", - "cross-env": "^7.0.3", - "eslint": "^8.23.1", - "eslint-config-next": "^13.0.4", - "eslint-config-prettier": "^8.5.0", - "eslint-import-resolver-typescript": "^3.6.1", - "eslint-plugin-import": "^2.29.1", - "postcss": "^8.4.4", - "postcss-import": "^14.1.0", - "prettier": "^3.2.4", - "tailwindcss": "^3.4.1", - "typescript": "^5.4.5" + "@eslint/js": "^10.0.1", + "@playwright/test": "^1.60.0", + "@tailwindcss/postcss": "^4.3.0", + "@tailwindcss/typography": "^0.5.20", + "@types/node": "^25.9.2", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.2", + "autoprefixer": "^10.5.0", + "eslint": "^10.4.1", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17.6.0", + "postcss": "^8.5.15", + "tailwindcss": "^4.3.0", + "typescript": "^6.0.3", + "typescript-eslint": "^8.60.1", + "vite": "^8.0.16", + "vitest": "^4.1.8" } } diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 0000000..4690bfb --- /dev/null +++ b/frontend/playwright.config.ts @@ -0,0 +1,31 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './tests/e2e', + testMatch: '**/*.e2e.ts', + testIgnore: '**/*.seeded.e2e.ts', + timeout: 30_000, + expect: { + timeout: 5_000, + }, + fullyParallel: true, + retries: 0, + reporter: 'list', + use: { + baseURL: 'http://127.0.0.1:4173', + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], + webServer: { + command: 'npm run build && npm run preview -- --host 127.0.0.1 --port 4173 --strictPort', + url: 'http://127.0.0.1:4173', + reuseExistingServer: true, + timeout: 120_000, + }, +}); diff --git a/frontend/playwright.content.config.ts b/frontend/playwright.content.config.ts new file mode 100644 index 0000000..8ae5cd3 --- /dev/null +++ b/frontend/playwright.content.config.ts @@ -0,0 +1,30 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './tests/e2e', + testMatch: '**/*.seeded.e2e.ts', + timeout: 30_000, + expect: { + timeout: 5_000, + }, + fullyParallel: false, + retries: 0, + reporter: 'list', + use: { + baseURL: 'http://127.0.0.1:4174', + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], + webServer: { + command: 'npm run build && npm run preview -- --host 127.0.0.1 --port 4174 --strictPort', + url: 'http://127.0.0.1:4174', + reuseExistingServer: true, + timeout: 120_000, + }, +}); diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js index 36f40f3..14502dc 100644 --- a/frontend/postcss.config.js +++ b/frontend/postcss.config.js @@ -1,9 +1,6 @@ -/* eslint-env node */ -module.exports = { +export default { plugins: { - 'postcss-import': {}, - 'tailwindcss/nesting': {}, - tailwindcss: {}, + "@tailwindcss/postcss": {}, autoprefixer: {}, - } -} \ No newline at end of file + }, +} diff --git a/frontend/public/placeholder.svg b/frontend/public/placeholder.svg new file mode 100644 index 0000000..76e9182 --- /dev/null +++ b/frontend/public/placeholder.svg @@ -0,0 +1,43 @@ + + + + + + + + diff --git a/frontend/public/robots.txt b/frontend/public/robots.txt new file mode 100644 index 0000000..6018e70 --- /dev/null +++ b/frontend/public/robots.txt @@ -0,0 +1,14 @@ +User-agent: Googlebot +Allow: / + +User-agent: Bingbot +Allow: / + +User-agent: Twitterbot +Allow: / + +User-agent: facebookexternalhit +Allow: / + +User-agent: * +Allow: / diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..827db2d --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,10 @@ +import { AppProviders } from '@/app/AppProviders'; +import { AppRouter } from '@/app/AppRouter'; + +const App = () => ( + + + +); + +export default App; diff --git a/frontend/src/app/AppProviders.tsx b/frontend/src/app/AppProviders.tsx new file mode 100644 index 0000000..e60b2c9 --- /dev/null +++ b/frontend/src/app/AppProviders.tsx @@ -0,0 +1,31 @@ +import type { ReactNode } from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { BrowserRouter } from 'react-router-dom'; +import { Toaster } from '@/components/ui/toaster'; +import { Toaster as Sonner } from '@/components/ui/sonner'; +import { TooltipProvider } from '@/components/ui/tooltip'; +import { ThemeProvider } from '@/components/theme-provider'; +import { AuthProvider } from '@/contexts/AuthContext'; +import { APP_DEFAULT_THEME } from '@/shared/constants/theme'; + +const queryClient = new QueryClient(); + +interface AppProvidersProps { + readonly children: ReactNode; +} + +export function AppProviders({ children }: AppProvidersProps) { + return ( + + + + + + + {children} + + + + + ); +} diff --git a/frontend/src/app/AppRouter.tsx b/frontend/src/app/AppRouter.tsx new file mode 100644 index 0000000..5ccd4d8 --- /dev/null +++ b/frontend/src/app/AppRouter.tsx @@ -0,0 +1,6 @@ +import { useRoutes } from 'react-router-dom'; +import { appRoutes } from '@/app/appRoutes'; + +export function AppRouter() { + return useRoutes(appRoutes); +} diff --git a/frontend/src/app/ModuleRouteGuard.tsx b/frontend/src/app/ModuleRouteGuard.tsx new file mode 100644 index 0000000..abcb133 --- /dev/null +++ b/frontend/src/app/ModuleRouteGuard.tsx @@ -0,0 +1,34 @@ +import type { ReactNode } from 'react'; +import { Suspense } from 'react'; +import { Navigate, useLocation } from 'react-router-dom'; +import { useShellOutletContext } from '@/app/shellOutletContext'; +import { StatePanel } from '@/components/ui/state-panel'; +import { + canUserRoleAccessModuleRoute, + DEFAULT_MODULE_ROUTE_PATH, +} from '@/shared/constants/moduleRoutes'; + +interface ModuleRouteGuardProps { + readonly children: ReactNode; +} + +export function ModuleRouteGuard({ children }: ModuleRouteGuardProps) { + const location = useLocation(); + const shell = useShellOutletContext(); + + if (!canUserRoleAccessModuleRoute(location.pathname, shell.userRole)) { + return ; + } + + return ( + + Loading module... + + )} + > + {children} + + ); +} diff --git a/frontend/src/app/appRoutes.test.ts b/frontend/src/app/appRoutes.test.ts new file mode 100644 index 0000000..0be3486 --- /dev/null +++ b/frontend/src/app/appRoutes.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; +import { appRoutes } from '@/app/appRoutes'; +import { MODULES } from '@/shared/constants/appData'; +import { APP_ROUTE_PATHS } from '@/shared/constants/routes'; + +describe('app routes', () => { + it('declares the required top-level routes through object config', () => { + expect(appRoutes.map((route) => route.path)).toEqual([ + APP_ROUTE_PATHS.home, + APP_ROUTE_PATHS.login, + APP_ROUTE_PATHS.notFound, + ]); + }); + + it('declares one shell child route for every product module route', () => { + const shellRoute = appRoutes.find((route) => route.path === APP_ROUTE_PATHS.home); + const childPaths = shellRoute?.children + ?.map((route) => route.path) + .filter((path): path is string => typeof path === 'string'); + + expect(childPaths).toEqual(MODULES.map((module) => module.routePath.slice(1))); + }); + + it('redirects the shell index route to the dashboard route', () => { + const shellRoute = appRoutes.find((route) => route.path === APP_ROUTE_PATHS.home); + const indexRoute = shellRoute?.children?.find((route) => route.index); + + expect(indexRoute).toBeDefined(); + expect(indexRoute?.element).toBeDefined(); + }); +}); diff --git a/frontend/src/app/appRoutes.tsx b/frontend/src/app/appRoutes.tsx new file mode 100644 index 0000000..d03c0b3 --- /dev/null +++ b/frontend/src/app/appRoutes.tsx @@ -0,0 +1,125 @@ +import { Navigate } from 'react-router-dom'; +import type { ReactNode } from 'react'; +import type { RouteObject } from 'react-router-dom'; +import { APP_ROUTE_PATHS } from '@/shared/constants/routes'; +import { + CampusAttendancePage, + ClassroomSupportPage, + ClassroomTimerPage, + CommunityPartnershipsPage, + DashboardPage, + DirectorDashboardPage, + EmotionalIntelligencePage, + EsaFundingPage, + FramePage, + HandbookPoliciesPage, + InternalAlertsPage, + ParentCommunicationPage, + QbsSafetyPage, + SafetyProtocolsPage, + SignLanguagePage, + VocationalOpportunitiesPage, + WalkthroughPage, + ZonesOfRegulationPage, +} from '@/app/lazyModulePages'; +import { ModuleRouteGuard } from '@/app/ModuleRouteGuard'; +import AppLayout from '@/components/AppLayout'; +import Login from '@/pages/Login'; +import NotFound from '@/pages/NotFound'; + +function moduleRoute(element: ReactNode): ReactNode { + return {element}; +} + +export const appRoutes: RouteObject[] = [ + { + path: APP_ROUTE_PATHS.home, + element: , + children: [ + { + index: true, + element: , + }, + { + path: APP_ROUTE_PATHS.dashboard.slice(1), + element: moduleRoute(), + }, + { + path: APP_ROUTE_PATHS.frame.slice(1), + element: moduleRoute(), + }, + { + path: APP_ROUTE_PATHS.classroom.slice(1), + element: moduleRoute(), + }, + { + path: APP_ROUTE_PATHS.timer.slice(1), + element: moduleRoute(), + }, + { + path: APP_ROUTE_PATHS.qbs.slice(1), + element: moduleRoute(), + }, + { + path: APP_ROUTE_PATHS.ei.slice(1), + element: moduleRoute(), + }, + { + path: APP_ROUTE_PATHS.zones.slice(1), + element: moduleRoute(), + }, + { + path: APP_ROUTE_PATHS.signs.slice(1), + element: moduleRoute(), + }, + { + path: APP_ROUTE_PATHS.attendance.slice(1), + element: moduleRoute(), + }, + { + path: APP_ROUTE_PATHS.parentComm.slice(1), + element: moduleRoute(), + }, + { + path: APP_ROUTE_PATHS.internalComm.slice(1), + element: moduleRoute(), + }, + { + path: APP_ROUTE_PATHS.safety.slice(1), + element: moduleRoute(), + }, + { + path: APP_ROUTE_PATHS.handbook.slice(1), + element: moduleRoute(), + }, + { + path: APP_ROUTE_PATHS.community.slice(1), + element: moduleRoute(), + }, + { + path: APP_ROUTE_PATHS.vocational.slice(1), + element: moduleRoute(), + }, + { + path: APP_ROUTE_PATHS.esa.slice(1), + element: moduleRoute(), + }, + { + path: APP_ROUTE_PATHS.walkthrough.slice(1), + element: moduleRoute(), + }, + { + path: APP_ROUTE_PATHS.director.slice(1), + element: moduleRoute(), + }, + ], + }, + { + path: APP_ROUTE_PATHS.login, + element: , + }, + { + path: APP_ROUTE_PATHS.notFound, + element: , + }, +]; diff --git a/frontend/src/app/lazyModulePages.ts b/frontend/src/app/lazyModulePages.ts new file mode 100644 index 0000000..e2e50b6 --- /dev/null +++ b/frontend/src/app/lazyModulePages.ts @@ -0,0 +1,20 @@ +import { lazy } from 'react'; + +export const DashboardPage = lazy(() => import('@/pages/modules/DashboardPage')); +export const FramePage = lazy(() => import('@/pages/modules/FramePage')); +export const ClassroomSupportPage = lazy(() => import('@/pages/modules/ClassroomSupportPage')); +export const ClassroomTimerPage = lazy(() => import('@/pages/modules/ClassroomTimerPage')); +export const QbsSafetyPage = lazy(() => import('@/pages/modules/QbsSafetyPage')); +export const EmotionalIntelligencePage = lazy(() => import('@/pages/modules/EmotionalIntelligencePage')); +export const ZonesOfRegulationPage = lazy(() => import('@/pages/modules/ZonesOfRegulationPage')); +export const SignLanguagePage = lazy(() => import('@/pages/modules/SignLanguagePage')); +export const CampusAttendancePage = lazy(() => import('@/pages/modules/CampusAttendancePage')); +export const ParentCommunicationPage = lazy(() => import('@/pages/modules/ParentCommunicationPage')); +export const InternalAlertsPage = lazy(() => import('@/pages/modules/InternalAlertsPage')); +export const SafetyProtocolsPage = lazy(() => import('@/pages/modules/SafetyProtocolsPage')); +export const HandbookPoliciesPage = lazy(() => import('@/pages/modules/HandbookPoliciesPage')); +export const CommunityPartnershipsPage = lazy(() => import('@/pages/modules/CommunityPartnershipsPage')); +export const VocationalOpportunitiesPage = lazy(() => import('@/pages/modules/VocationalOpportunitiesPage')); +export const EsaFundingPage = lazy(() => import('@/pages/modules/EsaFundingPage')); +export const WalkthroughPage = lazy(() => import('@/pages/modules/WalkthroughPage')); +export const DirectorDashboardPage = lazy(() => import('@/pages/modules/DirectorDashboardPage')); diff --git a/frontend/src/app/shellOutletContext.ts b/frontend/src/app/shellOutletContext.ts new file mode 100644 index 0000000..8749062 --- /dev/null +++ b/frontend/src/app/shellOutletContext.ts @@ -0,0 +1,8 @@ +import { useOutletContext } from 'react-router-dom'; +import type { ShellOutletContext } from '@/business/app-shell/types'; + +export type { ShellOutletContext }; + +export function useShellOutletContext(): ShellOutletContext { + return useOutletContext(); +} diff --git a/frontend/src/business/README.md b/frontend/src/business/README.md new file mode 100644 index 0000000..4bfc7ea --- /dev/null +++ b/frontend/src/business/README.md @@ -0,0 +1,35 @@ +# Business Layer + +Module-specific business logic lives here. + +Use this layer for: + +- React Query hooks. +- View model mapping. +- Workflow state. +- Calculations. +- Filtering and sorting. +- Validation helpers. + +Do not render JSX in this layer. + +Use shared business helpers for repeated cross-module patterns: + +- `@/shared/business/queryMutations` for mutations that invalidate React Query cache keys. +- `@/shared/business/apiListRows` for extracting or mapping `ApiListResponse.rows`. +- `@/shared/business/queryState` for combining loading and error state from multiple queries. + +Do not duplicate local `useMutation` + `useQueryClient` + `invalidateQueries` boilerplate in feature hooks unless the mutation needs behavior the shared helper cannot represent clearly. + +Expected module shape: + +```text +business// + hooks.ts + mappers.ts + selectors.ts + validators.ts + types.ts +``` + +Only create files that the module actually needs. diff --git a/frontend/src/business/app-shell/hooks.ts b/frontend/src/business/app-shell/hooks.ts new file mode 100644 index 0000000..5f7059e --- /dev/null +++ b/frontend/src/business/app-shell/hooks.ts @@ -0,0 +1,186 @@ +import { useMemo, useState } from 'react'; +import { useLocation, useNavigate } from 'react-router-dom'; +import { MODULES } from '@/shared/constants/appData'; +import { + DEFAULT_CAMPUS_LABEL, + findCampusByNameOrCode, +} from '@/shared/constants/campusDisplay'; +import { + getModuleIdByRoutePath, + getModuleRoutePath, +} from '@/shared/constants/moduleRoutes'; +import type { ModuleId, UserRole } from '@/shared/types/app'; +import { + DEFAULT_GUEST_PREVIEW_ROLE, + GUEST_PREVIEW_USER_NAME, + GUEST_PREVIEW_ROLE_OPTIONS, +} from '@/shared/constants/guestPreviewRoles'; +import { + getAccessibleModuleId, + getAccessibleModules, + getSidebarCampusInitial, + getSidebarRoleLabel, + shouldShowMobileSidebarOverlay, +} from '@/business/app-shell/selectors'; +import { useCampusCatalog } from '@/business/campuses/hooks'; +import type { + AppShellState, + SidebarPage, + SidebarProps, + UseAppShellOptions, +} from '@/business/app-shell/types'; + +function getUserRole(options: UseAppShellOptions, guestPreviewRole: UserRole): UserRole { + return options.isAuthenticated && options.profile?.role ? options.profile.role : guestPreviewRole; +} + +function getUserName(options: UseAppShellOptions): string { + return options.isAuthenticated && options.profile?.full_name ? options.profile.full_name : GUEST_PREVIEW_USER_NAME; +} + +function getUserCampus(options: UseAppShellOptions): string { + return options.isAuthenticated && options.profile?.campus ? options.profile.campus : DEFAULT_CAMPUS_LABEL; +} + +export function useAppShell(options: UseAppShellOptions): AppShellState { + const location = useLocation(); + const navigate = useNavigate(); + const campusCatalog = useCampusCatalog(); + const [sidebarCollapsed, setSidebarCollapsed] = useState(false); + const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); + const [zoneCheckIn, setZoneCheckIn] = useState(null); + const [showSignInModal, setShowSignInModal] = useState(false); + const [guestPreviewRole, setGuestPreviewRole] = useState(DEFAULT_GUEST_PREVIEW_ROLE); + const [showGuestRolePicker, setShowGuestRolePicker] = useState(false); + + const userRole = getUserRole(options, guestPreviewRole); + const userName = getUserName(options); + const userCampus = getUserCampus(options); + const currentRouteModule = getModuleIdByRoutePath(location.pathname); + const currentModule = getAccessibleModuleId(MODULES, currentRouteModule, userRole); + const activeModule = getAccessibleModuleId(MODULES, currentModule, userRole); + const campusInfo = findCampusByNameOrCode(campusCatalog.campuses, userCampus); + const mobileOverlayVisible = shouldShowMobileSidebarOverlay(options.isMobile, mobileSidebarOpen); + + const setCurrentModule = (id: ModuleId) => { + navigate(getModuleRoutePath(id)); + + if (options.isMobile) { + setMobileSidebarOpen(false); + } + }; + + const currentGuestPreviewRole = useMemo( + () => GUEST_PREVIEW_ROLE_OPTIONS.find((role) => role.value === guestPreviewRole) || GUEST_PREVIEW_ROLE_OPTIONS[0], + [guestPreviewRole], + ); + + const toggleSidebar = () => { + if (options.isMobile) { + setMobileSidebarOpen((current) => !current); + return; + } + + setSidebarCollapsed((current) => !current); + }; + + const sidebarProps: SidebarProps = { + currentModule: activeModule, + setCurrentModule, + userRole, + collapsed: options.isMobile ? false : sidebarCollapsed, + setCollapsed: setSidebarCollapsed, + campusInfo, + }; + + const topBarProps = { + userRole, + userName, + campusInfo, + toggleSidebar, + }; + + const shellOutletContext = { + userRole, + userName, + userCampus, + zoneCheckIn, + setZoneCheckIn, + setCurrentModule, + }; + + const guestBannerProps = { + guestPreviewRole, + guestPreviewRoles: GUEST_PREVIEW_ROLE_OPTIONS, + currentGuestPreviewRole, + showGuestRolePicker, + setGuestPreviewRole, + setShowGuestRolePicker, + onSignInClick: () => setShowSignInModal(true), + }; + + const footerProps = { + isAuthenticated: options.isAuthenticated, + userName, + userRole, + currentGuestPreviewRole, + setCurrentModule, + }; + + const signInModalProps = { + isOpen: showSignInModal, + onClose: () => setShowSignInModal(false), + }; + + return { + activeModule, + currentModule: activeModule, + userRole, + userName, + userCampus, + campusInfo, + sidebarCollapsed, + mobileSidebarOpen, + mobileOverlayVisible, + zoneCheckIn, + showSignInModal, + guestPreviewRole, + showGuestRolePicker, + guestPreviewRoles: GUEST_PREVIEW_ROLE_OPTIONS, + currentGuestPreviewRole, + sidebarProps, + topBarProps, + shellOutletContext, + guestBannerProps, + footerProps, + signInModalProps, + setSidebarCollapsed, + setMobileSidebarOpen, + setZoneCheckIn, + setShowSignInModal, + setGuestPreviewRole, + setShowGuestRolePicker, + setCurrentModule, + }; +} + +export function useSidebarPage({ + currentModule, + setCurrentModule, + userRole, + collapsed, + setCollapsed, + campusInfo, +}: SidebarProps): SidebarPage { + return { + currentModule, + userRole, + collapsed, + campusInfo, + modules: getAccessibleModules(MODULES, userRole), + roleLabel: getSidebarRoleLabel(userRole), + campusInitial: getSidebarCampusInitial(campusInfo), + setCurrentModule, + toggleCollapsed: () => setCollapsed(!collapsed), + }; +} diff --git a/frontend/src/business/app-shell/selectors.test.ts b/frontend/src/business/app-shell/selectors.test.ts new file mode 100644 index 0000000..376ad8b --- /dev/null +++ b/frontend/src/business/app-shell/selectors.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest'; +import { + canAccessModule, + getAccessibleModuleId, + getAccessibleModules, + getSidebarCampusInitial, + getSidebarRoleLabel, + shouldShowMobileSidebarOverlay, +} from '@/business/app-shell/selectors'; +import type { Module } from '@/shared/types/app'; + +const modules: readonly Module[] = [ + { + id: 'dashboard', + name: 'Dashboard', + icon: 'home', + roles: ['teacher', 'para', 'office', 'director', 'superintendent'], + color: 'text-blue-400', + routePath: '/dashboard', + }, + { + id: 'director', + name: 'Director', + icon: 'users', + roles: ['director', 'superintendent'], + color: 'text-emerald-400', + routePath: '/director-dashboard', + }, +]; + +describe('app-shell selectors', () => { + it('checks module access from module role metadata', () => { + expect(canAccessModule(modules, 'dashboard', 'teacher')).toBe(true); + expect(canAccessModule(modules, 'director', 'teacher')).toBe(false); + expect(canAccessModule(modules, 'director', 'superintendent')).toBe(true); + }); + + it('falls back to dashboard when the requested module is unavailable', () => { + expect(getAccessibleModuleId(modules, 'director', 'teacher')).toBe('dashboard'); + expect(getAccessibleModuleId(modules, 'director', 'director')).toBe('director'); + }); + + it('returns modules available to the selected role', () => { + expect(getAccessibleModules(modules, 'teacher').map((module) => module.id)).toEqual(['dashboard']); + expect(getAccessibleModules(modules, 'director').map((module) => module.id)).toEqual(['dashboard', 'director']); + }); + + it('formats sidebar role labels through shared auth role labels', () => { + expect(getSidebarRoleLabel('para')).toBe('Support Staff'); + expect(getSidebarRoleLabel('office')).toBe('Office Manager'); + }); + + it('returns a campus initial when campus branding is available', () => { + expect(getSidebarCampusInitial({ + id: 'campus-1', + mascot: 'Tigers', + fullName: 'Tigers Campus', + color: 'bg-orange-500', + bgGradient: 'from-orange-500 to-amber-500', + borderColor: 'border-orange-500/30', + textColor: 'text-orange-400', + bgLight: 'bg-orange-500/10', + description: 'Campus branding', + })).toBe('T'); + expect(getSidebarCampusInitial()).toBeNull(); + }); + + it('shows the mobile sidebar overlay only when mobile sidebar is open on mobile', () => { + expect(shouldShowMobileSidebarOverlay(true, true)).toBe(true); + expect(shouldShowMobileSidebarOverlay(true, false)).toBe(false); + expect(shouldShowMobileSidebarOverlay(false, true)).toBe(false); + }); +}); diff --git a/frontend/src/business/app-shell/selectors.ts b/frontend/src/business/app-shell/selectors.ts new file mode 100644 index 0000000..b4d95fa --- /dev/null +++ b/frontend/src/business/app-shell/selectors.ts @@ -0,0 +1,43 @@ +import { getAuthRoleLabel } from '@/business/auth/selectors'; +import type { CampusInfo, Module, ModuleId, UserRole } from '@/shared/types/app'; + +export function getAccessibleModules( + modules: readonly Module[], + userRole: UserRole, +): readonly Module[] { + return modules.filter((module) => module.roles.includes(userRole)); +} + +export function canAccessModule( + modules: readonly Module[], + moduleId: ModuleId, + userRole: UserRole, +): boolean { + return Boolean(modules.find((module) => module.id === moduleId)?.roles.includes(userRole)); +} + +export function getAccessibleModuleId( + modules: readonly Module[], + moduleId: ModuleId, + userRole: UserRole, +): ModuleId { + return canAccessModule(modules, moduleId, userRole) ? moduleId : 'dashboard'; +} + +export function getSidebarRoleLabel(role: UserRole): string { + return getAuthRoleLabel(role); +} + +export function getSidebarCampusInitial(campusInfo?: CampusInfo): string | null { + const mascot = campusInfo?.mascot.trim(); + + if (!mascot) { + return null; + } + + return mascot.charAt(0).toUpperCase(); +} + +export function shouldShowMobileSidebarOverlay(isMobile: boolean, mobileSidebarOpen: boolean): boolean { + return isMobile && mobileSidebarOpen; +} diff --git a/frontend/src/business/app-shell/types.ts b/frontend/src/business/app-shell/types.ts new file mode 100644 index 0000000..84e9d54 --- /dev/null +++ b/frontend/src/business/app-shell/types.ts @@ -0,0 +1,105 @@ +import type { Dispatch, SetStateAction } from 'react'; +import type { + CampusInfo, + Module, + ModuleId, + StaffProfile, + UserRole, +} from '@/shared/types/app'; +import type { TopBarProps } from '@/business/top-bar/types'; + +export interface GuestPreviewRoleOption { + readonly value: UserRole; + readonly label: string; + readonly color: string; +} + +export interface UseAppShellOptions { + readonly isAuthenticated: boolean; + readonly profile: StaffProfile | null; + readonly isMobile: boolean; +} + +export interface AppShellState { + readonly activeModule: ModuleId; + readonly currentModule: ModuleId; + readonly userRole: UserRole; + readonly userName: string; + readonly userCampus: string; + readonly campusInfo?: CampusInfo; + readonly sidebarCollapsed: boolean; + readonly mobileSidebarOpen: boolean; + readonly mobileOverlayVisible: boolean; + readonly zoneCheckIn: string | null; + readonly showSignInModal: boolean; + readonly guestPreviewRole: UserRole; + readonly showGuestRolePicker: boolean; + readonly guestPreviewRoles: readonly GuestPreviewRoleOption[]; + readonly currentGuestPreviewRole: GuestPreviewRoleOption; + readonly sidebarProps: SidebarProps; + readonly topBarProps: TopBarProps; + readonly shellOutletContext: ShellOutletContext; + readonly guestBannerProps: GuestBannerProps; + readonly footerProps: AppFooterProps; + readonly signInModalProps: SignInModalProps; + readonly setSidebarCollapsed: Dispatch>; + readonly setMobileSidebarOpen: Dispatch>; + readonly setZoneCheckIn: Dispatch>; + readonly setShowSignInModal: Dispatch>; + readonly setGuestPreviewRole: Dispatch>; + readonly setShowGuestRolePicker: Dispatch>; + readonly setCurrentModule: (id: ModuleId) => void; +} + +export interface ShellOutletContext { + readonly userRole: UserRole; + readonly userName: string; + readonly userCampus: string; + readonly zoneCheckIn: string | null; + readonly setZoneCheckIn: (zone: string | null) => void; + readonly setCurrentModule: (id: ModuleId) => void; +} + +export interface SidebarProps { + readonly currentModule: ModuleId; + readonly setCurrentModule: (id: ModuleId) => void; + readonly userRole: UserRole; + readonly collapsed: boolean; + readonly setCollapsed: (value: boolean) => void; + readonly campusInfo?: CampusInfo; +} + +export interface SidebarPage { + readonly currentModule: ModuleId; + readonly userRole: UserRole; + readonly collapsed: boolean; + readonly campusInfo?: CampusInfo; + readonly modules: readonly Module[]; + readonly roleLabel: string; + readonly campusInitial: string | null; + readonly setCurrentModule: (id: ModuleId) => void; + readonly toggleCollapsed: () => void; +} + +export interface GuestBannerProps { + readonly guestPreviewRole: UserRole; + readonly guestPreviewRoles: readonly GuestPreviewRoleOption[]; + readonly currentGuestPreviewRole: GuestPreviewRoleOption; + readonly showGuestRolePicker: boolean; + readonly setGuestPreviewRole: (role: UserRole) => void; + readonly setShowGuestRolePicker: (open: boolean) => void; + readonly onSignInClick: () => void; +} + +export interface AppFooterProps { + readonly isAuthenticated: boolean; + readonly userName: string; + readonly userRole: UserRole; + readonly currentGuestPreviewRole: GuestPreviewRoleOption; + readonly setCurrentModule: (id: ModuleId) => void; +} + +export interface SignInModalProps { + readonly isOpen: boolean; + readonly onClose: () => void; +} diff --git a/frontend/src/business/auth/hooks.ts b/frontend/src/business/auth/hooks.ts new file mode 100644 index 0000000..20899c9 --- /dev/null +++ b/frontend/src/business/auth/hooks.ts @@ -0,0 +1,286 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { getCurrentUser, signIn, signOut as signOutRequest } from '@/shared/api/auth'; +import { AuthExpiredError } from '@/shared/api/httpClient'; +import { + AUTH_MODAL_SIGNIN_CLOSE_DELAY_MS, + AUTH_MODAL_SIGNUP_CLOSE_DELAY_MS, +} from '@/shared/constants/auth'; +import { APP_ROUTE_PATHS } from '@/shared/constants/routes'; +import { CurrentUser } from '@/shared/types/auth'; +import { StaffProfile } from '@/shared/types/app'; +import { toStaffProfile } from '@/business/auth/mappers'; +import { useCampusCatalog } from '@/business/campuses/hooks'; +import { AuthActionResult, AuthSessionState } from '@/business/auth/types'; +import { getErrorMessage, getOptionalErrorMessage } from '@/shared/errors/errorMessages'; +import type { + AuthModalDraft, + AuthModalMode, + AuthModalWorkflowInput, + AuthSignupStep, +} from '@/business/auth/types'; +import { + getNextSignupStep, + getPreviousSignupStep, + getSignupCampusName, + validateSignupStepOne, +} from '@/business/auth/selectors'; +import type { CampusId, UserRole } from '@/shared/types/app'; + +export function useAuthSession(): AuthSessionState { + const navigate = useNavigate(); + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + + const clearSession = useCallback(() => { + setUser(null); + }, []); + + const redirectToLogin = useCallback(() => { + navigate(APP_ROUTE_PATHS.login, { replace: true }); + }, [navigate]); + + useEffect(() => { + let isActive = true; + + void getCurrentUser() + .then((currentUser) => { + if (isActive) { + setUser(currentUser); + } + }) + .catch((error: unknown) => { + if (isActive) { + clearSession(); + if (error instanceof AuthExpiredError) { + redirectToLogin(); + } + } + }) + .finally(() => { + if (isActive) { + setLoading(false); + } + }); + + return () => { + isActive = false; + }; + }, [clearSession, redirectToLogin]); + + const handleSignIn = useCallback(async (email: string, password: string): Promise => { + setLoading(true); + + try { + const currentUser = await signIn({ email, password }); + setUser(currentUser); + return { error: null }; + } catch (error) { + clearSession(); + if (error instanceof AuthExpiredError) { + redirectToLogin(); + } + return { error: getErrorMessage(error, 'Sign in failed') }; + } finally { + setLoading(false); + } + }, [clearSession, redirectToLogin]); + + const signUp = useCallback(async (): Promise => ({ + error: + 'Account creation is not available until backend product roles, campus assignment, and staff profile creation are implemented.', + }), []); + + const signOut = useCallback(async (): Promise => { + try { + await signOutRequest(); + clearSession(); + return { error: null }; + } catch (error) { + if (error instanceof AuthExpiredError) { + clearSession(); + redirectToLogin(); + return { error: null }; + } + return { error: getErrorMessage(error, 'Sign out failed') }; + } + }, [clearSession, redirectToLogin]); + + const updateProfile = useCallback(async (): Promise => ({ + error: 'Profile updates are not available until backend staff profile updates are implemented.', + }), []); + + const profile: StaffProfile | null = useMemo(() => (user ? toStaffProfile(user) : null), [user]); + + return { + user, + profile, + loading, + signIn: handleSignIn, + signUp, + signOut, + updateProfile, + isAuthenticated: Boolean(user), + }; +} + +const initialAuthModalDraft: AuthModalDraft = { + email: '', + password: '', + confirmPassword: '', + fullName: '', + role: 'teacher', + campus: '', + showPassword: false, +}; + +export function useAuthModalWorkflow({ + signIn: signInAction, + signUp: signUpAction, + onClose, +}: AuthModalWorkflowInput) { + const campusCatalog = useCampusCatalog(); + const [mode, setMode] = useState('signin'); + const [signupStep, setSignupStep] = useState(1); + const [draft, setDraft] = useState(initialAuthModalDraft); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + + const resetForm = useCallback(() => { + setDraft(initialAuthModalDraft); + setError(null); + setSuccess(null); + setMode('signin'); + setSignupStep(1); + }, []); + + const closeAfterSuccess = useCallback((delayMs: number) => { + window.setTimeout(() => { + onClose(); + resetForm(); + }, delayMs); + }, [onClose, resetForm]); + + const updateDraft = useCallback((patch: Partial) => { + setDraft((currentDraft) => ({ ...currentDraft, ...patch })); + }, []); + + const handleSignIn = useCallback(async () => { + setError(null); + setSuccess(null); + setLoading(true); + + const result = await signInAction(draft.email, draft.password); + setLoading(false); + + if (result.error) { + setError(result.error); + return; + } + + setSuccess('Signed in successfully!'); + closeAfterSuccess(AUTH_MODAL_SIGNIN_CLOSE_DELAY_MS); + }, [closeAfterSuccess, draft.email, draft.password, signInAction]); + + const handleSignUp = useCallback(async () => { + setError(null); + setSuccess(null); + + const campusName = getSignupCampusName(draft.campus, campusCatalog.campuses); + + if (!campusName) { + setError(campusCatalog.error ? 'Campus list is unavailable. Please try again.' : 'Please select your campus mascot'); + return; + } + + setLoading(true); + const result = await signUpAction(draft.email, draft.password, draft.fullName, draft.role, campusName); + setLoading(false); + + if (result.error) { + setError(result.error); + return; + } + + setSuccess('Account created! You are now signed in.'); + closeAfterSuccess(AUTH_MODAL_SIGNUP_CLOSE_DELAY_MS); + }, [ + campusCatalog.campuses, + campusCatalog.error, + closeAfterSuccess, + draft.campus, + draft.email, + draft.fullName, + draft.password, + draft.role, + signUpAction, + ]); + + const goToNextStep = useCallback(() => { + setError(null); + + if (signupStep === 1) { + const validationError = validateSignupStepOne(draft); + if (validationError) { + setError(validationError); + return; + } + setSignupStep(2); + return; + } + + if (signupStep === 2) { + setSignupStep(3); + return; + } + + void handleSignUp(); + }, [draft, handleSignUp, signupStep]); + + const goToPreviousStep = useCallback(() => { + setError(null); + setSignupStep((currentStep) => getPreviousSignupStep(currentStep)); + }, []); + + const handleClose = useCallback(() => { + resetForm(); + onClose(); + }, [onClose, resetForm]); + + const switchMode = useCallback((nextMode: AuthModalMode) => { + setMode(nextMode); + setError(null); + setSuccess(null); + setSignupStep(1); + }, []); + + const selectedCampusId = draft.campus; + + return { + state: { + mode, + signupStep, + draft, + loading, + error, + success, + selectedCampusId, + campuses: campusCatalog.campuses, + campusesLoading: campusCatalog.isLoading, + campusesError: getOptionalErrorMessage(campusCatalog.error), + }, + actions: { + updateDraft, + setRole: (role: UserRole) => updateDraft({ role }), + setCampus: (campus: CampusId | '') => updateDraft({ campus }), + setShowPassword: (showPassword: boolean) => updateDraft({ showPassword }), + handleSignIn, + goToNextStep, + goToPreviousStep, + handleClose, + switchMode, + getNextSignupStep, + }, + }; +} diff --git a/frontend/src/business/auth/mappers.test.ts b/frontend/src/business/auth/mappers.test.ts new file mode 100644 index 0000000..d62fc71 --- /dev/null +++ b/frontend/src/business/auth/mappers.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest'; +import { + getUserDisplayName, + toStaffProfile, +} from '@/business/auth/mappers'; +import { DEFAULT_CAMPUS_LABEL } from '@/shared/constants/campusDisplay'; +import type { CurrentUser } from '@/shared/types/auth'; + +function createUser(overrides: Partial = {}): CurrentUser { + return { + id: 'user-1', + email: 'teacher@example.com', + firstName: 'Ava', + lastName: 'Lee', + app_role: null, + organizations: null, + organizationsId: 'org-1', + campus: { id: 'campus-1', name: 'North Campus', code: 'north' }, + campusId: 'campus-1', + productRole: 'teacher', + staffProfile: { + id: 'staff-1', + employee_number: 'E-1', + job_title: 'Teacher', + staff_type: 'instructional', + status: 'active', + organizationId: 'org-1', + campusId: 'campus-1', + userId: 'user-1', + }, + permissions: [], + ...overrides, + }; +} + +describe('auth mappers', () => { + it('formats display names and falls back to email', () => { + expect(getUserDisplayName(createUser())).toBe('Ava Lee'); + expect(getUserDisplayName(createUser({ firstName: null, lastName: null }))).toBe('teacher@example.com'); + }); + + it('maps current user to staff profile with backend staff id and campus name', () => { + expect(toStaffProfile(createUser())).toEqual({ + id: 'staff-1', + full_name: 'Ava Lee', + role: 'teacher', + campus: 'North Campus', + avatar_url: null, + }); + }); + + it('uses user id and default campus label when optional backend profile data is absent', () => { + expect(toStaffProfile(createUser({ + campus: null, + campusId: null, + productRole: 'para', + staffProfile: null, + }))).toEqual({ + id: 'user-1', + full_name: 'Ava Lee', + role: 'para', + campus: DEFAULT_CAMPUS_LABEL, + avatar_url: null, + }); + }); +}); diff --git a/frontend/src/business/auth/mappers.ts b/frontend/src/business/auth/mappers.ts new file mode 100644 index 0000000..e3f3d63 --- /dev/null +++ b/frontend/src/business/auth/mappers.ts @@ -0,0 +1,24 @@ +import { DEFAULT_PRODUCT_ROLE } from '@/shared/constants/roles'; +import { DEFAULT_CAMPUS_LABEL } from '@/shared/constants/campusDisplay'; +import { CurrentUser } from '@/shared/types/auth'; +import { StaffProfile, UserRole } from '@/shared/types/app'; + +function getProductRole(user: CurrentUser): UserRole { + return user.productRole || DEFAULT_PRODUCT_ROLE; +} + +export function getUserDisplayName(user: CurrentUser): string { + const fullName = [user.firstName, user.lastName].filter(Boolean).join(' ').trim(); + + return fullName || user.email; +} + +export function toStaffProfile(user: CurrentUser): StaffProfile { + return { + id: user.staffProfile?.id || user.id, + full_name: getUserDisplayName(user), + role: getProductRole(user), + campus: user.campus?.name || user.campus?.code || DEFAULT_CAMPUS_LABEL, + avatar_url: null, + }; +} diff --git a/frontend/src/business/auth/selectors.test.ts b/frontend/src/business/auth/selectors.test.ts new file mode 100644 index 0000000..44a532f --- /dev/null +++ b/frontend/src/business/auth/selectors.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest'; +import { + getAuthRoleLabel, + getNextSignupStep, + getPreviousSignupStep, + getSignupCampusName, + validateSignupStepOne, +} from '@/business/auth/selectors'; +import type { AuthModalDraft } from '@/business/auth/types'; +import { toCampusCatalogViewModels } from '@/business/campuses/mappers'; +import { campusCatalogItemSeed } from '@/test-seeds/campuses'; + +const validDraft: AuthModalDraft = { + email: 'staff@example.com', + password: 'secret1', + confirmPassword: 'secret1', + fullName: 'Ava Lee', + role: 'teacher', + campus: 'tigers', + showPassword: false, +}; + +describe('auth selectors', () => { + const campuses = toCampusCatalogViewModels([campusCatalogItemSeed]); + + it('validates sign-up step one in field order', () => { + expect(validateSignupStepOne(validDraft)).toBeNull(); + expect(validateSignupStepOne({ ...validDraft, fullName: ' ' })).toBe('Please enter your full name'); + expect(validateSignupStepOne({ ...validDraft, email: 'invalid' })).toBe('Please enter a valid email address'); + expect(validateSignupStepOne({ ...validDraft, password: 'short', confirmPassword: 'short' })).toBe( + 'Password must be at least 6 characters', + ); + expect(validateSignupStepOne({ ...validDraft, confirmPassword: 'different' })).toBe('Passwords do not match'); + }); + + it('keeps signup step navigation inside the supported range', () => { + expect(getNextSignupStep(1)).toBe(2); + expect(getNextSignupStep(2)).toBe(3); + expect(getNextSignupStep(3)).toBe(3); + expect(getPreviousSignupStep(3)).toBe(2); + expect(getPreviousSignupStep(2)).toBe(1); + expect(getPreviousSignupStep(1)).toBe(1); + }); + + it('maps campus and role display labels', () => { + expect(getSignupCampusName('tigers', campuses)).toBe('Tigers'); + expect(getSignupCampusName('', campuses)).toBeNull(); + expect(getAuthRoleLabel('para')).toBe('Support Staff'); + expect(getAuthRoleLabel('office')).toBe('Office Manager'); + }); +}); diff --git a/frontend/src/business/auth/selectors.ts b/frontend/src/business/auth/selectors.ts new file mode 100644 index 0000000..be833cc --- /dev/null +++ b/frontend/src/business/auth/selectors.ts @@ -0,0 +1,71 @@ +import type { CampusId, CampusInfo } from '@/shared/types/app'; +import type { UserRole } from '@/shared/types/app'; +import type { AuthModalDraft, AuthSignupStep } from '@/business/auth/types'; + +export function validateSignupStepOne(draft: AuthModalDraft): string | null { + if (!draft.fullName.trim()) { + return 'Please enter your full name'; + } + + if (!draft.email.trim() || !draft.email.includes('@')) { + return 'Please enter a valid email address'; + } + + if (draft.password.length < 6) { + return 'Password must be at least 6 characters'; + } + + if (draft.password !== draft.confirmPassword) { + return 'Passwords do not match'; + } + + return null; +} + +export function getNextSignupStep(step: AuthSignupStep): AuthSignupStep { + if (step === 1) { + return 2; + } + + if (step === 2) { + return 3; + } + + return 3; +} + +export function getPreviousSignupStep(step: AuthSignupStep): AuthSignupStep { + if (step === 3) { + return 2; + } + + if (step === 2) { + return 1; + } + + return 1; +} + +export function getSignupCampusName(campus: CampusId | '', campuses: readonly CampusInfo[]): string | null { + if (!campus) { + return null; + } + + const selectedCampus = campuses.find((item) => item.id === campus); + return selectedCampus?.mascot || campus; +} + +export function getAuthRoleLabel(role: UserRole): string { + switch (role) { + case 'teacher': + return 'Teacher'; + case 'para': + return 'Support Staff'; + case 'office': + return 'Office Manager'; + case 'director': + return 'Director'; + case 'superintendent': + return 'Superintendent'; + } +} diff --git a/frontend/src/business/auth/types.ts b/frontend/src/business/auth/types.ts new file mode 100644 index 0000000..014b9e1 --- /dev/null +++ b/frontend/src/business/auth/types.ts @@ -0,0 +1,43 @@ +import { CurrentUser } from '@/shared/types/auth'; +import { CampusId, StaffProfile, UserRole } from '@/shared/types/app'; + +export interface AuthActionResult { + readonly error: string | null; +} + +export interface AuthSessionState { + readonly user: CurrentUser | null; + readonly profile: StaffProfile | null; + readonly loading: boolean; + readonly isAuthenticated: boolean; + readonly signIn: (email: string, password: string) => Promise; + readonly signUp: ( + email: string, + password: string, + fullName: string, + role: UserRole, + campus: string, + ) => Promise; + readonly signOut: () => Promise; + readonly updateProfile: (updates: Partial) => Promise; +} + +export type AuthModalMode = 'signin' | 'signup'; + +export type AuthSignupStep = 1 | 2 | 3; + +export interface AuthModalDraft { + readonly email: string; + readonly password: string; + readonly confirmPassword: string; + readonly fullName: string; + readonly role: UserRole; + readonly campus: CampusId | ''; + readonly showPassword: boolean; +} + +export interface AuthModalWorkflowInput { + readonly signIn: AuthSessionState['signIn']; + readonly signUp: AuthSessionState['signUp']; + readonly onClose: () => void; +} diff --git a/frontend/src/business/campus-attendance/hooks.ts b/frontend/src/business/campus-attendance/hooks.ts new file mode 100644 index 0000000..719f24e --- /dev/null +++ b/frontend/src/business/campus-attendance/hooks.ts @@ -0,0 +1,282 @@ +import { useQuery } from '@tanstack/react-query'; +import { useMemo, useState } from 'react'; +import { + listCampusAttendanceConfigs, + listCampusAttendanceSummaries, + saveCampusAttendanceConfig, + saveCampusAttendanceSummary, +} from '@/shared/api/campusAttendance'; +import { useCampusCatalog } from '@/business/campuses/hooks'; +import { CAMPUS_ATTENDANCE_QUERY_KEYS } from '@/shared/constants/campusAttendance'; +import { findCampusByNameOrCode } from '@/shared/constants/campusDisplay'; +import { UI_FEEDBACK_CLEAR_DELAY_MS } from '@/shared/constants/ui'; +import type { + CampusAttendanceCampusKey, + CampusAttendanceListFilter, +} from '@/shared/types/campusAttendance'; +import { + toCampusAttendanceConfigViewModel, + toCampusAttendanceSummaryMutationDto, + toCampusAttendanceSummaryViewModel, +} from '@/business/campus-attendance/mappers'; +import { + buildAttendanceEntryInput, + buildCampusAttendanceStats, + buildOverallAttendanceStats, + getToday, + getTodayPercentage, + getWeeklyAverage, + getWeekEnd, + getWeekStart, +} from '@/business/campus-attendance/selectors'; +import { + CAMPUS_ATTENDANCE_PRINT_POPUP_BLOCKED_MESSAGE, + openCampusAttendancePrintReport, +} from '@/business/campus-attendance/printReport'; +import type { + CampusAttendanceEntryDraft, + CampusAttendanceEntryInput, +} from '@/business/campus-attendance/types'; +import type { CampusId, UserRole } from '@/shared/types/app'; +import { getOptionalErrorMessage } from '@/shared/errors/errorMessages'; +import { mapApiListRows } from '@/shared/business/apiListRows'; +import { useInvalidatingMutation } from '@/shared/business/queryMutations'; + +const EMPTY_CONFIGS: ReturnType[] = []; +const EMPTY_SUMMARIES: ReturnType[] = []; + +export function useCampusAttendanceConfigs(campusKey?: CampusAttendanceCampusKey, enabled = true) { + return useQuery({ + queryKey: [CAMPUS_ATTENDANCE_QUERY_KEYS.configs, campusKey], + enabled, + queryFn: () => mapApiListRows( + listCampusAttendanceConfigs(campusKey ? { campusKey } : undefined), + toCampusAttendanceConfigViewModel, + ), + }); +} + +export function useSaveCampusAttendanceConfig() { + return useInvalidatingMutation({ + mutationFn: (input: { campusKey: CampusAttendanceCampusKey; attendanceLink: string | null }) => ( + saveCampusAttendanceConfig(input.campusKey, { attendance_link: input.attendanceLink }) + ), + invalidateQueryKey: [CAMPUS_ATTENDANCE_QUERY_KEYS.configs], + }); +} + +export function useCampusAttendanceSummaries(filter?: CampusAttendanceListFilter, enabled = true) { + return useQuery({ + queryKey: [CAMPUS_ATTENDANCE_QUERY_KEYS.summaries, filter], + enabled, + queryFn: () => mapApiListRows( + listCampusAttendanceSummaries(filter), + toCampusAttendanceSummaryViewModel, + ), + }); +} + +export function useSaveCampusAttendanceSummary() { + return useInvalidatingMutation({ + mutationFn: (input: CampusAttendanceEntryInput) => saveCampusAttendanceSummary( + input.campusId, + input.date, + toCampusAttendanceSummaryMutationDto(input), + ), + invalidateQueryKey: [CAMPUS_ATTENDANCE_QUERY_KEYS.summaries], + }); +} + +type UseCampusAttendancePageInput = { + readonly userRole: UserRole; + readonly userCampus: string; + readonly userName: string; +}; + +const emptyEntryDraft = (date: string): CampusAttendanceEntryDraft => ({ + date, + enrolled: '', + present: '', + absent: '', + tardy: '', + notes: '', +}); + +export function useCampusAttendancePage({ + userRole, + userCampus, + userName, +}: UseCampusAttendancePageInput) { + const roleAccess = { + isSuperintendent: userRole === 'superintendent', + isDirector: userRole === 'director', + isOfficeManager: userRole === 'office', + canSeeAllCampuses: userRole === 'superintendent', + canEnterData: userRole === 'office', + canPrint: userRole === 'superintendent' || userRole === 'director' || userRole === 'office', + }; + const campusCatalog = useCampusCatalog(); + const campusInfo = findCampusByNameOrCode(campusCatalog.campuses, userCampus); + const campusId = campusInfo?.id ?? null; + const today = getToday(); + const weekStart = getWeekStart(new Date()); + const weekEnd = getWeekEnd(new Date()); + + const hasCampusScope = roleAccess.canSeeAllCampuses || Boolean(campusId); + const configsQuery = useCampusAttendanceConfigs( + roleAccess.canSeeAllCampuses || !campusId ? undefined : campusId, + hasCampusScope, + ); + const summariesQuery = useCampusAttendanceSummaries( + roleAccess.canSeeAllCampuses || !campusId ? undefined : { campusKey: campusId }, + hasCampusScope, + ); + const saveConfigMutation = useSaveCampusAttendanceConfig(); + const saveSummaryMutation = useSaveCampusAttendanceSummary(); + + const configs = configsQuery.data ?? EMPTY_CONFIGS; + const attendanceData = summariesQuery.data ?? EMPTY_SUMMARIES; + const loading = campusCatalog.isLoading || configsQuery.isLoading || summariesQuery.isLoading; + const saving = saveConfigMutation.isPending || saveSummaryMutation.isPending; + const loadError = campusCatalog.error ?? configsQuery.error ?? summariesQuery.error; + const saveError = saveConfigMutation.error ?? saveSummaryMutation.error; + const [successMessage, setSuccessMessage] = useState(''); + const [printError, setPrintError] = useState(null); + const errorMessage = printError ?? getOptionalErrorMessage(loadError ?? saveError); + const [editingLink, setEditingLink] = useState(null); + const [linkValue, setLinkValue] = useState(''); + const [showEntryForm, setShowEntryForm] = useState(false); + const [expandedCampus, setExpandedCampus] = useState(null); + const [entryDraft, setEntryDraft] = useState(() => emptyEntryDraft(today)); + const [entryError, setEntryError] = useState(null); + + const campusStats = useMemo( + () => buildCampusAttendanceStats(campusCatalog.campuses, configs, attendanceData, today, weekStart, weekEnd), + [attendanceData, campusCatalog.campuses, configs, today, weekEnd, weekStart], + ); + const overallStats = useMemo( + () => buildOverallAttendanceStats(attendanceData, today, weekStart, weekEnd), + [attendanceData, today, weekEnd, weekStart], + ); + const myCampusConfig = campusId ? configs.find((config) => config.campus_id === campusId) : undefined; + const myCampusData = campusId ? attendanceData.filter((record) => record.campus_id === campusId) : []; + const myTodayPct = campusId ? getTodayPercentage(attendanceData, campusId, today) : null; + const myWeekAvg = campusId ? getWeeklyAverage(attendanceData, campusId, weekStart, weekEnd) : null; + + const showSuccess = (message: string) => { + setPrintError(null); + setSuccessMessage(message); + window.setTimeout(() => setSuccessMessage(''), UI_FEEDBACK_CLEAR_DELAY_MS); + }; + + const updateEntryDraft = (patch: Partial) => { + setEntryDraft((currentDraft) => ({ ...currentDraft, ...patch })); + setEntryError(null); + }; + + const handleSaveLink = async (targetCampusId: CampusId) => { + setPrintError(null); + await saveConfigMutation.mutateAsync({ + campusKey: targetCampusId, + attendanceLink: linkValue.trim() || null, + }); + showSuccess('Link saved successfully!'); + setEditingLink(null); + }; + + const handleSubmitEntry = async () => { + setPrintError(null); + + if (!campusId) { + setEntryError('Campus is unavailable. Refresh the campus catalog before saving attendance.'); + return; + } + + const input = buildAttendanceEntryInput(entryDraft, campusId); + + if (!input) { + setEntryError('Enter enrolled, present, and absent counts before saving.'); + return; + } + + await saveSummaryMutation.mutateAsync(input); + showSuccess('Attendance data saved!'); + setShowEntryForm(false); + setEntryDraft(emptyEntryDraft(today)); + }; + + const handlePrint = () => { + const campusesToPrint = roleAccess.canSeeAllCampuses + ? campusStats + : campusStats.filter((campus) => campus.id === campusId); + const reportTitle = roleAccess.canSeeAllCampuses + ? 'All Campuses Attendance Report' + : `${campusInfo?.fullName || userCampus} Attendance Report`; + const printTodayRecords = roleAccess.canSeeAllCampuses + ? overallStats.todayRecords + : attendanceData.filter((record) => record.campus_id === campusId && record.date === today); + const printWeekRecords = roleAccess.canSeeAllCampuses + ? overallStats.weekRecords + : attendanceData.filter((record) => record.campus_id === campusId && record.date >= weekStart && record.date <= weekEnd); + + const printResult = openCampusAttendancePrintReport({ + input: { + reportTitle, + generatedByName: userName, + generatedByRole: `${userRole.charAt(0).toUpperCase()}${userRole.slice(1)}`, + today, + weekStart, + campusesToPrint, + printTodayRecords, + printWeekRecords, + }, + }); + + if (!printResult.ok) { + setPrintError(CAMPUS_ATTENDANCE_PRINT_POPUP_BLOCKED_MESSAGE); + return; + } + + setPrintError(null); + }; + + return { + state: { + roleAccess, + campusInfo, + campusId, + today, + weekStart, + weekEnd, + configs, + attendanceData, + loading, + saving, + errorMessage, + successMessage, + editingLink, + linkValue, + showEntryForm, + expandedCampus, + entryDraft, + entryError, + campusStats, + overallStats, + myCampusConfig, + myCampusData, + myTodayPct, + myWeekAvg, + userCampus, + }, + actions: { + setEditingLink, + setLinkValue, + setShowEntryForm, + setExpandedCampus, + updateEntryDraft, + handleSaveLink, + handleSubmitEntry, + handlePrint, + }, + }; +} diff --git a/frontend/src/business/campus-attendance/mappers.test.ts b/frontend/src/business/campus-attendance/mappers.test.ts new file mode 100644 index 0000000..77d8d2e --- /dev/null +++ b/frontend/src/business/campus-attendance/mappers.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest'; +import { + toCampusAttendanceConfigViewModel, + toCampusAttendanceSummaryMutationDto, + toCampusAttendanceSummaryViewModel, +} from '@/business/campus-attendance/mappers'; +import type { CampusAttendanceEntryInput } from '@/business/campus-attendance/types'; +import type { + CampusAttendanceConfigDto, + CampusAttendanceSummaryDto, +} from '@/shared/types/campusAttendance'; + +describe('campus attendance mappers', () => { + it('maps backend config DTO fields into the frontend view model shape', () => { + const dto: CampusAttendanceConfigDto = { + id: 'config-1', + campus_key: 'tigers', + attendance_link: 'https://attendance.example/north', + updated_by_label: 'Office Manager', + organizationId: 'org-1', + campusId: 'campus-1', + createdById: 'user-1', + updatedById: 'user-2', + createdAt: '2026-06-08T08:00:00.000Z', + updatedAt: '2026-06-08T09:00:00.000Z', + }; + + expect(toCampusAttendanceConfigViewModel(dto)).toEqual({ + id: 'config-1', + campus_id: 'tigers', + attendance_link: 'https://attendance.example/north', + updated_by: 'Office Manager', + updated_at: '2026-06-08T09:00:00.000Z', + }); + }); + + it('maps backend summary DTO fields into the frontend view model shape', () => { + const dto: CampusAttendanceSummaryDto = { + id: 'summary-1', + campus_key: 'gators', + date: '2026-06-08', + total_enrolled: 80, + total_present: 72, + total_absent: 6, + total_tardy: 2, + attendance_percentage: 90, + recorded_by_label: 'Director', + notes: 'Two late bus arrivals', + organizationId: 'org-1', + campusId: 'campus-2', + createdById: 'user-3', + updatedById: 'user-3', + createdAt: '2026-06-08T10:00:00.000Z', + updatedAt: '2026-06-08T10:15:00.000Z', + }; + + expect(toCampusAttendanceSummaryViewModel(dto)).toEqual({ + id: 'summary-1', + campus_id: 'gators', + date: '2026-06-08', + total_enrolled: 80, + total_present: 72, + total_absent: 6, + total_tardy: 2, + attendance_percentage: 90, + recorded_by: 'Director', + notes: 'Two late bus arrivals', + }); + }); + + it('maps entry input back into the backend mutation DTO shape', () => { + const input: CampusAttendanceEntryInput = { + campusId: 'hawks', + date: '2026-06-08', + totalEnrolled: 42, + totalPresent: 39, + totalAbsent: 2, + totalTardy: 1, + notes: null, + }; + + expect(toCampusAttendanceSummaryMutationDto(input)).toEqual({ + total_enrolled: 42, + total_present: 39, + total_absent: 2, + total_tardy: 1, + notes: null, + }); + }); +}); diff --git a/frontend/src/business/campus-attendance/mappers.ts b/frontend/src/business/campus-attendance/mappers.ts new file mode 100644 index 0000000..2d59f97 --- /dev/null +++ b/frontend/src/business/campus-attendance/mappers.ts @@ -0,0 +1,51 @@ +import type { + CampusAttendanceConfigDto, + CampusAttendanceSummaryDto, + CampusAttendanceSummaryMutationDto, +} from '@/shared/types/campusAttendance'; +import type { + CampusAttendanceConfigViewModel, + CampusAttendanceEntryInput, + CampusAttendanceSummaryViewModel, +} from '@/business/campus-attendance/types'; + +export function toCampusAttendanceConfigViewModel( + dto: CampusAttendanceConfigDto, +): CampusAttendanceConfigViewModel { + return { + id: dto.id, + campus_id: dto.campus_key, + attendance_link: dto.attendance_link, + updated_by: dto.updated_by_label, + updated_at: dto.updatedAt, + }; +} + +export function toCampusAttendanceSummaryViewModel( + dto: CampusAttendanceSummaryDto, +): CampusAttendanceSummaryViewModel { + return { + id: dto.id, + campus_id: dto.campus_key, + date: dto.date, + total_enrolled: dto.total_enrolled, + total_present: dto.total_present, + total_absent: dto.total_absent, + total_tardy: dto.total_tardy, + attendance_percentage: dto.attendance_percentage, + recorded_by: dto.recorded_by_label, + notes: dto.notes, + }; +} + +export function toCampusAttendanceSummaryMutationDto( + input: CampusAttendanceEntryInput, +): CampusAttendanceSummaryMutationDto { + return { + total_enrolled: input.totalEnrolled, + total_present: input.totalPresent, + total_absent: input.totalAbsent, + total_tardy: input.totalTardy, + notes: input.notes, + }; +} diff --git a/frontend/src/business/campus-attendance/printReport.test.ts b/frontend/src/business/campus-attendance/printReport.test.ts new file mode 100644 index 0000000..87c5cc3 --- /dev/null +++ b/frontend/src/business/campus-attendance/printReport.test.ts @@ -0,0 +1,97 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + CAMPUS_ATTENDANCE_PRINT_POPUP_BLOCKED_MESSAGE, + type CampusAttendancePrintWindow, + buildCampusAttendancePrintHtml, + openCampusAttendancePrintReport, +} from '@/business/campus-attendance/printReport'; +import { + CAMPUS_ATTENDANCE_TEST_SEED, + campusAttendancePrintInputSeed, + campusAttendanceStatsSeed, +} from '@/test-seeds/campusAttendance'; + +describe('campus attendance print report', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('builds printable HTML with escaped report metadata and attendance percentages', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-06-08T14:05:00.000Z')); + + const html = buildCampusAttendancePrintHtml(campusAttendancePrintInputSeed); + + expect(html).toContain(`${CAMPUS_ATTENDANCE_TEST_SEED.reportTitleEscaped} - Mon, Jun 8, 2026`); + expect(html).toContain(`FRAMEworks ${CAMPUS_ATTENDANCE_TEST_SEED.reportTitleEscaped}`); + expect(html).toContain( + `Printed by: ${CAMPUS_ATTENDANCE_TEST_SEED.generatedByName} (${CAMPUS_ATTENDANCE_TEST_SEED.generatedByRoleEscaped})`, + ); + expect(html).toContain(CAMPUS_ATTENDANCE_TEST_SEED.campusFullNameEscaped); + expect(html).toContain(CAMPUS_ATTENDANCE_TEST_SEED.todayNotesEscaped); + expect(html).toContain('
90%
'); + expect(html).toContain('
85%
'); + expect(html).toContain('
'); + }); + + it('shows explicit no-data labels when the selected print scope has no records', () => { + const html = buildCampusAttendancePrintHtml({ + ...campusAttendancePrintInputSeed, + campusesToPrint: [ + { + ...campusAttendanceStatsSeed, + todayPct: null, + weekAvg: null, + recentData: [], + todayRecord: null, + }, + ], + printTodayRecords: [], + printWeekRecords: [], + }); + + expect(html).toContain('
No data
'); + expect(html).toContain('No attendance data recorded for today.'); + expect(html).toContain('Today: N/A'); + expect(html).toContain('Week Avg: N/A'); + }); + + it('returns an explicit blocked-popup result when the report window cannot open', () => { + const result = openCampusAttendancePrintReport({ + input: campusAttendancePrintInputSeed, + openWindow: () => null, + }); + + expect(result).toEqual({ ok: false, reason: 'popup-blocked' }); + expect(CAMPUS_ATTENDANCE_PRINT_POPUP_BLOCKED_MESSAGE).toContain('allow pop-ups'); + }); + + it('writes, focuses, and schedules print when the report window opens', () => { + const write = vi.fn(); + const close = vi.fn(); + const focus = vi.fn(); + const print = vi.fn(); + const schedulePrint = vi.fn((callback: () => void) => callback()); + const printWindow: CampusAttendancePrintWindow = { + document: { + write, + close, + }, + focus, + print, + }; + + const result = openCampusAttendancePrintReport({ + input: campusAttendancePrintInputSeed, + openWindow: () => printWindow, + schedulePrint, + }); + + expect(result).toEqual({ ok: true }); + expect(write).toHaveBeenCalledWith(expect.stringContaining('FRAMEworks')); + expect(close).toHaveBeenCalled(); + expect(focus).toHaveBeenCalled(); + expect(schedulePrint).toHaveBeenCalled(); + expect(print).toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/business/campus-attendance/printReport.ts b/frontend/src/business/campus-attendance/printReport.ts new file mode 100644 index 0000000..43a771b --- /dev/null +++ b/frontend/src/business/campus-attendance/printReport.ts @@ -0,0 +1,202 @@ +import { buildPrintAttendanceStats, formatAttendanceDate } from '@/business/campus-attendance/selectors'; +import { PRINT_DIALOG_OPEN_DELAY_MS } from '@/shared/constants/ui'; +import type { CampusAttendancePrintInput } from '@/business/campus-attendance/types'; + +const escapeHtml = (value: string): string => ( + value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +); + +const percentageClass = (percentage: number | null): string => { + if (percentage === null) { + return ''; + } + + if (percentage >= 90) { + return 'green'; + } + + if (percentage >= 80) { + return 'amber'; + } + + return 'red'; +}; + +const inlinePercentageClass = (percentage: number | null): string => { + if (percentage === null) { + return ''; + } + + if (percentage >= 90) { + return 'pct-good'; + } + + if (percentage >= 80) { + return 'pct-warn'; + } + + return 'pct-bad'; +}; + +export type CampusAttendancePrintResult = + | { readonly ok: true } + | { readonly ok: false; readonly reason: 'popup-blocked' }; + +export interface CampusAttendancePrintWindow { + readonly document: Pick; + readonly focus: () => void; + readonly print: () => void; +} + +interface OpenCampusAttendancePrintReportOptions { + readonly input: CampusAttendancePrintInput; + readonly openWindow?: () => CampusAttendancePrintWindow | null; + readonly schedulePrint?: (callback: () => void, delayMs: number) => void; +} + +export const CAMPUS_ATTENDANCE_PRINT_POPUP_BLOCKED_MESSAGE = + 'The attendance report could not be opened. Please allow pop-ups for this site and try again.'; + +export function openCampusAttendancePrintReport({ + input, + openWindow = () => window.open('', '_blank'), + schedulePrint = (callback, delayMs) => { + window.setTimeout(callback, delayMs); + }, +}: OpenCampusAttendancePrintReportOptions): CampusAttendancePrintResult { + const printWindow = openWindow(); + + if (!printWindow) { + return { ok: false, reason: 'popup-blocked' }; + } + + printWindow.document.write(buildCampusAttendancePrintHtml(input)); + printWindow.document.close(); + printWindow.focus(); + schedulePrint(() => { + printWindow.print(); + }, PRINT_DIALOG_OPEN_DELAY_MS); + + return { ok: true }; +} + +export function buildCampusAttendancePrintHtml(input: CampusAttendancePrintInput): string { + const printStats = buildPrintAttendanceStats(input); + const generatedAtDate = new Date().toLocaleDateString('en-US', { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + }); + const generatedAtTime = new Date().toLocaleTimeString('en-US', { + hour: '2-digit', + minute: '2-digit', + }); + + return ` + + + + ${escapeHtml(input.reportTitle)} - ${formatAttendanceDate(input.today)} + + + +
+

FRAMEworks ${escapeHtml(input.reportTitle)}

+

Generated on ${generatedAtDate} at ${generatedAtTime}

+

Printed by: ${escapeHtml(input.generatedByName)} (${escapeHtml(input.generatedByRole)})

+
+
+
+
Today's Attendance
+
${printStats.todayPct !== null ? `${printStats.todayPct}%` : 'No data'}
+
${formatAttendanceDate(input.today)}
+
+
+
This Week's Average Attendance
+
${printStats.weekPct !== null ? `${printStats.weekPct}%` : 'No data'}
+
Week of ${formatAttendanceDate(input.weekStart)}
+
+
+ ${input.campusesToPrint.map((campus) => ` +
+
+

${escapeHtml(campus.fullName)}

+
+ Today: ${campus.todayPct !== null ? `${campus.todayPct}%` : 'N/A'} + Week Avg: ${campus.weekAvg !== null ? `${campus.weekAvg}%` : 'N/A'} +
+
+ ${campus.todayRecord ? ` +
80.0%
+ + + ${campus.todayRecord.notes ? `` : ''} +
Enrolled${campus.todayRecord.total_enrolled}Present${campus.todayRecord.total_present}
Absent${campus.todayRecord.total_absent}Tardy${campus.todayRecord.total_tardy}
Notes${escapeHtml(campus.todayRecord.notes)}
+ ` : '

No attendance data recorded for today.

'} + ${campus.recentData.length > 0 ? ` + + + + + + + + ${campus.recentData.map((record) => ` + + + + + + + + + + `).join('')} + +
DateEnrolledPresentAbsentTardyAttendance %Notes
${formatAttendanceDate(record.date)}${record.total_enrolled}${record.total_present}${record.total_absent}${record.total_tardy}${record.attendance_percentage.toFixed(1)}%${record.notes ? escapeHtml(record.notes) : '-'}
+ ` : ''} + + `).join('')} + + + + `; +} diff --git a/frontend/src/business/campus-attendance/selectors.test.ts b/frontend/src/business/campus-attendance/selectors.test.ts new file mode 100644 index 0000000..72597c9 --- /dev/null +++ b/frontend/src/business/campus-attendance/selectors.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from 'vitest'; +import { + buildAttendanceEntryInput, + buildOverallAttendanceStats, + getWeekEnd, + getWeekStart, +} from '@/business/campus-attendance/selectors'; +import type { + CampusAttendanceEntryDraft, + CampusAttendanceSummaryViewModel, +} from '@/business/campus-attendance/types'; + +const summaries: readonly CampusAttendanceSummaryViewModel[] = [ + { + id: 'summary-1', + campus_id: 'tigers', + date: '2026-06-08', + total_enrolled: 100, + total_present: 90, + total_absent: 10, + total_tardy: 3, + attendance_percentage: 90, + recorded_by: 'director-1', + notes: null, + }, + { + id: 'summary-2', + campus_id: 'gators', + date: '2026-06-08', + total_enrolled: 50, + total_present: 40, + total_absent: 10, + total_tardy: 1, + attendance_percentage: 80, + recorded_by: 'director-1', + notes: null, + }, + { + id: 'summary-3', + campus_id: 'tigers', + date: '2026-06-09', + total_enrolled: 100, + total_present: 95, + total_absent: 5, + total_tardy: 2, + attendance_percentage: 95, + recorded_by: 'director-1', + notes: null, + }, +]; + +describe('campus attendance selectors', () => { + it('calculates Monday and Friday boundaries for a midweek date', () => { + const date = new Date('2026-06-10T12:00:00Z'); + + expect(getWeekStart(date)).toBe('2026-06-08'); + expect(getWeekEnd(date)).toBe('2026-06-12'); + }); + + it('builds validated attendance input and normalizes optional notes', () => { + const draft: CampusAttendanceEntryDraft = { + date: '2026-06-08', + enrolled: '25', + present: '21', + absent: '4', + tardy: '', + notes: ' ', + }; + + expect(buildAttendanceEntryInput(draft, 'tigers')).toEqual({ + campusId: 'tigers', + date: '2026-06-08', + totalEnrolled: 25, + totalPresent: 21, + totalAbsent: 4, + totalTardy: 0, + notes: null, + }); + }); + + it('rejects attendance input without a positive enrollment count', () => { + const draft: CampusAttendanceEntryDraft = { + date: '2026-06-08', + enrolled: '0', + present: '0', + absent: '0', + tardy: '0', + notes: 'Closed', + }; + + expect(buildAttendanceEntryInput(draft, 'tigers')).toBeNull(); + }); + + it('aggregates daily and weekly attendance percentages across campuses', () => { + expect( + buildOverallAttendanceStats(summaries, '2026-06-08', '2026-06-08', '2026-06-12'), + ).toMatchObject({ + todayEnrolled: 150, + todayPresent: 130, + todayPct: 86.67, + weekPct: 90.83, + }); + }); +}); diff --git a/frontend/src/business/campus-attendance/selectors.ts b/frontend/src/business/campus-attendance/selectors.ts new file mode 100644 index 0000000..fd56907 --- /dev/null +++ b/frontend/src/business/campus-attendance/selectors.ts @@ -0,0 +1,197 @@ +import type { CampusId, CampusInfo } from '@/shared/types/app'; +import type { + CampusAttendanceEntryDraft, + CampusAttendanceConfigViewModel, + CampusAttendanceOverallStats, + CampusAttendancePrintInput, + CampusAttendanceStats, + CampusAttendanceSummaryViewModel, +} from '@/business/campus-attendance/types'; + +export function getWeekStart(date: Date): string { + const weekStart = new Date(date); + const day = weekStart.getDay(); + const diff = weekStart.getDate() - day + (day === 0 ? -6 : 1); + weekStart.setDate(diff); + return weekStart.toISOString().split('T')[0]; +} + +export function getWeekEnd(date: Date): string { + const weekEnd = new Date(date); + const day = weekEnd.getDay(); + const diff = weekEnd.getDate() - day + (day === 0 ? 0 : 5); + weekEnd.setDate(diff); + return weekEnd.toISOString().split('T')[0]; +} + +export function getToday(): string { + return new Date().toISOString().split('T')[0]; +} + +export function formatAttendanceDate(date: string): string { + const displayDate = new Date(`${date}T12:00:00`); + return displayDate.toLocaleDateString('en-US', { + weekday: 'short', + month: 'short', + day: 'numeric', + year: 'numeric', + }); +} + +export function parseAttendanceCount(value: string): number | null { + const parsed = Number.parseInt(value, 10); + return Number.isNaN(parsed) ? null : parsed; +} + +export function buildAttendanceEntryInput(draft: CampusAttendanceEntryDraft, campusId: CampusId) { + const totalEnrolled = parseAttendanceCount(draft.enrolled); + const totalPresent = parseAttendanceCount(draft.present); + const totalAbsent = parseAttendanceCount(draft.absent); + const totalTardy = parseAttendanceCount(draft.tardy) ?? 0; + + if (totalEnrolled === null || totalPresent === null || totalAbsent === null || totalEnrolled <= 0) { + return null; + } + + return { + campusId, + date: draft.date, + totalEnrolled, + totalPresent, + totalAbsent, + totalTardy, + notes: draft.notes.trim() || null, + }; +} + +export function getDraftAttendancePercentage(draft: CampusAttendanceEntryDraft): number | null { + const totalEnrolled = parseAttendanceCount(draft.enrolled); + const totalPresent = parseAttendanceCount(draft.present); + + if (totalEnrolled === null || totalPresent === null || totalEnrolled <= 0) { + return null; + } + + return (totalPresent / totalEnrolled) * 100; +} + +export function getTodayData( + summaries: readonly CampusAttendanceSummaryViewModel[], + campusId: CampusId, + today: string, +): readonly CampusAttendanceSummaryViewModel[] { + return summaries.filter((record) => record.campus_id === campusId && record.date === today); +} + +export function getWeekData( + summaries: readonly CampusAttendanceSummaryViewModel[], + campusId: CampusId, + weekStart: string, + weekEnd: string, +): readonly CampusAttendanceSummaryViewModel[] { + return summaries.filter( + (record) => record.campus_id === campusId && record.date >= weekStart && record.date <= weekEnd, + ); +} + +export function getWeeklyAverage( + summaries: readonly CampusAttendanceSummaryViewModel[], + campusId: CampusId, + weekStart: string, + weekEnd: string, +): number | null { + const weekData = getWeekData(summaries, campusId, weekStart, weekEnd); + + if (weekData.length === 0) { + return null; + } + + const avg = weekData.reduce((sum, record) => sum + record.attendance_percentage, 0) / weekData.length; + return Number(avg.toFixed(2)); +} + +export function getTodayPercentage( + summaries: readonly CampusAttendanceSummaryViewModel[], + campusId: CampusId, + today: string, +): number | null { + const todayData = getTodayData(summaries, campusId, today); + return todayData[0]?.attendance_percentage ?? null; +} + +export function buildCampusAttendanceStats( + campuses: readonly CampusInfo[], + configs: readonly CampusAttendanceConfigViewModel[], + summaries: readonly CampusAttendanceSummaryViewModel[], + today: string, + weekStart: string, + weekEnd: string, +): readonly CampusAttendanceStats[] { + return campuses.map((campus) => { + const todayPct = getTodayPercentage(summaries, campus.id, today); + const weekAvg = getWeeklyAverage(summaries, campus.id, weekStart, weekEnd); + const config = configs.find((item) => item.campus_id === campus.id) ?? null; + const recentData = summaries.filter((record) => record.campus_id === campus.id).slice(0, 10); + const todayRecord = getTodayData(summaries, campus.id, today)[0] ?? null; + + return { + ...campus, + todayPct, + weekAvg, + config, + recentData, + todayRecord, + }; + }); +} + +export function buildOverallAttendanceStats( + summaries: readonly CampusAttendanceSummaryViewModel[], + today: string, + weekStart: string, + weekEnd: string, +): CampusAttendanceOverallStats { + const todayRecords = summaries.filter((record) => record.date === today); + const todayEnrolled = todayRecords.reduce((sum, record) => sum + record.total_enrolled, 0); + const todayPresent = todayRecords.reduce((sum, record) => sum + record.total_present, 0); + const todayPct = todayEnrolled > 0 ? Number(((todayPresent / todayEnrolled) * 100).toFixed(2)) : null; + const weekRecords = summaries.filter((record) => record.date >= weekStart && record.date <= weekEnd); + const weekDays = Array.from(new Set(weekRecords.map((record) => record.date))); + const weekPct = weekDays.length > 0 + ? Number((weekDays.reduce((sum, day) => { + const dayRecords = weekRecords.filter((record) => record.date === day); + const dayEnrolled = dayRecords.reduce((daySum, record) => daySum + record.total_enrolled, 0); + const dayPresent = dayRecords.reduce((daySum, record) => daySum + record.total_present, 0); + return sum + (dayEnrolled > 0 ? (dayPresent / dayEnrolled) * 100 : 0); + }, 0) / weekDays.length).toFixed(2)) + : null; + + return { + todayRecords, + todayEnrolled, + todayPresent, + todayPct, + weekRecords, + weekPct, + }; +} + +export function buildPrintAttendanceStats(input: CampusAttendancePrintInput) { + const printEnrolled = input.printTodayRecords.reduce((sum, record) => sum + record.total_enrolled, 0); + const printPresent = input.printTodayRecords.reduce((sum, record) => sum + record.total_present, 0); + const todayPct = printEnrolled > 0 ? Number(((printPresent / printEnrolled) * 100).toFixed(2)) : null; + const weekDays = Array.from(new Set(input.printWeekRecords.map((record) => record.date))); + const weekPct = weekDays.length > 0 + ? Number((weekDays.reduce((sum, day) => { + const dayRecords = input.printWeekRecords.filter((record) => record.date === day); + const dayEnrolled = dayRecords.reduce((daySum, record) => daySum + record.total_enrolled, 0); + const dayPresent = dayRecords.reduce((daySum, record) => daySum + record.total_present, 0); + return sum + (dayEnrolled > 0 ? (dayPresent / dayEnrolled) * 100 : 0); + }, 0) / weekDays.length).toFixed(2)) + : null; + + return { + todayPct, + weekPct, + }; +} diff --git a/frontend/src/business/campus-attendance/types.ts b/frontend/src/business/campus-attendance/types.ts new file mode 100644 index 0000000..a9d0940 --- /dev/null +++ b/frontend/src/business/campus-attendance/types.ts @@ -0,0 +1,78 @@ +import type { CampusId, CampusInfo } from '@/shared/types/app'; + +export interface CampusAttendanceConfigViewModel { + readonly id: string; + readonly campus_id: CampusId; + readonly attendance_link: string | null; + readonly updated_by: string | null; + readonly updated_at: string; +} + +export interface CampusAttendanceSummaryViewModel { + readonly id: string; + readonly campus_id: CampusId; + readonly date: string; + readonly total_enrolled: number; + readonly total_present: number; + readonly total_absent: number; + readonly total_tardy: number; + readonly attendance_percentage: number; + readonly recorded_by: string | null; + readonly notes: string | null; +} + +export interface CampusAttendanceEntryInput { + readonly campusId: CampusId; + readonly date: string; + readonly totalEnrolled: number; + readonly totalPresent: number; + readonly totalAbsent: number; + readonly totalTardy: number; + readonly notes: string | null; +} + +export interface CampusAttendanceStats extends CampusInfo { + readonly todayPct: number | null; + readonly weekAvg: number | null; + readonly config: CampusAttendanceConfigViewModel | null; + readonly recentData: readonly CampusAttendanceSummaryViewModel[]; + readonly todayRecord: CampusAttendanceSummaryViewModel | null; +} + +export interface CampusAttendanceOverallStats { + readonly todayRecords: readonly CampusAttendanceSummaryViewModel[]; + readonly todayEnrolled: number; + readonly todayPresent: number; + readonly todayPct: number | null; + readonly weekRecords: readonly CampusAttendanceSummaryViewModel[]; + readonly weekPct: number | null; +} + +export interface CampusAttendanceEntryDraft { + readonly date: string; + readonly enrolled: string; + readonly present: string; + readonly absent: string; + readonly tardy: string; + readonly notes: string; +} + +export interface CampusAttendanceRoleAccess { + readonly isSuperintendent: boolean; + readonly isDirector: boolean; + readonly isOfficeManager: boolean; + readonly canSeeAllCampuses: boolean; + readonly canEnterData: boolean; + readonly canPrint: boolean; +} + +export interface CampusAttendancePrintInput { + readonly reportTitle: string; + readonly generatedByName: string; + readonly generatedByRole: string; + readonly today: string; + readonly weekStart: string; + readonly campusesToPrint: readonly CampusAttendanceStats[]; + readonly printTodayRecords: readonly CampusAttendanceSummaryViewModel[]; + readonly printWeekRecords: readonly CampusAttendanceSummaryViewModel[]; +} diff --git a/frontend/src/business/campuses/hooks.ts b/frontend/src/business/campuses/hooks.ts new file mode 100644 index 0000000..8b3da01 --- /dev/null +++ b/frontend/src/business/campuses/hooks.ts @@ -0,0 +1,21 @@ +import { useQuery } from '@tanstack/react-query'; +import { listPublicCampuses } from '@/shared/api/campuses'; +import { CAMPUS_QUERY_KEYS } from '@/shared/constants/campuses'; +import { toCampusCatalogViewModels } from '@/business/campuses/mappers'; +import { selectApiListRows } from '@/shared/business/apiListRows'; + +const EMPTY_CAMPUSES: ReturnType = []; + +export function useCampusCatalog() { + const query = useQuery({ + queryKey: CAMPUS_QUERY_KEYS.catalog, + queryFn: () => selectApiListRows(listPublicCampuses(), toCampusCatalogViewModels), + }); + + return { + campuses: query.data ?? EMPTY_CAMPUSES, + isLoading: query.isLoading, + error: query.error, + refresh: query.refetch, + }; +} diff --git a/frontend/src/business/campuses/mappers.test.ts b/frontend/src/business/campuses/mappers.test.ts new file mode 100644 index 0000000..cfb3736 --- /dev/null +++ b/frontend/src/business/campuses/mappers.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; +import { toCampusCatalogViewModels } from '@/business/campuses/mappers'; +import { campusCatalogItemSeed } from '@/test-seeds/campuses'; + +describe('campus catalog mappers', () => { + it('maps backend campus rows to UI campus view models', () => { + expect(toCampusCatalogViewModels([campusCatalogItemSeed])).toEqual([ + { + id: 'tigers', + mascot: 'Tigers', + fullName: 'Tigers Campus', + color: 'bg-orange-500', + bgGradient: 'from-orange-500 to-amber-500', + borderColor: 'border-orange-500/30', + textColor: 'text-orange-400', + bgLight: 'bg-orange-500/10', + description: 'Strength, courage & determination', + isOnline: false, + }, + ]); + }); + + it('omits backend campus rows without a usable code', () => { + expect(toCampusCatalogViewModels([{ ...campusCatalogItemSeed, code: ' ' }])).toEqual([]); + }); +}); diff --git a/frontend/src/business/campuses/mappers.ts b/frontend/src/business/campuses/mappers.ts new file mode 100644 index 0000000..5e8a4c0 --- /dev/null +++ b/frontend/src/business/campuses/mappers.ts @@ -0,0 +1,10 @@ +import { toCampusInfo } from '@/shared/constants/campusDisplay'; +import type { CampusInfo } from '@/shared/types/app'; +import type { CampusCatalogItem } from '@/shared/types/campuses'; + +export function toCampusCatalogViewModels(campuses: readonly CampusCatalogItem[]): readonly CampusInfo[] { + return campuses.flatMap((campus) => { + const campusInfo = toCampusInfo(campus); + return campusInfo ? [campusInfo] : []; + }); +} diff --git a/frontend/src/business/classroom-support/hooks.ts b/frontend/src/business/classroom-support/hooks.ts new file mode 100644 index 0000000..90079b7 --- /dev/null +++ b/frontend/src/business/classroom-support/hooks.ts @@ -0,0 +1,82 @@ +import { useMemo, useState } from 'react'; + +import { useContentCatalogPayload } from '@/business/content-catalog/hooks'; +import { + filterClassroomSupportStrategies, + getClassroomSupportDailyStrategy, + toggleClassroomSupportFavorite, +} from '@/business/classroom-support/selectors'; +import type { ClassroomSupportPage } from '@/business/classroom-support/types'; +import { CONTENT_CATALOG_TYPES } from '@/shared/constants/contentCatalog'; +import type { + ClassroomSupportAgeFilter, + ClassroomSupportCategoryFilter, + ClassroomSupportZoneFilter, +} from '@/shared/constants/classroomSupport'; +import type { Strategy } from '@/shared/types/app'; + +export function useClassroomSupportPage(now: Date = new Date()): ClassroomSupportPage { + const strategiesQuery = useContentCatalogPayload( + CONTENT_CATALOG_TYPES.classroomStrategies, + [], + ); + const [favoriteStrategyIds, setFavoriteStrategyIds] = useState>(new Set()); + const [searchQuery, setSearchQuery] = useState(''); + const [categoryFilter, setCategoryFilter] = useState('all'); + const [ageFilter, setAgeFilter] = useState('all'); + const [zoneFilter, setZoneFilter] = useState('all'); + const [showFavoritesOnly, setShowFavoritesOnly] = useState(false); + const [selectedStrategy, setSelectedStrategy] = useState(null); + const strategies = strategiesQuery.payload; + const filters = useMemo( + () => ({ + searchQuery, + categoryFilter, + ageFilter, + zoneFilter, + showFavoritesOnly, + }), + [ageFilter, categoryFilter, searchQuery, showFavoritesOnly, zoneFilter], + ); + const filteredStrategies = useMemo( + () => filterClassroomSupportStrategies(strategies, filters, favoriteStrategyIds), + [favoriteStrategyIds, filters, strategies], + ); + const tryTodayStrategy = useMemo( + () => getClassroomSupportDailyStrategy(strategies, now), + [now, strategies], + ); + + function toggleFavoritesOnly() { + setShowFavoritesOnly((current) => !current); + } + + function toggleFavorite(id: string) { + setFavoriteStrategyIds((current) => toggleClassroomSupportFavorite(current, id)); + } + + function clearSearch() { + setSearchQuery(''); + } + + return { + strategies, + filteredStrategies, + favoriteStrategyIds, + favoriteCount: favoriteStrategyIds.size, + filters, + selectedStrategy, + tryTodayStrategy, + isLoading: strategiesQuery.isLoading, + error: strategiesQuery.error, + setSearchQuery, + setCategoryFilter, + setAgeFilter, + setZoneFilter, + toggleFavoritesOnly, + toggleFavorite, + selectStrategy: setSelectedStrategy, + closeStrategy: () => setSelectedStrategy(null), + clearSearch, + }; +} diff --git a/frontend/src/business/classroom-support/selectors.test.ts b/frontend/src/business/classroom-support/selectors.test.ts new file mode 100644 index 0000000..287b4e3 --- /dev/null +++ b/frontend/src/business/classroom-support/selectors.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest'; + +import { + filterClassroomSupportStrategies, + getClassroomSupportDailyStrategy, + toClassroomSupportAgeFilter, + toClassroomSupportCategoryFilter, + toClassroomSupportZoneFilter, + toggleClassroomSupportFavorite, +} from '@/business/classroom-support/selectors'; +import type { ClassroomSupportFilters } from '@/business/classroom-support/types'; +import type { Strategy } from '@/shared/types/app'; + +function createStrategy(overrides: Partial = {}): Strategy { + return { + id: 'strategy-1', + title: 'Visual Schedule', + description: 'Use a visual schedule.', + implementationTip: 'Start small.', + category: 'visual-support', + ageGroup: 'All', + zone: 'green', + image: 'https://example.com/image.jpg', + ...overrides, + }; +} + +function createFilters(overrides: Partial = {}): ClassroomSupportFilters { + return { + searchQuery: '', + categoryFilter: 'all', + ageFilter: 'all', + zoneFilter: 'all', + showFavoritesOnly: false, + ...overrides, + }; +} + +describe('classroom support selectors', () => { + it('filters strategies by search, category, age, zone, and favorites', () => { + const strategies = [ + createStrategy({ id: 'visual', title: 'Visual Schedule' }), + createStrategy({ id: 'sensory', title: 'Sensory Break', category: 'sensory', ageGroup: 'K-2', zone: 'yellow' }), + ]; + + expect(filterClassroomSupportStrategies(strategies, createFilters({ searchQuery: 'break' }), new Set()).map((strategy) => strategy.id)).toEqual(['sensory']); + expect(filterClassroomSupportStrategies(strategies, createFilters({ categoryFilter: 'sensory' }), new Set()).map((strategy) => strategy.id)).toEqual(['sensory']); + expect(filterClassroomSupportStrategies(strategies, createFilters({ ageFilter: 'K-2' }), new Set()).map((strategy) => strategy.id)).toEqual(['sensory']); + expect(filterClassroomSupportStrategies(strategies, createFilters({ zoneFilter: 'yellow' }), new Set()).map((strategy) => strategy.id)).toEqual(['sensory']); + expect(filterClassroomSupportStrategies(strategies, createFilters({ showFavoritesOnly: true }), new Set(['visual'])).map((strategy) => strategy.id)).toEqual(['visual']); + }); + + it('selects a deterministic daily strategy', () => { + const strategies = [ + createStrategy({ id: 'first' }), + createStrategy({ id: 'second' }), + ]; + + expect(getClassroomSupportDailyStrategy(strategies, new Date(0))?.id).toBe('first'); + expect(getClassroomSupportDailyStrategy(strategies, new Date(86_400_000))?.id).toBe('second'); + expect(getClassroomSupportDailyStrategy([], new Date(0))).toBeNull(); + }); + + it('toggles favorite IDs immutably', () => { + const current = new Set(['strategy-1']); + + expect(Array.from(toggleClassroomSupportFavorite(current, 'strategy-1'))).toEqual([]); + expect(Array.from(toggleClassroomSupportFavorite(current, 'strategy-2'))).toEqual(['strategy-1', 'strategy-2']); + }); + + it('normalizes invalid filter values', () => { + expect(toClassroomSupportCategoryFilter('sensory')).toBe('sensory'); + expect(toClassroomSupportCategoryFilter('bad')).toBe('all'); + expect(toClassroomSupportAgeFilter('3-5')).toBe('3-5'); + expect(toClassroomSupportAgeFilter('bad')).toBe('all'); + expect(toClassroomSupportZoneFilter('red')).toBe('red'); + expect(toClassroomSupportZoneFilter('bad')).toBe('all'); + }); +}); diff --git a/frontend/src/business/classroom-support/selectors.ts b/frontend/src/business/classroom-support/selectors.ts new file mode 100644 index 0000000..81132ac --- /dev/null +++ b/frontend/src/business/classroom-support/selectors.ts @@ -0,0 +1,103 @@ +import { + CLASSROOM_SUPPORT_DAILY_STRATEGY_INTERVAL_MS, + type ClassroomSupportAgeFilter, + type ClassroomSupportCategoryFilter, + type ClassroomSupportZoneFilter, +} from '@/shared/constants/classroomSupport'; +import type { Strategy } from '@/shared/types/app'; +import type { ClassroomSupportFilters } from '@/business/classroom-support/types'; + +export function filterClassroomSupportStrategies( + strategies: readonly Strategy[], + filters: ClassroomSupportFilters, + favoriteStrategyIds: ReadonlySet, +): readonly Strategy[] { + const normalizedSearch = filters.searchQuery.trim().toLowerCase(); + + return strategies.filter((strategy) => { + if ( + normalizedSearch.length > 0 + && !strategy.title.toLowerCase().includes(normalizedSearch) + && !strategy.description.toLowerCase().includes(normalizedSearch) + ) { + return false; + } + + if (filters.categoryFilter !== 'all' && strategy.category !== filters.categoryFilter) { + return false; + } + + if (filters.ageFilter !== 'all' && strategy.ageGroup !== filters.ageFilter) { + return false; + } + + if (filters.zoneFilter !== 'all' && strategy.zone !== filters.zoneFilter) { + return false; + } + + if (filters.showFavoritesOnly && !favoriteStrategyIds.has(strategy.id)) { + return false; + } + + return true; + }); +} + +export function getClassroomSupportDailyStrategy( + strategies: readonly Strategy[], + now: Date, +): Strategy | null { + if (strategies.length === 0) { + return null; + } + + const dayIndex = Math.floor(now.getTime() / CLASSROOM_SUPPORT_DAILY_STRATEGY_INTERVAL_MS); + return strategies[dayIndex % strategies.length] ?? null; +} + +export function toggleClassroomSupportFavorite( + favoriteStrategyIds: ReadonlySet, + strategyId: string, +): ReadonlySet { + const next = new Set(favoriteStrategyIds); + + if (next.has(strategyId)) { + next.delete(strategyId); + } else { + next.add(strategyId); + } + + return next; +} + +export function toClassroomSupportCategoryFilter(value: string): ClassroomSupportCategoryFilter { + if ( + value === 'all' + || value === 'visual-support' + || value === 'transition' + || value === 'sensory' + || value === 'communication' + || value === 'behavior' + || value === 'social' + ) { + return value; + } + + return 'all'; +} + +export function toClassroomSupportAgeFilter(value: string): ClassroomSupportAgeFilter { + if (value === 'all' || value === 'K-2' || value === '3-5' || value === '6-8' || value === 'All') { + return value; + } + + return 'all'; +} + +export function toClassroomSupportZoneFilter(value: string): ClassroomSupportZoneFilter { + if (value === 'all' || value === 'blue' || value === 'green' || value === 'yellow' || value === 'red') { + return value; + } + + return 'all'; +} diff --git a/frontend/src/business/classroom-support/types.ts b/frontend/src/business/classroom-support/types.ts new file mode 100644 index 0000000..a220a48 --- /dev/null +++ b/frontend/src/business/classroom-support/types.ts @@ -0,0 +1,35 @@ +import type { + ClassroomSupportAgeFilter, + ClassroomSupportCategoryFilter, + ClassroomSupportZoneFilter, +} from '@/shared/constants/classroomSupport'; +import type { Strategy } from '@/shared/types/app'; + +export interface ClassroomSupportFilters { + readonly searchQuery: string; + readonly categoryFilter: ClassroomSupportCategoryFilter; + readonly ageFilter: ClassroomSupportAgeFilter; + readonly zoneFilter: ClassroomSupportZoneFilter; + readonly showFavoritesOnly: boolean; +} + +export interface ClassroomSupportPage { + readonly strategies: readonly Strategy[]; + readonly filteredStrategies: readonly Strategy[]; + readonly favoriteStrategyIds: ReadonlySet; + readonly favoriteCount: number; + readonly filters: ClassroomSupportFilters; + readonly selectedStrategy: Strategy | null; + readonly tryTodayStrategy: Strategy | null; + readonly isLoading: boolean; + readonly error: unknown; + readonly setSearchQuery: (query: string) => void; + readonly setCategoryFilter: (filter: ClassroomSupportCategoryFilter) => void; + readonly setAgeFilter: (filter: ClassroomSupportAgeFilter) => void; + readonly setZoneFilter: (filter: ClassroomSupportZoneFilter) => void; + readonly toggleFavoritesOnly: () => void; + readonly toggleFavorite: (id: string) => void; + readonly selectStrategy: (strategy: Strategy) => void; + readonly closeStrategy: () => void; + readonly clearSearch: () => void; +} diff --git a/frontend/src/business/classroom-timer/audio.ts b/frontend/src/business/classroom-timer/audio.ts new file mode 100644 index 0000000..be94fea --- /dev/null +++ b/frontend/src/business/classroom-timer/audio.ts @@ -0,0 +1,126 @@ +import type { TimerSoundType } from '@/shared/types/classroomTimer'; + +const connectTone = ( + audioCtx: AudioContext, + oscillator: OscillatorNode, + gain: GainNode, +) => { + oscillator.connect(gain).connect(audioCtx.destination); +}; + +export function playBuiltInSound(soundType: TimerSoundType, audioCtx: AudioContext): void { + const now = audioCtx.currentTime; + + switch (soundType) { + case 'gentle-chime': { + const frequencies = [523.25, 659.25, 783.99, 1046.5]; + frequencies.forEach((frequency, index) => { + const oscillator = audioCtx.createOscillator(); + const gain = audioCtx.createGain(); + oscillator.type = 'sine'; + oscillator.frequency.value = frequency; + gain.gain.setValueAtTime(0, now + index * 0.3); + gain.gain.linearRampToValueAtTime(0.3, now + index * 0.3 + 0.05); + gain.gain.exponentialRampToValueAtTime(0.001, now + index * 0.3 + 1.5); + connectTone(audioCtx, oscillator, gain); + oscillator.start(now + index * 0.3); + oscillator.stop(now + index * 0.3 + 1.5); + }); + return; + } + case 'soft-bell': { + const oscillator = audioCtx.createOscillator(); + const gain = audioCtx.createGain(); + oscillator.type = 'sine'; + oscillator.frequency.value = 440; + gain.gain.setValueAtTime(0.4, now); + gain.gain.exponentialRampToValueAtTime(0.001, now + 3); + connectTone(audioCtx, oscillator, gain); + oscillator.start(now); + oscillator.stop(now + 3); + + const overtone = audioCtx.createOscillator(); + const overtoneGain = audioCtx.createGain(); + overtone.type = 'sine'; + overtone.frequency.value = 880; + overtoneGain.gain.setValueAtTime(0.15, now); + overtoneGain.gain.exponentialRampToValueAtTime(0.001, now + 2); + connectTone(audioCtx, overtone, overtoneGain); + overtone.start(now); + overtone.stop(now + 2); + return; + } + case 'xylophone': { + const notes = [523.25, 587.33, 659.25, 783.99, 880, 783.99, 659.25]; + notes.forEach((frequency, index) => { + const oscillator = audioCtx.createOscillator(); + const gain = audioCtx.createGain(); + oscillator.type = 'triangle'; + oscillator.frequency.value = frequency; + gain.gain.setValueAtTime(0, now + index * 0.15); + gain.gain.linearRampToValueAtTime(0.35, now + index * 0.15 + 0.01); + gain.gain.exponentialRampToValueAtTime(0.001, now + index * 0.15 + 0.6); + connectTone(audioCtx, oscillator, gain); + oscillator.start(now + index * 0.15); + oscillator.stop(now + index * 0.15 + 0.6); + }); + return; + } + case 'singing-bowl': { + const oscillator = audioCtx.createOscillator(); + const gain = audioCtx.createGain(); + oscillator.type = 'sine'; + oscillator.frequency.value = 256; + oscillator.frequency.linearRampToValueAtTime(260, now + 4); + gain.gain.setValueAtTime(0, now); + gain.gain.linearRampToValueAtTime(0.35, now + 0.3); + gain.gain.setValueAtTime(0.35, now + 1); + gain.gain.exponentialRampToValueAtTime(0.001, now + 5); + connectTone(audioCtx, oscillator, gain); + oscillator.start(now); + oscillator.stop(now + 5); + + const harmonic = audioCtx.createOscillator(); + const harmonicGain = audioCtx.createGain(); + harmonic.type = 'sine'; + harmonic.frequency.value = 512; + harmonicGain.gain.setValueAtTime(0, now); + harmonicGain.gain.linearRampToValueAtTime(0.1, now + 0.3); + harmonicGain.gain.exponentialRampToValueAtTime(0.001, now + 4); + connectTone(audioCtx, harmonic, harmonicGain); + harmonic.start(now); + harmonic.stop(now + 4); + return; + } + case 'harp-gliss': { + const scale = [261.63, 293.66, 329.63, 349.23, 392, 440, 493.88, 523.25, 587.33, 659.25, 698.46, 783.99]; + scale.forEach((frequency, index) => { + const oscillator = audioCtx.createOscillator(); + const gain = audioCtx.createGain(); + oscillator.type = 'sine'; + oscillator.frequency.value = frequency; + gain.gain.setValueAtTime(0, now + index * 0.08); + gain.gain.linearRampToValueAtTime(0.2, now + index * 0.08 + 0.02); + gain.gain.exponentialRampToValueAtTime(0.001, now + index * 0.08 + 1.2); + connectTone(audioCtx, oscillator, gain); + oscillator.start(now + index * 0.08); + oscillator.stop(now + index * 0.08 + 1.2); + }); + return; + } + case 'nature-birds': + case 'ocean-wave': + case 'rain-stick': { + const oscillator = audioCtx.createOscillator(); + const gain = audioCtx.createGain(); + oscillator.type = 'sine'; + oscillator.frequency.value = 523.25; + gain.gain.setValueAtTime(0.3, now); + gain.gain.exponentialRampToValueAtTime(0.001, now + 2); + connectTone(audioCtx, oscillator, gain); + oscillator.start(now); + oscillator.stop(now + 2); + return; + } + } +} diff --git a/frontend/src/business/classroom-timer/hooks.ts b/frontend/src/business/classroom-timer/hooks.ts new file mode 100644 index 0000000..fac3091 --- /dev/null +++ b/frontend/src/business/classroom-timer/hooks.ts @@ -0,0 +1,269 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import { useContentCatalogPayload } from '@/business/content-catalog/hooks'; +import { playBuiltInSound } from '@/business/classroom-timer/audio'; +import { getFirstQueryError, isAnyLoading } from '@/shared/business/queryState'; +import { + createTimerParticles, + formatTimerTime, + getTimerProgress, + getTimerUrgencyColor, + parseBoundedInteger, +} from '@/business/classroom-timer/selectors'; +import { + DEFAULT_CUSTOM_MINUTES, + DEFAULT_CUSTOM_SECONDS, + DEFAULT_TIMER_SECONDS, + FULLSCREEN_PARTICLE_COUNT, + TIMER_CARD_PARTICLE_COUNT, + TIMER_FINISH_REPEAT_DELAY_MS, + TIMER_PREVIEW_DURATION_MS, +} from '@/shared/constants/classroomTimer'; +import { CONTENT_CATALOG_TYPES } from '@/shared/constants/contentCatalog'; +import type { + ClassroomTimerTip, + PresetTimerOption, + SensoryBackground, + SensoryBackgroundId, + TimerSoundOption, + TimerSoundType, +} from '@/shared/types/classroomTimer'; + +declare global { + interface Window { + readonly webkitAudioContext?: typeof AudioContext; + } +} + +export function useClassroomTimer() { + const backgroundsQuery = useContentCatalogPayload( + CONTENT_CATALOG_TYPES.classroomTimerBackgrounds, + [], + ); + const soundsQuery = useContentCatalogPayload( + CONTENT_CATALOG_TYPES.classroomTimerSounds, + [], + ); + const presetsQuery = useContentCatalogPayload( + CONTENT_CATALOG_TYPES.classroomTimerPresets, + [], + ); + const tipsQuery = useContentCatalogPayload( + CONTENT_CATALOG_TYPES.classroomTimerTips, + [], + ); + const [totalSeconds, setTotalSeconds] = useState(DEFAULT_TIMER_SECONDS); + const [remainingSeconds, setRemainingSeconds] = useState(DEFAULT_TIMER_SECONDS); + const [isRunning, setIsRunning] = useState(false); + const [isFinished, setIsFinished] = useState(false); + const [isFullscreen, setIsFullscreen] = useState(false); + const [selectedBackgroundId, setSelectedBackgroundId] = useState(null); + const [selectedSoundId, setSelectedSoundId] = useState(null); + const [soundEnabled, setSoundEnabled] = useState(true); + const [customMinutes, setCustomMinutes] = useState(DEFAULT_CUSTOM_MINUTES); + const [customSeconds, setCustomSeconds] = useState(DEFAULT_CUSTOM_SECONDS); + const [previewPlaying, setPreviewPlaying] = useState(false); + + const intervalRef = useRef(null); + const audioCtxRef = useRef(null); + const fullscreenRef = useRef(null); + + const displayParticles = useMemo( + () => createTimerParticles(TIMER_CARD_PARTICLE_COUNT, 6, 3), + [], + ); + const fullscreenParticles = useMemo( + () => createTimerParticles(FULLSCREEN_PARTICLE_COUNT, 8, 4), + [], + ); + const backgrounds = backgroundsQuery.payload; + const sounds = soundsQuery.payload; + const presets = presetsQuery.payload; + const tips = tipsQuery.payload; + const contentQueries = [backgroundsQuery, soundsQuery, presetsQuery, tipsQuery]; + const selectedBackground = useMemo( + () => backgrounds.find((background) => background.id === selectedBackgroundId) ?? backgrounds[0] ?? null, + [backgrounds, selectedBackgroundId], + ); + const selectedSound = useMemo( + () => sounds.find((sound) => sound.id === selectedSoundId) ?? sounds[0] ?? null, + [selectedSoundId, sounds], + ); + + const getAudioContext = useCallback((): AudioContext => { + if (!audioCtxRef.current) { + const AudioContextConstructor = window.AudioContext || window.webkitAudioContext; + + if (!AudioContextConstructor) { + throw new Error('Web Audio API is not supported in this browser.'); + } + + audioCtxRef.current = new AudioContextConstructor(); + } + + if (audioCtxRef.current.state === 'suspended') { + void audioCtxRef.current.resume(); + } + + return audioCtxRef.current; + }, []); + + useEffect(() => { + if (isRunning && remainingSeconds > 0) { + intervalRef.current = window.setInterval(() => { + setRemainingSeconds((previousValue) => { + if (previousValue <= 1) { + setIsRunning(false); + setIsFinished(true); + return 0; + } + + return previousValue - 1; + }); + }, 1000); + } + + return () => { + if (intervalRef.current) { + window.clearInterval(intervalRef.current); + } + }; + }, [isRunning, remainingSeconds]); + + useEffect(() => { + if (!isFinished || !soundEnabled || !selectedSound) { + return; + } + + const audioContext = getAudioContext(); + playBuiltInSound(selectedSound.id, audioContext); + const repeatSoundTimeout = window.setTimeout(() => { + playBuiltInSound(selectedSound.id, audioContext); + }, TIMER_FINISH_REPEAT_DELAY_MS); + + return () => window.clearTimeout(repeatSoundTimeout); + }, [getAudioContext, isFinished, selectedSound, soundEnabled]); + + useEffect(() => { + const handleFullscreenChange = () => { + if (!document.fullscreenElement) { + setIsFullscreen(false); + } + }; + + document.addEventListener('fullscreenchange', handleFullscreenChange); + return () => document.removeEventListener('fullscreenchange', handleFullscreenChange); + }, []); + + const handleStart = useCallback(() => { + if (remainingSeconds <= 0) { + return; + } + + getAudioContext(); + setIsRunning(true); + setIsFinished(false); + }, [getAudioContext, remainingSeconds]); + + const handlePause = useCallback(() => { + setIsRunning(false); + }, []); + + const handleReset = useCallback(() => { + setIsRunning(false); + setIsFinished(false); + setRemainingSeconds(totalSeconds); + }, [totalSeconds]); + + const handleSetTime = useCallback((seconds: number) => { + setIsRunning(false); + setIsFinished(false); + setTotalSeconds(seconds); + setRemainingSeconds(seconds); + }, []); + + const handleCustomTime = useCallback(() => { + const total = customMinutes * 60 + customSeconds; + if (total > 0) { + handleSetTime(total); + } + }, [customMinutes, customSeconds, handleSetTime]); + + const toggleFullscreen = useCallback(async () => { + if (!isFullscreen) { + if (fullscreenRef.current) { + await fullscreenRef.current.requestFullscreen(); + } + setIsFullscreen(true); + return; + } + + if (document.fullscreenElement) { + await document.exitFullscreen(); + } + setIsFullscreen(false); + }, [isFullscreen]); + + const previewSound = useCallback((soundId: TimerSoundType) => { + if (previewPlaying) { + return; + } + + setPreviewPlaying(true); + const audioContext = getAudioContext(); + playBuiltInSound(soundId, audioContext); + window.setTimeout(() => setPreviewPlaying(false), TIMER_PREVIEW_DURATION_MS); + }, [getAudioContext, previewPlaying]); + + const progress = getTimerProgress(totalSeconds, remainingSeconds); + const formattedTime = formatTimerTime(remainingSeconds); + const urgencyColor = selectedBackground + ? getTimerUrgencyColor(isFinished, progress, selectedBackground) + : 'text-cyan-100'; + + return { + state: { + backgrounds, + sounds, + presets, + tips, + totalSeconds, + remainingSeconds, + isRunning, + isFinished, + isFullscreen, + selectedBackground, + selectedSound, + soundEnabled, + customMinutes, + customSeconds, + previewPlaying, + progress, + formattedTime, + urgencyColor, + displayParticles, + fullscreenParticles, + isContentLoading: isAnyLoading(...contentQueries), + contentError: getFirstQueryError(...contentQueries), + }, + refs: { + fullscreenRef, + }, + actions: { + handleStart, + handlePause, + handleReset, + handleSetTime, + handleCustomTime, + toggleFullscreen, + previewSound, + setSelectedBackground: (background: SensoryBackground) => setSelectedBackgroundId(background.id), + setSelectedSound: (sound: TimerSoundOption) => setSelectedSoundId(sound.id), + setSoundEnabled, + setCustomMinutes, + setCustomSeconds, + parseCustomMinutes: (value: string) => setCustomMinutes(parseBoundedInteger(value, 0, 120)), + parseCustomSeconds: (value: string) => setCustomSeconds(parseBoundedInteger(value, 0, 59)), + }, + }; +} diff --git a/frontend/src/business/classroom-timer/selectors.test.ts b/frontend/src/business/classroom-timer/selectors.test.ts new file mode 100644 index 0000000..db13f06 --- /dev/null +++ b/frontend/src/business/classroom-timer/selectors.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest'; +import { + clampNumber, + formatTimerTime, + getTimerProgress, + getTimerUrgencyColor, + parseBoundedInteger, +} from '@/business/classroom-timer/selectors'; +import type { SensoryBackground } from '@/shared/types/classroomTimer'; + +const background: SensoryBackground = { + id: 'ocean', + name: 'Ocean Calm', + iconId: 'waves', + image: 'ocean.png', + overlay: 'from-blue-950/40 via-blue-900/20 to-cyan-950/40', + textColor: 'text-blue-100', + accentColor: 'text-blue-300', + ringColor: 'stroke-blue-400', + trackColor: 'stroke-blue-900/50', +}; + +describe('classroom timer selectors', () => { + it('formats timer seconds as mm:ss', () => { + expect(formatTimerTime(0)).toBe('00:00'); + expect(formatTimerTime(65)).toBe('01:05'); + expect(formatTimerTime(600)).toBe('10:00'); + }); + + it('calculates progress and handles zero totals', () => { + expect(getTimerProgress(100, 25)).toBe(0.25); + expect(getTimerProgress(0, 25)).toBe(0); + }); + + it('selects urgency color from finish and progress thresholds', () => { + expect(getTimerUrgencyColor(true, 0.5, background)).toBe('text-red-400 animate-pulse'); + expect(getTimerUrgencyColor(false, 0.1, background)).toBe('text-red-300'); + expect(getTimerUrgencyColor(false, 0.25, background)).toBe('text-amber-300'); + expect(getTimerUrgencyColor(false, 0.5, background)).toBe('text-blue-100'); + }); + + it('clamps and parses bounded integers', () => { + expect(clampNumber(12, 1, 10)).toBe(10); + expect(clampNumber(-2, 1, 10)).toBe(1); + expect(parseBoundedInteger('7', 1, 10)).toBe(7); + expect(parseBoundedInteger('invalid', 1, 10)).toBe(1); + }); +}); diff --git a/frontend/src/business/classroom-timer/selectors.ts b/frontend/src/business/classroom-timer/selectors.ts new file mode 100644 index 0000000..ca3de9f --- /dev/null +++ b/frontend/src/business/classroom-timer/selectors.ts @@ -0,0 +1,53 @@ +import type { SensoryBackground, TimerParticle } from '@/shared/types/classroomTimer'; + +export function formatTimerTime(seconds: number): string { + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds % 60; + + return `${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`; +} + +export function getTimerProgress(totalSeconds: number, remainingSeconds: number): number { + return totalSeconds > 0 ? remainingSeconds / totalSeconds : 0; +} + +export function getTimerUrgencyColor( + isFinished: boolean, + progress: number, + background: SensoryBackground, +): string { + if (isFinished) { + return 'text-red-400 animate-pulse'; + } + + if (progress <= 0.1) { + return 'text-red-300'; + } + + if (progress <= 0.25) { + return 'text-amber-300'; + } + + return background.textColor; +} + +export function clampNumber(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)); +} + +export function parseBoundedInteger(value: string, min: number, max: number): number { + const parsedValue = Number.parseInt(value, 10); + return clampNumber(Number.isNaN(parsedValue) ? min : parsedValue, min, max); +} + +export function createTimerParticles(count: number, maxSize: number, minSize: number): readonly TimerParticle[] { + return Array.from({ length: count }, (_, index) => ({ + id: index, + width: Math.random() * maxSize + minSize, + height: Math.random() * maxSize + minSize, + left: Math.random() * 100, + top: Math.random() * 100, + animationDelay: Math.random() * 10, + animationDuration: Math.random() * 10 + 10, + })); +} diff --git a/frontend/src/business/communications/hooks.ts b/frontend/src/business/communications/hooks.ts new file mode 100644 index 0000000..007eb1b --- /dev/null +++ b/frontend/src/business/communications/hooks.ts @@ -0,0 +1,44 @@ +import { useQuery } from '@tanstack/react-query'; +import { + createCommunicationEvent, + createParentMessage, + listCommunicationEvents, + listParentMessages, +} from '@/shared/api/communications'; +import { COMMUNICATION_QUERY_KEYS } from '@/shared/constants/communications'; +import type { + CommunicationEventCreateDto, + CommunicationEventType, + ParentMessageCategory, + ParentMessageCreateDto, +} from '@/shared/types/communications'; +import { getApiListRows } from '@/shared/business/apiListRows'; +import { useInvalidatingMutation } from '@/shared/business/queryMutations'; + +export function useParentMessages(category?: ParentMessageCategory) { + return useQuery({ + queryKey: category ? [...COMMUNICATION_QUERY_KEYS.parentMessages, category] : COMMUNICATION_QUERY_KEYS.parentMessages, + queryFn: () => getApiListRows(listParentMessages(category)), + }); +} + +export function useSendParentMessage() { + return useInvalidatingMutation({ + mutationFn: (request: ParentMessageCreateDto) => createParentMessage(request), + invalidateQueryKey: COMMUNICATION_QUERY_KEYS.parentMessages, + }); +} + +export function useCommunicationEvents(type?: CommunicationEventType) { + return useQuery({ + queryKey: type ? [...COMMUNICATION_QUERY_KEYS.events, type] : COMMUNICATION_QUERY_KEYS.events, + queryFn: () => getApiListRows(listCommunicationEvents(type)), + }); +} + +export function useCreateCommunicationEvent() { + return useInvalidatingMutation({ + mutationFn: (request: CommunicationEventCreateDto) => createCommunicationEvent(request), + invalidateQueryKey: COMMUNICATION_QUERY_KEYS.events, + }); +} diff --git a/frontend/src/business/communications/selectors.test.ts b/frontend/src/business/communications/selectors.test.ts new file mode 100644 index 0000000..fae7b35 --- /dev/null +++ b/frontend/src/business/communications/selectors.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest'; +import { + canCreateCommunicationEvents, + filterCommunicationEventsByRole, + getTemplateCategory, + toCommunicationEventType, +} from '@/business/communications/selectors'; +import type { CommunicationEventDto, ParentTemplate } from '@/shared/types/communications'; + +function createEvent( + overrides: Partial = {}, +): CommunicationEventDto { + return { + id: 'event-1', + title: 'Safety drill', + date: '2026-06-08', + type: 'drill', + roles: ['director', 'teacher'], + organizationId: 'org-1', + campusId: 'campus-1', + createdById: 'user-1', + updatedById: null, + createdAt: '2026-06-08T10:00:00.000Z', + updatedAt: '2026-06-08T10:00:00.000Z', + ...overrides, + }; +} + +describe('communication selectors', () => { + it('allows only director and superintendent roles to create events', () => { + expect(canCreateCommunicationEvents('director')).toBe(true); + expect(canCreateCommunicationEvents('superintendent')).toBe(true); + expect(canCreateCommunicationEvents('teacher')).toBe(false); + expect(canCreateCommunicationEvents('para')).toBe(false); + expect(canCreateCommunicationEvents('office')).toBe(false); + }); + + it('resolves selected template category and falls back to general', () => { + const templates: readonly ParentTemplate[] = [ + { id: 'progress', template: 'Progress note', category: 'progress' }, + { id: 'event', template: 'Event note', category: 'event' }, + ]; + + expect(getTemplateCategory('progress', templates)).toBe('progress'); + expect(getTemplateCategory('missing', templates)).toBe('general'); + expect(getTemplateCategory(null, templates)).toBe('general'); + }); + + it('filters communication events by visible user role', () => { + const events = [ + createEvent({ id: 'teacher-event', roles: ['teacher'] }), + createEvent({ id: 'director-event', roles: ['director', 'superintendent'] }), + createEvent({ id: 'office-event', roles: ['office'] }), + ]; + + expect(filterCommunicationEventsByRole(events, 'director')).toEqual([events[1]]); + expect(filterCommunicationEventsByRole(events, 'office')).toEqual([events[2]]); + }); + + it('normalizes unsupported event types to meeting', () => { + expect(toCommunicationEventType('deadline')).toBe('deadline'); + expect(toCommunicationEventType('unsupported')).toBe('meeting'); + }); +}); diff --git a/frontend/src/business/communications/selectors.ts b/frontend/src/business/communications/selectors.ts new file mode 100644 index 0000000..2023f14 --- /dev/null +++ b/frontend/src/business/communications/selectors.ts @@ -0,0 +1,30 @@ +import type { + CommunicationEventType, + CommunicationEventDto, + ParentMessageCategory, +} from '@/shared/types/communications'; +import type { UserRole } from '@/shared/types/app'; +import { COMMUNICATION_EVENT_TYPES } from '@/shared/constants/communications'; + +export function canCreateCommunicationEvents(userRole: UserRole): boolean { + return userRole === 'director' || userRole === 'superintendent'; +} + +export function getTemplateCategory( + selectedTemplateId: string | null, + templates: readonly { readonly id: string; readonly category: ParentMessageCategory }[], +): ParentMessageCategory { + const template = templates.find((item) => item.id === selectedTemplateId); + return template?.category ?? 'general'; +} + +export function filterCommunicationEventsByRole( + events: readonly CommunicationEventDto[], + userRole: UserRole, +): readonly CommunicationEventDto[] { + return events.filter((event) => event.roles.includes(userRole)); +} + +export function toCommunicationEventType(value: string): CommunicationEventType { + return COMMUNICATION_EVENT_TYPES.find((type) => type === value) ?? 'meeting'; +} diff --git a/frontend/src/business/community/hooks.ts b/frontend/src/business/community/hooks.ts new file mode 100644 index 0000000..a944ac9 --- /dev/null +++ b/frontend/src/business/community/hooks.ts @@ -0,0 +1,102 @@ +import { useMemo, useState } from 'react'; +import { useContentCatalogPayload } from '@/business/content-catalog/hooks'; +import { CONTENT_CATALOG_TYPES } from '@/shared/constants/contentCatalog'; +import type { + CommunityAgeFilter, + CommunityCategoryFilter, + CommunityOrganization, + CommunityPartnershipFilter, +} from '@/shared/types/community'; +import { + calculateCommunityStats, + filterCommunityOrganizations, + listCommunityCategories, + toCommunityAgeFilter, + toCommunityCategoryFilter, + toCommunityPartnershipFilter, +} from '@/business/community/selectors'; + +export function useCommunityService() { + const contentCatalog = useContentCatalogPayload( + CONTENT_CATALOG_TYPES.communityOrganizations, + [], + ); + const [searchQuery, setSearchQuery] = useState(''); + const [categoryFilter, setCategoryFilter] = useState('all'); + const [typeFilter, setTypeFilter] = useState('all'); + const [ageFilter, setAgeFilter] = useState('all'); + const [expandedOrganizationId, setExpandedOrganizationId] = useState(null); + const [savedOrganizationIds, setSavedOrganizationIds] = useState>(new Set()); + const [showFilters, setShowFilters] = useState(false); + + const organizations = contentCatalog.payload; + const categories = useMemo(() => listCommunityCategories(organizations), [organizations]); + const filteredOrganizations = useMemo( + () => filterCommunityOrganizations( + organizations, + searchQuery, + categoryFilter, + typeFilter, + ageFilter, + ), + [ageFilter, categoryFilter, organizations, searchQuery, typeFilter], + ); + const stats = useMemo( + () => calculateCommunityStats(organizations, savedOrganizationIds.size), + [organizations, savedOrganizationIds.size], + ); + + const updateCategoryFilter = (value: string) => { + setCategoryFilter(toCommunityCategoryFilter(value, categories)); + }; + + const updateTypeFilter = (value: string) => { + setTypeFilter(toCommunityPartnershipFilter(value)); + }; + + const updateAgeFilter = (value: string) => { + setAgeFilter(toCommunityAgeFilter(value)); + }; + + const toggleFilters = () => { + setShowFilters((current) => !current); + }; + + const toggleExpandedOrganization = (id: string) => { + setExpandedOrganizationId((current) => (current === id ? null : id)); + }; + + const toggleSavedOrganization = (id: string) => { + setSavedOrganizationIds((current) => { + const next = new Set(current); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + return next; + }); + }; + + return { + searchQuery, + categoryFilter, + typeFilter, + ageFilter, + expandedOrganizationId, + savedOrganizationIds, + showFilters, + categories, + filteredOrganizations, + stats, + isLoading: contentCatalog.isLoading, + error: contentCatalog.error, + setSearchQuery, + updateCategoryFilter, + updateTypeFilter, + updateAgeFilter, + toggleFilters, + toggleExpandedOrganization, + toggleSavedOrganization, + }; +} diff --git a/frontend/src/business/community/selectors.test.ts b/frontend/src/business/community/selectors.test.ts new file mode 100644 index 0000000..f61443b --- /dev/null +++ b/frontend/src/business/community/selectors.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from 'vitest'; +import { + calculateCommunityStats, + filterCommunityOrganizations, + listCommunityCategories, + toCommunityAgeFilter, + toCommunityCategoryFilter, + toCommunityPartnershipFilter, +} from '@/business/community/selectors'; +import type { CommunityOrganization } from '@/shared/types/community'; + +const organizations: readonly CommunityOrganization[] = [ + { + id: 'food-bank', + name: 'Sunshine Food Bank', + category: 'Food & Nutrition', + description: 'Meal kit packing and family food support.', + address: '100 Main St', + phone: '(602) 555-0100', + email: 'volunteer@example.org', + website: 'example.org', + distance: '2.0 mi', + opportunities: ['Meal kits'], + partnershipType: 'both', + ageGroups: ['K-2', '3-5', '6-8'], + rating: 5, + featured: true, + }, + { + id: 'library', + name: 'Phoenix Library', + category: 'Education & Literacy', + description: 'Reading buddies and shelf organization.', + address: '200 Book Ave', + phone: '(602) 555-0200', + email: 'library@example.org', + website: 'library.example.org', + distance: '4.0 mi', + opportunities: ['Reading buddies'], + partnershipType: 'school-partnership', + ageGroups: ['K-2', '3-5'], + rating: 4, + featured: false, + }, + { + id: 'cleanup', + name: 'Neighborhood Cleanup Coalition', + category: 'Environment & Nature', + description: 'Park cleanups and recycling drives.', + address: 'Various', + phone: '(602) 555-0300', + email: 'cleanup@example.org', + website: 'cleanup.example.org', + distance: '1.0 mi', + opportunities: ['Park cleanup'], + partnershipType: 'community-service', + ageGroups: ['6-8'], + rating: 4, + featured: false, + }, +]; + +describe('community selectors', () => { + it('lists unique categories in sorted order', () => { + expect(listCommunityCategories(organizations)).toEqual([ + 'Education & Literacy', + 'Environment & Nature', + 'Food & Nutrition', + ]); + }); + + it('normalizes category, partnership, and age filters', () => { + const categories = listCommunityCategories(organizations); + + expect(toCommunityCategoryFilter('Food & Nutrition', categories)).toBe('Food & Nutrition'); + expect(toCommunityCategoryFilter('Missing', categories)).toBe('all'); + expect(toCommunityPartnershipFilter('school-partnership')).toBe('school-partnership'); + expect(toCommunityPartnershipFilter('unexpected')).toBe('all'); + expect(toCommunityAgeFilter('3-5')).toBe('3-5'); + expect(toCommunityAgeFilter('9-12')).toBe('all'); + }); + + it('filters organizations by search, category, partnership, and age', () => { + expect(filterCommunityOrganizations(organizations, ' food ', 'all', 'all', 'all')).toEqual([organizations[0]]); + expect(filterCommunityOrganizations(organizations, '', 'Education & Literacy', 'all', 'all')).toEqual([ + organizations[1], + ]); + expect(filterCommunityOrganizations(organizations, '', 'all', 'school-partnership', 'all')).toEqual([ + organizations[0], + organizations[1], + ]); + expect(filterCommunityOrganizations(organizations, '', 'all', 'community-service', '6-8')).toEqual([ + organizations[0], + organizations[2], + ]); + }); + + it('calculates catalog-level stats with saved count', () => { + expect(calculateCommunityStats(organizations, 3)).toEqual({ + organizations: organizations.length, + serviceProjects: organizations.filter((organization) => ( + organization.partnershipType !== 'school-partnership' + )).length, + schoolPartners: organizations.filter((organization) => ( + organization.partnershipType !== 'community-service' + )).length, + saved: 3, + }); + }); +}); diff --git a/frontend/src/business/community/selectors.ts b/frontend/src/business/community/selectors.ts new file mode 100644 index 0000000..0b76b7e --- /dev/null +++ b/frontend/src/business/community/selectors.ts @@ -0,0 +1,86 @@ +import type { + CommunityAgeFilter, + CommunityCategory, + CommunityCategoryFilter, + CommunityOrganization, + CommunityPartnershipFilter, + CommunityStats, +} from '@/shared/types/community'; + +export function listCommunityCategories( + organizations: readonly CommunityOrganization[], +): readonly CommunityCategory[] { + return [...new Set(organizations.map((organization) => organization.category))].sort(); +} + +export function toCommunityCategoryFilter( + value: string, + categories: readonly CommunityCategory[], +): CommunityCategoryFilter { + if (value === 'all') { + return 'all'; + } + + return categories.find((category) => category === value) ?? 'all'; +} + +export function toCommunityPartnershipFilter(value: string): CommunityPartnershipFilter { + if ( + value === 'community-service' + || value === 'school-partnership' + || value === 'both' + || value === 'all' + ) { + return value; + } + + return 'all'; +} + +export function toCommunityAgeFilter(value: string): CommunityAgeFilter { + if (value === 'K-2' || value === '3-5' || value === '6-8' || value === 'all') { + return value; + } + + return 'all'; +} + +export function filterCommunityOrganizations( + organizations: readonly CommunityOrganization[], + searchQuery: string, + categoryFilter: CommunityCategoryFilter, + typeFilter: CommunityPartnershipFilter, + ageFilter: CommunityAgeFilter, +): readonly CommunityOrganization[] { + const normalizedQuery = searchQuery.trim().toLowerCase(); + + return organizations.filter((organization) => { + const matchesSearch = normalizedQuery === '' + || organization.name.toLowerCase().includes(normalizedQuery) + || organization.description.toLowerCase().includes(normalizedQuery) + || organization.category.toLowerCase().includes(normalizedQuery); + const matchesCategory = categoryFilter === 'all' || organization.category === categoryFilter; + const matchesType = typeFilter === 'all' + || organization.partnershipType === typeFilter + || organization.partnershipType === 'both'; + const matchesAge = ageFilter === 'all' || organization.ageGroups.includes(ageFilter); + + return matchesSearch && matchesCategory && matchesType && matchesAge; + }); +} + +export function calculateCommunityStats( + organizations: readonly CommunityOrganization[], + savedCount: number, +): CommunityStats { + return { + organizations: organizations.length, + serviceProjects: organizations.filter((organization) => ( + organization.partnershipType !== 'school-partnership' + )).length, + schoolPartners: organizations.filter((organization) => ( + organization.partnershipType !== 'community-service' + )).length, + saved: savedCount, + }; +} diff --git a/frontend/src/business/content-catalog/hooks.ts b/frontend/src/business/content-catalog/hooks.ts new file mode 100644 index 0000000..d2e5cf9 --- /dev/null +++ b/frontend/src/business/content-catalog/hooks.ts @@ -0,0 +1,23 @@ +import { useQuery } from '@tanstack/react-query'; +import { getContentCatalog } from '@/shared/api/contentCatalog'; +import { CONTENT_CATALOG_QUERY_KEYS } from '@/shared/constants/contentCatalog'; + +export function useContentCatalogPayload( + contentType: string, + emptyPayload: TPayload, +) { + const query = useQuery({ + queryKey: [...CONTENT_CATALOG_QUERY_KEYS.content, contentType], + queryFn: async () => { + const response = await getContentCatalog(contentType); + return response.payload; + }, + }); + + return { + payload: query.data ?? emptyPayload, + isLoading: query.isLoading, + error: query.error, + refresh: query.refetch, + }; +} diff --git a/frontend/src/business/dashboard/hooks.ts b/frontend/src/business/dashboard/hooks.ts new file mode 100644 index 0000000..99823c2 --- /dev/null +++ b/frontend/src/business/dashboard/hooks.ts @@ -0,0 +1,110 @@ +import { useMemo, useState } from 'react'; + +import { useCommunicationEvents } from '@/business/communications/hooks'; +import { filterCommunicationEventsByRole } from '@/business/communications/selectors'; +import { useContentCatalogPayload } from '@/business/content-catalog/hooks'; +import { + getDashboardGreeting, + selectDashboardActiveZone, + selectDashboardQuickActions, + selectDashboardQuote, + selectDashboardUpcomingEvents, +} from '@/business/dashboard/selectors'; +import type { + DashboardPage, + DashboardProps, +} from '@/business/dashboard/types'; +import { useFrameEntries } from '@/business/frame/hooks'; +import { useZoneCheckIn } from '@/business/user-progress/hooks'; +import { CONTENT_CATALOG_TYPES } from '@/shared/constants/contentCatalog'; +import { DASHBOARD_ZONE_OPTIONS } from '@/shared/constants/dashboard'; +import { getOptionalErrorMessage } from '@/shared/errors/errorMessages'; +import type { ZoneColor } from '@/shared/types/app'; +import type { + DashboardComplianceItem, + DashboardEncouragingQuote, + DashboardSignOfWeek, +} from '@/shared/types/dashboard'; + +export function useDashboardPage({ + userRole, + userName, + setCurrentModule, + zoneCheckIn, + setZoneCheckIn, +}: DashboardProps): DashboardPage { + const [dashboardDate] = useState(() => new Date()); + const quotesQuery = useContentCatalogPayload( + CONTENT_CATALOG_TYPES.dashboardEncouragingQuotes, + [], + ); + const complianceItemsQuery = useContentCatalogPayload( + CONTENT_CATALOG_TYPES.dashboardComplianceItems, + [], + ); + const signOfWeekQuery = useContentCatalogPayload( + CONTENT_CATALOG_TYPES.dashboardSignOfWeek, + null, + ); + const frameEntriesQuery = useFrameEntries(); + const zoneCheckInState = useZoneCheckIn(); + const communicationEventsQuery = useCommunicationEvents(); + const roleEvents = useMemo( + () => filterCommunicationEventsByRole(communicationEventsQuery.data ?? [], userRole), + [communicationEventsQuery.data, userRole], + ); + const upcomingEvents = useMemo( + () => selectDashboardUpcomingEvents(roleEvents), + [roleEvents], + ); + const activeZone = selectDashboardActiveZone(zoneCheckIn, zoneCheckInState.currentZone); + const todayQuote = useMemo( + () => selectDashboardQuote(quotesQuery.payload, dashboardDate), + [dashboardDate, quotesQuery.payload], + ); + + async function checkInZone(zone: ZoneColor) { + await zoneCheckInState.setZone(zone); + setZoneCheckIn(zone); + } + + async function resetZone() { + await zoneCheckInState.resetZone(); + setZoneCheckIn(null); + } + + return { + userRole, + userName, + greeting: getDashboardGreeting(dashboardDate), + latestFrame: frameEntriesQuery.data?.[0] ?? null, + todayQuote, + quoteState: { + isLoading: quotesQuery.isLoading, + isError: Boolean(quotesQuery.error), + }, + zoneOptions: DASHBOARD_ZONE_OPTIONS, + activeZone, + isZoneSaving: zoneCheckInState.isSaving, + zoneErrorMessage: getOptionalErrorMessage(zoneCheckInState.error), + upcomingEvents, + eventsState: { + isLoading: communicationEventsQuery.isLoading, + isError: communicationEventsQuery.isError, + }, + complianceItems: complianceItemsQuery.payload, + complianceState: { + isLoading: complianceItemsQuery.isLoading, + isError: Boolean(complianceItemsQuery.error), + }, + signOfWeek: signOfWeekQuery.payload, + signOfWeekState: { + isLoading: signOfWeekQuery.isLoading, + isError: Boolean(signOfWeekQuery.error), + }, + quickActions: selectDashboardQuickActions(userRole), + goToModule: setCurrentModule, + checkInZone, + resetZone, + }; +} diff --git a/frontend/src/business/dashboard/selectors.test.ts b/frontend/src/business/dashboard/selectors.test.ts new file mode 100644 index 0000000..971aeb2 --- /dev/null +++ b/frontend/src/business/dashboard/selectors.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest'; + +import { + getDashboardDayOfYear, + getDashboardGreeting, + selectDashboardActiveZone, + selectDashboardQuickActions, + selectDashboardQuote, + selectDashboardUpcomingEvents, + toDashboardZoneColor, +} from '@/business/dashboard/selectors'; +import type { CommunicationEventDto } from '@/shared/types/communications'; +import type { DashboardEncouragingQuote } from '@/shared/types/dashboard'; + +const quotes: readonly DashboardEncouragingQuote[] = [ + { quote: 'First', author: 'A' }, + { quote: 'Second', author: 'B' }, +]; + +function createEvent(id: string): CommunicationEventDto { + return { + id, + title: `Event ${id}`, + date: '2026-06-09T10:00:00.000Z', + type: 'meeting', + roles: ['teacher'], + organizationId: 'org-1', + campusId: 'campus-1', + createdById: 'user-1', + updatedById: null, + createdAt: '2026-06-09T10:00:00.000Z', + updatedAt: '2026-06-09T10:00:00.000Z', + }; +} + +describe('dashboard selectors', () => { + it('selects greeting by day segment', () => { + expect(getDashboardGreeting(new Date('2026-06-09T08:00:00'))).toBe('Good Morning'); + expect(getDashboardGreeting(new Date('2026-06-09T13:00:00'))).toBe('Good Afternoon'); + expect(getDashboardGreeting(new Date('2026-06-09T18:00:00'))).toBe('Good Evening'); + }); + + it('calculates day of year and rotates quotes', () => { + const date = new Date('2026-01-02T12:00:00'); + + expect(getDashboardDayOfYear(date)).toBe(2); + expect(selectDashboardQuote(quotes, date)).toEqual(quotes[0]); + }); + + it('returns null quote when no quotes are published', () => { + expect(selectDashboardQuote([], new Date('2026-01-02T12:00:00'))).toBeNull(); + }); + + it('normalizes dashboard zone values', () => { + expect(toDashboardZoneColor('green')).toBe('green'); + expect(toDashboardZoneColor('purple')).toBeNull(); + expect(toDashboardZoneColor(null)).toBeNull(); + }); + + it('prefers local zone check-in over saved zone', () => { + expect(selectDashboardActiveZone('yellow', 'green')).toBe('yellow'); + expect(selectDashboardActiveZone(null, 'green')).toBe('green'); + }); + + it('limits upcoming events', () => { + expect(selectDashboardUpcomingEvents([ + createEvent('1'), + createEvent('2'), + createEvent('3'), + createEvent('4'), + createEvent('5'), + ])).toHaveLength(4); + }); + + it('hides classroom quick action for office role', () => { + expect(selectDashboardQuickActions('office').some((action) => action.module === 'classroom')).toBe(false); + expect(selectDashboardQuickActions('teacher').some((action) => action.module === 'classroom')).toBe(true); + }); +}); diff --git a/frontend/src/business/dashboard/selectors.ts b/frontend/src/business/dashboard/selectors.ts new file mode 100644 index 0000000..a700687 --- /dev/null +++ b/frontend/src/business/dashboard/selectors.ts @@ -0,0 +1,67 @@ +import { + DASHBOARD_QUICK_ACTIONS, + DASHBOARD_UPCOMING_EVENTS_LIMIT, +} from '@/shared/constants/dashboard'; +import type { DashboardQuickAction } from '@/shared/constants/dashboard'; +import type { + UserRole, + ZoneColor, +} from '@/shared/types/app'; +import type { CommunicationEventDto } from '@/shared/types/communications'; +import type { DashboardEncouragingQuote } from '@/shared/types/dashboard'; + +export function getDashboardGreeting(date: Date): string { + const hour = date.getHours(); + + if (hour < 12) { + return 'Good Morning'; + } + + if (hour < 17) { + return 'Good Afternoon'; + } + + return 'Good Evening'; +} + +export function getDashboardDayOfYear(date: Date): number { + const yearStart = new Date(date.getFullYear(), 0, 0).getTime(); + + return Math.floor((date.getTime() - yearStart) / 86400000); +} + +export function selectDashboardQuote( + quotes: readonly DashboardEncouragingQuote[], + date: Date, +): DashboardEncouragingQuote | null { + if (quotes.length === 0) { + return null; + } + + return quotes[getDashboardDayOfYear(date) % quotes.length]; +} + +export function toDashboardZoneColor(value: string | null): ZoneColor | null { + if (value === 'blue' || value === 'green' || value === 'yellow' || value === 'red') { + return value; + } + + return null; +} + +export function selectDashboardActiveZone( + localZone: string | null, + savedZone: ZoneColor | null, +): ZoneColor | null { + return toDashboardZoneColor(localZone) ?? savedZone; +} + +export function selectDashboardUpcomingEvents( + events: readonly CommunicationEventDto[], +): readonly CommunicationEventDto[] { + return events.slice(0, DASHBOARD_UPCOMING_EVENTS_LIMIT); +} + +export function selectDashboardQuickActions(userRole: UserRole): readonly DashboardQuickAction[] { + return DASHBOARD_QUICK_ACTIONS.filter((action) => !action.hiddenForRoles?.includes(userRole)); +} diff --git a/frontend/src/business/dashboard/types.ts b/frontend/src/business/dashboard/types.ts new file mode 100644 index 0000000..a0aa0ed --- /dev/null +++ b/frontend/src/business/dashboard/types.ts @@ -0,0 +1,52 @@ +import type { FrameEntryViewModel } from '@/business/frame/types'; +import type { + DashboardQuickAction, + DashboardZoneOption, +} from '@/shared/constants/dashboard'; +import type { + ModuleId, + UserRole, + ZoneColor, +} from '@/shared/types/app'; +import type { CommunicationEventDto } from '@/shared/types/communications'; +import type { + DashboardComplianceItem, + DashboardEncouragingQuote, + DashboardSignOfWeek, +} from '@/shared/types/dashboard'; + +export interface DashboardProps { + readonly userRole: UserRole; + readonly userName: string; + readonly setCurrentModule: (id: ModuleId) => void; + readonly zoneCheckIn: string | null; + readonly setZoneCheckIn: (zone: string | null) => void; +} + +export interface DashboardPage { + readonly userRole: UserRole; + readonly userName: string; + readonly greeting: string; + readonly latestFrame: FrameEntryViewModel | null; + readonly todayQuote: DashboardEncouragingQuote | null; + readonly quoteState: DashboardContentState; + readonly zoneOptions: readonly DashboardZoneOption[]; + readonly activeZone: ZoneColor | null; + readonly isZoneSaving: boolean; + readonly zoneErrorMessage: string | null; + readonly upcomingEvents: readonly CommunicationEventDto[]; + readonly eventsState: DashboardContentState; + readonly complianceItems: readonly DashboardComplianceItem[]; + readonly complianceState: DashboardContentState; + readonly signOfWeek: DashboardSignOfWeek | null; + readonly signOfWeekState: DashboardContentState; + readonly quickActions: readonly DashboardQuickAction[]; + readonly goToModule: (id: ModuleId) => void; + readonly checkInZone: (zone: ZoneColor) => Promise; + readonly resetZone: () => Promise; +} + +export interface DashboardContentState { + readonly isLoading: boolean; + readonly isError: boolean; +} diff --git a/frontend/src/business/director-dashboard/hooks.ts b/frontend/src/business/director-dashboard/hooks.ts new file mode 100644 index 0000000..d8db9c0 --- /dev/null +++ b/frontend/src/business/director-dashboard/hooks.ts @@ -0,0 +1,55 @@ +import { useState } from 'react'; + +import { useFrameEntries } from '@/business/frame/hooks'; +import { useSafetyQuizResults } from '@/business/safety-quiz/hooks'; +import { + useStaffAttendanceRecords, + useStaffAttendanceSummary, +} from '@/business/staff-attendance/hooks'; +import { + buildDirectorFramePreviews, + buildDirectorOverviewCards, + buildDirectorRiskAreas, +} from '@/business/director-dashboard/selectors'; +import type { DirectorDashboardPage } from '@/business/director-dashboard/types'; +import { + DIRECTOR_DASHBOARD_QUICK_ACTIONS, + type DirectorDashboardTimeRange, +} from '@/shared/constants/directorDashboard'; + +export function useDirectorDashboardPage(): DirectorDashboardPage { + const [timeRange, setTimeRangeState] = useState('month'); + const frameEntriesQuery = useFrameEntries(); + const quizResultsQuery = useSafetyQuizResults(); + const staffAttendanceRecordsQuery = useStaffAttendanceRecords(); + const staffAttendanceSummaryQuery = useStaffAttendanceSummary(); + const frameEntries = frameEntriesQuery.data ?? []; + const quizResults = quizResultsQuery.data ?? []; + const attendanceRecords = staffAttendanceRecordsQuery.data ?? []; + const staffCount = staffAttendanceSummaryQuery.data?.staffCount ?? 0; + const isLoading = frameEntriesQuery.isLoading + || quizResultsQuery.isLoading + || staffAttendanceRecordsQuery.isLoading + || staffAttendanceSummaryQuery.isLoading; + const error = frameEntriesQuery.error + ?? quizResultsQuery.error + ?? staffAttendanceRecordsQuery.error + ?? staffAttendanceSummaryQuery.error; + + return { + timeRange, + overviewCards: buildDirectorOverviewCards( + attendanceRecords, + quizResults, + frameEntries, + staffCount, + ), + riskAreas: buildDirectorRiskAreas(attendanceRecords, quizResults, staffCount), + framePreviews: buildDirectorFramePreviews(frameEntries), + quickActions: DIRECTOR_DASHBOARD_QUICK_ACTIONS, + quizResults, + isLoading, + error, + setTimeRange: setTimeRangeState, + }; +} diff --git a/frontend/src/business/director-dashboard/selectors.test.ts b/frontend/src/business/director-dashboard/selectors.test.ts new file mode 100644 index 0000000..b872533 --- /dev/null +++ b/frontend/src/business/director-dashboard/selectors.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from 'vitest'; + +import { + buildDirectorFramePreviews, + buildDirectorOverviewCards, + buildDirectorRiskAreas, + calculateQuizCompletionRate, +} from '@/business/director-dashboard/selectors'; +import type { FrameEntryViewModel } from '@/business/frame/types'; +import type { StaffAttendanceRecordViewModel } from '@/business/staff-attendance/types'; +import type { SafetyQuizResultDto } from '@/shared/types/safetyQuiz'; + +function createAttendanceRecord( + overrides: Partial = {}, +): StaffAttendanceRecordViewModel { + return { + id: 'attendance-1', + date: '2026-06-01', + status: 'present', + note: null, + userName: 'Ava Lee', + userRole: 'teacher', + ...overrides, + }; +} + +function createQuizResult(overrides: Partial = {}): SafetyQuizResultDto { + return { + id: 'quiz-1', + quiz_id: 'qbs', + quiz_title: 'QBS Safety', + week_of: '2026-06-01', + score: 5, + total_questions: 5, + answers: [0, 1, 2], + user_name: 'Ava Lee', + user_role: 'teacher', + completed_at: '2026-06-01T09:00:00.000Z', + organizationId: 'org-1', + campusId: 'campus-1', + userId: 'user-1', + createdAt: '2026-06-01T09:00:00.000Z', + updatedAt: '2026-06-01T09:00:00.000Z', + ...overrides, + }; +} + +function createFrameEntry(overrides: Partial = {}): FrameEntryViewModel { + return { + id: 'frame-1', + weekOf: '2026-06-01', + postedDate: 'June 1, 2026', + formal: 'Formal learning focus for the week', + recognition: 'Recognition focus for the week', + application: 'Application focus for the week', + management: 'Management focus for the week', + emotional: 'Emotional learning focus for the week', + author: 'Director', + ...overrides, + }; +} + +describe('director dashboard selectors', () => { + it('calculates quiz completion rate with empty staff protection', () => { + expect(calculateQuizCompletionRate([createQuizResult()], 0)).toBe(0); + expect(calculateQuizCompletionRate([createQuizResult()], 4)).toBe(25); + }); + + it('builds overview cards from backend-backed records', () => { + const cards = buildDirectorOverviewCards( + [ + createAttendanceRecord({ id: 'present', status: 'present' }), + createAttendanceRecord({ id: 'absent', status: 'absent' }), + ], + [createQuizResult()], + [createFrameEntry()], + 2, + ); + + expect(cards.map((card) => card.value)).toEqual(['50%', '1/2', '1', '2']); + expect(cards.map((card) => card.module)).toEqual(['attendance', 'qbs', 'frame', 'attendance']); + }); + + it('flags dashboard risk levels from quiz completion and absences', () => { + const risks = buildDirectorRiskAreas( + [ + createAttendanceRecord({ id: '1', status: 'absent' }), + createAttendanceRecord({ id: '2', status: 'absent' }), + createAttendanceRecord({ id: '3', status: 'absent' }), + createAttendanceRecord({ id: '4', status: 'absent' }), + ], + [createQuizResult()], + 6, + ); + + expect(risks).toEqual([ + { + issue: "5 staff haven't completed de-escalation quiz", + severity: 'high', + module: 'qbs', + }, + { + issue: '4 absences recorded this period', + severity: 'high', + module: 'attendance', + }, + ]); + }); + + it('limits and truncates FRAME previews', () => { + const longText = 'A'.repeat(70); + const previews = buildDirectorFramePreviews([ + createFrameEntry({ id: '1', formal: longText }), + createFrameEntry({ id: '2' }), + createFrameEntry({ id: '3' }), + createFrameEntry({ id: '4' }), + ]); + + expect(previews).toHaveLength(3); + expect(previews[0].sections[0]).toEqual({ + letter: 'F', + text: `${'A'.repeat(60)}...`, + }); + }); +}); diff --git a/frontend/src/business/director-dashboard/selectors.ts b/frontend/src/business/director-dashboard/selectors.ts new file mode 100644 index 0000000..4742804 --- /dev/null +++ b/frontend/src/business/director-dashboard/selectors.ts @@ -0,0 +1,125 @@ +import { + DIRECTOR_DASHBOARD_COMPLETION_TREND_THRESHOLD, + DIRECTOR_DASHBOARD_FRAME_PREVIEW_LIMIT, + DIRECTOR_DASHBOARD_HIGH_ABSENCE_THRESHOLD, + DIRECTOR_DASHBOARD_HIGH_INCOMPLETE_STAFF_THRESHOLD, + DIRECTOR_DASHBOARD_TEXT_PREVIEW_LENGTH, +} from '@/shared/constants/directorDashboard'; +import { + countStaffAttendanceStatus, + staffAttendanceRate, +} from '@/business/staff-attendance/selectors'; +import type { FrameEntryViewModel } from '@/business/frame/types'; +import type { StaffAttendanceRecordViewModel } from '@/business/staff-attendance/types'; +import type { SafetyQuizResultDto } from '@/shared/types/safetyQuiz'; +import type { + DirectorFramePreview, + DirectorOverviewCard, + DirectorRiskArea, +} from '@/business/director-dashboard/types'; + +export function calculateQuizCompletionRate( + quizResults: readonly SafetyQuizResultDto[], + staffCount: number, +): number { + if (staffCount <= 0) { + return 0; + } + + return Math.round((quizResults.length / staffCount) * 100); +} + +export function buildDirectorOverviewCards( + attendanceRecords: readonly StaffAttendanceRecordViewModel[], + quizResults: readonly SafetyQuizResultDto[], + frameEntries: readonly FrameEntryViewModel[], + staffCount: number, +): readonly DirectorOverviewCard[] { + const attendanceRate = staffAttendanceRate(attendanceRecords); + const quizCompletionRate = calculateQuizCompletionRate(quizResults, staffCount); + + return [ + { + label: 'Staff Attendance', + value: `${attendanceRate}%`, + change: `${attendanceRecords.length} records`, + trend: 'up', + iconId: 'clock', + tone: 'orange', + module: 'attendance', + }, + { + label: 'De-escalation Completion', + value: `${quizResults.length}/${staffCount}`, + change: `${quizCompletionRate}%`, + trend: quizCompletionRate > DIRECTOR_DASHBOARD_COMPLETION_TREND_THRESHOLD ? 'up' : 'down', + iconId: 'shield', + tone: 'blue', + module: 'qbs', + }, + { + label: 'F.R.A.M.E. Entries', + value: frameEntries.length.toString(), + change: 'Total entries', + trend: 'up', + iconId: 'eye', + tone: 'amber', + module: 'frame', + }, + { + label: 'Staff Members', + value: staffCount.toString(), + change: 'Active', + trend: 'up', + iconId: 'users', + tone: 'purple', + module: 'attendance', + }, + ]; +} + +export function buildDirectorRiskAreas( + attendanceRecords: readonly StaffAttendanceRecordViewModel[], + quizResults: readonly SafetyQuizResultDto[], + staffCount: number, +): readonly DirectorRiskArea[] { + const incompleteStaffCount = Math.max(staffCount - quizResults.length, 0); + const absenceCount = countStaffAttendanceStatus(attendanceRecords, 'absent'); + + return [ + { + issue: `${incompleteStaffCount} staff haven't completed de-escalation quiz`, + severity: incompleteStaffCount > DIRECTOR_DASHBOARD_HIGH_INCOMPLETE_STAFF_THRESHOLD ? 'high' : 'medium', + module: 'qbs', + }, + { + issue: `${absenceCount} absences recorded this period`, + severity: absenceCount > DIRECTOR_DASHBOARD_HIGH_ABSENCE_THRESHOLD ? 'high' : 'low', + module: 'attendance', + }, + ]; +} + +export function buildDirectorFramePreviews( + frameEntries: readonly FrameEntryViewModel[], +): readonly DirectorFramePreview[] { + return frameEntries.slice(0, DIRECTOR_DASHBOARD_FRAME_PREVIEW_LIMIT).map((entry) => ({ + id: entry.id, + week: entry.weekOf, + sections: [ + { letter: 'F', text: truncatePreview(entry.formal) }, + { letter: 'R', text: truncatePreview(entry.recognition) }, + { letter: 'A', text: truncatePreview(entry.application) }, + { letter: 'M', text: truncatePreview(entry.management) }, + { letter: 'E', text: truncatePreview(entry.emotional) }, + ], + })); +} + +function truncatePreview(value: string): string { + if (value.length <= DIRECTOR_DASHBOARD_TEXT_PREVIEW_LENGTH) { + return value; + } + + return `${value.slice(0, DIRECTOR_DASHBOARD_TEXT_PREVIEW_LENGTH)}...`; +} diff --git a/frontend/src/business/director-dashboard/types.ts b/frontend/src/business/director-dashboard/types.ts new file mode 100644 index 0000000..08bb3fc --- /dev/null +++ b/frontend/src/business/director-dashboard/types.ts @@ -0,0 +1,51 @@ +import type { + DirectorDashboardTimeRange, + DirectorQuickActionConfig, +} from '@/shared/constants/directorDashboard'; +import type { ModuleId } from '@/shared/types/app'; +import type { SafetyQuizResultDto } from '@/shared/types/safetyQuiz'; + +export type DirectorDashboardTrend = 'up' | 'down'; +export type DirectorDashboardRiskSeverity = 'high' | 'medium' | 'low'; +export type DirectorOverviewIconId = 'clock' | 'shield' | 'eye' | 'users'; +export type DirectorOverviewTone = 'orange' | 'blue' | 'amber' | 'purple'; +export type DirectorFrameSectionLetter = 'F' | 'R' | 'A' | 'M' | 'E'; + +export interface DirectorOverviewCard { + readonly label: string; + readonly value: string; + readonly change: string; + readonly trend: DirectorDashboardTrend; + readonly iconId: DirectorOverviewIconId; + readonly tone: DirectorOverviewTone; + readonly module: ModuleId; +} + +export interface DirectorRiskArea { + readonly issue: string; + readonly severity: DirectorDashboardRiskSeverity; + readonly module: ModuleId; +} + +export interface DirectorFrameSectionPreview { + readonly letter: DirectorFrameSectionLetter; + readonly text: string; +} + +export interface DirectorFramePreview { + readonly id: string; + readonly week: string; + readonly sections: readonly DirectorFrameSectionPreview[]; +} + +export interface DirectorDashboardPage { + readonly timeRange: DirectorDashboardTimeRange; + readonly overviewCards: readonly DirectorOverviewCard[]; + readonly riskAreas: readonly DirectorRiskArea[]; + readonly framePreviews: readonly DirectorFramePreview[]; + readonly quickActions: readonly DirectorQuickActionConfig[]; + readonly quizResults: readonly SafetyQuizResultDto[]; + readonly isLoading: boolean; + readonly error: unknown; + readonly setTimeRange: (timeRange: DirectorDashboardTimeRange) => void; +} diff --git a/frontend/src/business/esa-funding/hooks.ts b/frontend/src/business/esa-funding/hooks.ts new file mode 100644 index 0000000..dd61313 --- /dev/null +++ b/frontend/src/business/esa-funding/hooks.ts @@ -0,0 +1,38 @@ +import { useState } from 'react'; + +import { useContentCatalogPayload } from '@/business/content-catalog/hooks'; +import { toggleEsaFaq } from '@/business/esa-funding/selectors'; +import type { EsaFundingPage } from '@/business/esa-funding/types'; +import { CONTENT_CATALOG_TYPES } from '@/shared/constants/contentCatalog'; +import { ESA_FUNDING_FAQS } from '@/shared/constants/esaFunding'; +import type { EsaFundingContent } from '@/shared/types/esaFunding'; + +export const EMPTY_ESA_FUNDING_CONTENT: EsaFundingContent = { + approvedUses: [], + keyPoints: [], + stateChecklist: [], + schoolImpactItems: [], + staffRoleItems: [], + parentConversationScript: '', + resources: [], +}; + +export function useEsaFundingPage(): EsaFundingPage { + const contentQuery = useContentCatalogPayload( + CONTENT_CATALOG_TYPES.esaFundingContent, + EMPTY_ESA_FUNDING_CONTENT, + ); + const [expandedFAQ, setExpandedFAQ] = useState(0); + const [acknowledged, setAcknowledged] = useState(false); + + return { + content: contentQuery.payload, + faqs: ESA_FUNDING_FAQS, + expandedFAQ, + acknowledged, + isLoading: contentQuery.isLoading, + error: contentQuery.error, + toggleFAQ: (index) => setExpandedFAQ((currentIndex) => toggleEsaFaq(currentIndex, index)), + toggleAcknowledged: () => setAcknowledged((current) => !current), + }; +} diff --git a/frontend/src/business/esa-funding/selectors.test.ts b/frontend/src/business/esa-funding/selectors.test.ts new file mode 100644 index 0000000..71910ef --- /dev/null +++ b/frontend/src/business/esa-funding/selectors.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; + +import { + isValidEsaResourceUrl, + toggleEsaFaq, +} from '@/business/esa-funding/selectors'; + +describe('ESA funding selectors', () => { + it('toggles FAQ expansion', () => { + expect(toggleEsaFaq(0, 0)).toBeNull(); + expect(toggleEsaFaq(0, 1)).toBe(1); + expect(toggleEsaFaq(null, 1)).toBe(1); + }); + + it('validates resource URLs', () => { + expect(isValidEsaResourceUrl('https://example.com')).toBe(true); + expect(isValidEsaResourceUrl('http://example.com')).toBe(true); + expect(isValidEsaResourceUrl('#')).toBe(false); + expect(isValidEsaResourceUrl('mailto:test@example.com')).toBe(false); + expect(isValidEsaResourceUrl('not a url')).toBe(false); + }); +}); diff --git a/frontend/src/business/esa-funding/selectors.ts b/frontend/src/business/esa-funding/selectors.ts new file mode 100644 index 0000000..62187d6 --- /dev/null +++ b/frontend/src/business/esa-funding/selectors.ts @@ -0,0 +1,17 @@ +export function toggleEsaFaq(currentIndex: number | null, nextIndex: number): number | null { + return currentIndex === nextIndex ? null : nextIndex; +} + +export function isValidEsaResourceUrl(url: string): boolean { + if (!url || url === '#') { + return false; + } + + try { + const parsedUrl = new URL(url); + + return parsedUrl.protocol === 'https:' || parsedUrl.protocol === 'http:'; + } catch { + return false; + } +} diff --git a/frontend/src/business/esa-funding/types.ts b/frontend/src/business/esa-funding/types.ts new file mode 100644 index 0000000..7df5f80 --- /dev/null +++ b/frontend/src/business/esa-funding/types.ts @@ -0,0 +1,13 @@ +import type { EsaFundingContent } from '@/shared/types/esaFunding'; +import type { EsaFaqItem } from '@/shared/types/esaFunding'; + +export interface EsaFundingPage { + readonly content: EsaFundingContent; + readonly faqs: readonly EsaFaqItem[]; + readonly expandedFAQ: number | null; + readonly acknowledged: boolean; + readonly isLoading: boolean; + readonly error: Error | null; + readonly toggleFAQ: (index: number) => void; + readonly toggleAcknowledged: () => void; +} diff --git a/frontend/src/business/frame/hooks.ts b/frontend/src/business/frame/hooks.ts new file mode 100644 index 0000000..6fdec60 --- /dev/null +++ b/frontend/src/business/frame/hooks.ts @@ -0,0 +1,170 @@ +import { useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { + createFrameEntry, + listFrameEntries, + updateFrameEntry, +} from '@/shared/api/frame'; +import { FRAME_QUERY_KEYS } from '@/shared/constants/frame'; +import { FrameSectionKey } from '@/shared/types/frame'; +import { + EditableFrameEntry, + FrameEntryDraft, + FrameEntryViewModel, +} from '@/business/frame/types'; +import { + toFrameEntryMutationDto, + toFrameEntryViewModel, +} from '@/business/frame/mappers'; +import { canEditFrameEntries } from '@/business/frame/selectors'; +import { UserRole } from '@/shared/types/app'; +import { mapApiListRows } from '@/shared/business/apiListRows'; +import { useInvalidatingMutation } from '@/shared/business/queryMutations'; + +const EMPTY_FRAME_ENTRIES: readonly FrameEntryViewModel[] = []; + +function createEmptyDraft(author: string): FrameEntryDraft { + return { + weekOf: '', + postedDate: new Date().toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + }), + formal: '', + recognition: '', + application: '', + management: '', + emotional: '', + author, + }; +} + +function isValidDraft(entry: EditableFrameEntry): boolean { + return Boolean( + entry.weekOf.trim() + && entry.postedDate.trim() + && entry.formal.trim() + && entry.recognition.trim() + && entry.application.trim() + && entry.management.trim() + && entry.emotional.trim() + && entry.author.trim(), + ); +} + +export function useFrameEntries() { + return useQuery({ + queryKey: FRAME_QUERY_KEYS.entries, + queryFn: () => mapApiListRows(listFrameEntries(), toFrameEntryViewModel), + }); +} + +export function useFrameModule(userRole: UserRole, userName: string) { + const canEdit = canEditFrameEntries(userRole); + const authorName = userName.trim(); + const [expandedIdState, setExpandedId] = useState(''); + const [isEditing, setIsEditing] = useState(false); + const [editEntry, setEditEntry] = useState(null); + const [showNewForm, setShowNewForm] = useState(false); + const [newEntry, setNewEntry] = useState(() => createEmptyDraft(authorName)); + const [formError, setFormError] = useState(null); + + const entriesQuery = useFrameEntries(); + + const createMutation = useInvalidatingMutation({ + mutationFn: (entry: EditableFrameEntry) => createFrameEntry(toFrameEntryMutationDto(entry)), + invalidateQueryKey: FRAME_QUERY_KEYS.entries, + onSuccess: (createdEntry) => { + setExpandedId(createdEntry.id); + setShowNewForm(false); + setNewEntry(createEmptyDraft(authorName)); + setFormError(null); + }, + }); + + const updateMutation = useInvalidatingMutation({ + mutationFn: (entry: FrameEntryViewModel) => updateFrameEntry(entry.id, toFrameEntryMutationDto(entry)), + invalidateQueryKey: FRAME_QUERY_KEYS.entries, + onSuccess: (updatedEntry) => { + setExpandedId(updatedEntry.id); + setIsEditing(false); + setEditEntry(null); + setFormError(null); + }, + }); + + const entries = entriesQuery.data ?? EMPTY_FRAME_ENTRIES; + const expandedId = expandedIdState || entries[0]?.id || ''; + + function updateNewEntryField(key: keyof FrameEntryDraft, value: string) { + setNewEntry((current) => ({ ...current, [key]: value })); + } + + function updateNewEntrySection(key: FrameSectionKey, value: string) { + setNewEntry((current) => ({ ...current, [key]: value })); + } + + function updateEditEntrySection(key: FrameSectionKey, value: string) { + setEditEntry((current) => current ? { ...current, [key]: value } : current); + } + + function startEditing(entry: FrameEntryViewModel) { + setIsEditing(true); + setEditEntry(entry); + setFormError(null); + } + + function cancelEditing() { + setIsEditing(false); + setEditEntry(null); + setFormError(null); + } + + async function saveNewEntry() { + if (!isValidDraft(newEntry)) { + setFormError('Complete every F.R.A.M.E. field before publishing.'); + return; + } + + await createMutation.mutateAsync(newEntry); + } + + async function saveEditEntry() { + if (!editEntry) { + return; + } + + if (!isValidDraft(editEntry)) { + setFormError('Complete every F.R.A.M.E. field before saving.'); + return; + } + + await updateMutation.mutateAsync(editEntry); + } + + return { + entries, + expandedId, + isEditing, + editEntry, + showNewForm, + newEntry, + formError, + canEdit, + isLoading: entriesQuery.isLoading, + isRefreshing: entriesQuery.isFetching, + isSaving: createMutation.isPending || updateMutation.isPending, + error: entriesQuery.error || createMutation.error || updateMutation.error, + setExpandedId, + setShowNewForm, + updateNewEntryField, + updateNewEntrySection, + updateEditEntrySection, + startEditing, + cancelEditing, + saveNewEntry, + saveEditEntry, + refresh: entriesQuery.refetch, + }; +} diff --git a/frontend/src/business/frame/mappers.test.ts b/frontend/src/business/frame/mappers.test.ts new file mode 100644 index 0000000..34627aa --- /dev/null +++ b/frontend/src/business/frame/mappers.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest'; +import { + toFrameEntryMutationDto, + toFrameEntryViewModel, +} from '@/business/frame/mappers'; +import type { EditableFrameEntry } from '@/business/frame/types'; +import type { FrameEntryDto } from '@/shared/types/frame'; + +describe('frame mappers', () => { + it('maps backend FRAME DTO fields into the frontend view model shape', () => { + const dto: FrameEntryDto = { + id: 'frame-1', + week_of: '2026-06-08', + posted_date: '2026-06-08', + formal: 'Formal note', + recognition: 'Recognition note', + application: 'Application note', + management: 'Management note', + emotional: 'Emotional note', + author: 'Director', + organizationId: 'org-1', + campusId: 'campus-1', + createdAt: '2026-06-08T10:00:00.000Z', + updatedAt: '2026-06-08T10:00:00.000Z', + }; + + expect(toFrameEntryViewModel(dto)).toEqual({ + id: 'frame-1', + weekOf: '2026-06-08', + postedDate: '2026-06-08', + formal: 'Formal note', + recognition: 'Recognition note', + application: 'Application note', + management: 'Management note', + emotional: 'Emotional note', + author: 'Director', + }); + }); + + it('maps editable FRAME state back into the backend mutation DTO shape', () => { + const entry: EditableFrameEntry = { + weekOf: '2026-06-08', + postedDate: '2026-06-09', + formal: 'Formal', + recognition: 'Recognition', + application: 'Application', + management: 'Management', + emotional: 'Emotional', + author: 'Office Manager', + }; + + expect(toFrameEntryMutationDto(entry)).toEqual({ + week_of: '2026-06-08', + posted_date: '2026-06-09', + formal: 'Formal', + recognition: 'Recognition', + application: 'Application', + management: 'Management', + emotional: 'Emotional', + author: 'Office Manager', + }); + }); +}); diff --git a/frontend/src/business/frame/mappers.ts b/frontend/src/business/frame/mappers.ts new file mode 100644 index 0000000..0e460c5 --- /dev/null +++ b/frontend/src/business/frame/mappers.ts @@ -0,0 +1,29 @@ +import { FrameEntryDto, FrameEntryMutationDto } from '@/shared/types/frame'; +import { EditableFrameEntry, FrameEntryViewModel } from '@/business/frame/types'; + +export function toFrameEntryViewModel(dto: FrameEntryDto): FrameEntryViewModel { + return { + id: dto.id, + weekOf: dto.week_of, + postedDate: dto.posted_date, + formal: dto.formal, + recognition: dto.recognition, + application: dto.application, + management: dto.management, + emotional: dto.emotional, + author: dto.author, + }; +} + +export function toFrameEntryMutationDto(entry: EditableFrameEntry): FrameEntryMutationDto { + return { + week_of: entry.weekOf, + posted_date: entry.postedDate, + formal: entry.formal, + recognition: entry.recognition, + application: entry.application, + management: entry.management, + emotional: entry.emotional, + author: entry.author, + }; +} diff --git a/frontend/src/business/frame/selectors.test.ts b/frontend/src/business/frame/selectors.test.ts new file mode 100644 index 0000000..98f404f --- /dev/null +++ b/frontend/src/business/frame/selectors.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest'; +import { canEditFrameEntries } from '@/business/frame/selectors'; +import type { UserRole } from '@/shared/types/app'; + +describe('frame selectors', () => { + it('allows director and superintendent users to edit FRAME entries', () => { + const editorRoles: readonly UserRole[] = ['director', 'superintendent']; + + expect(editorRoles.map((role) => canEditFrameEntries(role))).toEqual([true, true]); + }); + + it('keeps staff roles read-only for FRAME entries', () => { + const readOnlyRoles: readonly UserRole[] = ['teacher', 'para', 'office']; + + expect(readOnlyRoles.map((role) => canEditFrameEntries(role))).toEqual([false, false, false]); + }); +}); diff --git a/frontend/src/business/frame/selectors.ts b/frontend/src/business/frame/selectors.ts new file mode 100644 index 0000000..ef114d7 --- /dev/null +++ b/frontend/src/business/frame/selectors.ts @@ -0,0 +1,5 @@ +import { UserRole } from '@/shared/types/app'; + +export function canEditFrameEntries(userRole: UserRole): boolean { + return userRole === 'director' || userRole === 'superintendent'; +} diff --git a/frontend/src/business/frame/types.ts b/frontend/src/business/frame/types.ts new file mode 100644 index 0000000..4ae973c --- /dev/null +++ b/frontend/src/business/frame/types.ts @@ -0,0 +1,19 @@ +import { FrameSectionKey } from '@/shared/types/frame'; + +export interface FrameEntryViewModel { + readonly id: string; + readonly weekOf: string; + readonly postedDate: string; + readonly formal: string; + readonly recognition: string; + readonly application: string; + readonly management: string; + readonly emotional: string; + readonly author: string; +} + +export type EditableFrameEntry = Omit; + +export type FrameEntryDraft = EditableFrameEntry; + +export type FrameSectionValues = Pick; diff --git a/frontend/src/business/personality/directoryHooks.ts b/frontend/src/business/personality/directoryHooks.ts new file mode 100644 index 0000000..f075741 --- /dev/null +++ b/frontend/src/business/personality/directoryHooks.ts @@ -0,0 +1,67 @@ +import { useMemo, useState } from 'react'; +import { useContentCatalogPayload } from '@/business/content-catalog/hooks'; +import type { + PersonalityDirectoryFilterGroup, + PersonalityDirectoryPage, + PersonalityDirectorySection, +} from '@/business/personality/types'; +import { + filterPersonalityDirectoryTypes, + getPersonalityDirectoryGroupDescription, +} from '@/business/personality/selectors'; +import type { PersonalityType } from '@/shared/constants/personalityCatalog'; +import { CONTENT_CATALOG_TYPES } from '@/shared/constants/contentCatalog'; + +export function usePersonalityDirectoryPage(highlightType?: string | null): PersonalityDirectoryPage { + const personalityTypesQuery = useContentCatalogPayload( + CONTENT_CATALOG_TYPES.personalityTypes, + [], + ); + const [expandedType, setExpandedType] = useState(highlightType || null); + const [searchQuery, setSearchQuery] = useState(''); + const [filterGroup, setFilterGroup] = useState('all'); + const [expandedSections, setExpandedSections] = useState>>({}); + const personalityTypes = personalityTypesQuery.payload; + const filteredTypes = useMemo( + () => filterPersonalityDirectoryTypes(personalityTypes, searchQuery, filterGroup), + [filterGroup, personalityTypes, searchQuery], + ); + + function toggleExpand(code: string) { + setExpandedType((current) => (current === code ? null : code)); + } + + function getActiveSection(code: string): PersonalityDirectorySection { + return expandedSections[code] || 'overview'; + } + + function setActiveSection(code: string, section: PersonalityDirectorySection) { + setExpandedSections((current) => ({ + ...current, + [code]: section, + })); + } + + function clearFilters() { + setSearchQuery(''); + setFilterGroup('all'); + } + + return { + personalityTypes, + filteredTypes, + expandedType, + searchQuery, + filterGroup, + expandedSections, + isLoading: personalityTypesQuery.isLoading, + error: personalityTypesQuery.error, + groupDescription: getPersonalityDirectoryGroupDescription(filterGroup), + setSearchQuery, + setFilterGroup, + toggleExpand, + getActiveSection, + setActiveSection, + clearFilters, + }; +} diff --git a/frontend/src/business/personality/emotionalIntelligenceHooks.ts b/frontend/src/business/personality/emotionalIntelligenceHooks.ts new file mode 100644 index 0000000..f13c1fd --- /dev/null +++ b/frontend/src/business/personality/emotionalIntelligenceHooks.ts @@ -0,0 +1,174 @@ +import { useMemo, useState } from 'react'; +import { useContentCatalogPayload } from '@/business/content-catalog/hooks'; +import { + useCurrentPersonalityResult, + usePersonalityDistribution, + useSaveCurrentPersonalityResult, +} from '@/business/personality/queryHooks'; +import { + getEmotionalIntelligenceLevel, + getPersonalityGroup, + groupPersonalityDistribution, + totalPersonalityDistribution, +} from '@/business/personality/selectors'; +import { CONTENT_CATALOG_TYPES } from '@/shared/constants/contentCatalog'; +import type { UserRole } from '@/shared/types/app'; +import type { + EmotionalIntelligenceQuestion, + EmotionalIntelligenceTab, + EmotionalIntelligenceTopic, + EmotionalIntelligenceWeeklyFocus, + PersonalityWorkplaceContent, + TeamWellnessMetric, +} from '@/shared/types/emotionalIntelligence'; +import type { PersonalityDistributionDto } from '@/shared/types/personality'; +import type { PersonalityType } from '@/shared/constants/personalityCatalog'; +import { getOptionalErrorMessage } from '@/shared/errors/errorMessages'; +import { getFirstQueryError, isAnyLoading } from '@/shared/business/queryState'; + +const EMPTY_PERSONALITY_DISTRIBUTION: readonly PersonalityDistributionDto[] = []; + +export function useEmotionalIntelligencePage(userRole: UserRole) { + const assessmentQuestionsQuery = useContentCatalogPayload( + CONTENT_CATALOG_TYPES.emotionalIntelligenceAssessmentQuestions, + [], + ); + const weeklyTopicsQuery = useContentCatalogPayload( + CONTENT_CATALOG_TYPES.emotionalIntelligenceWeeklyTopics, + [], + ); + const growthTipsQuery = useContentCatalogPayload( + CONTENT_CATALOG_TYPES.emotionalIntelligenceGrowthTips, + [], + ); + const teamWellnessMetricsQuery = useContentCatalogPayload( + CONTENT_CATALOG_TYPES.emotionalIntelligenceTeamWellnessMetrics, + [], + ); + const weeklyFocusQuery = useContentCatalogPayload( + CONTENT_CATALOG_TYPES.emotionalIntelligenceWeeklyFocus, + null, + ); + const personalityWorkplaceContentQuery = useContentCatalogPayload( + CONTENT_CATALOG_TYPES.personalityWorkplaceContent, + null, + ); + const personalityTypesQuery = useContentCatalogPayload( + CONTENT_CATALOG_TYPES.personalityTypes, + [], + ); + const [activeTab, setActiveTab] = useState('assessment'); + const [assessmentStarted, setAssessmentStarted] = useState(false); + const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0); + const [answers, setAnswers] = useState([]); + const [assessmentComplete, setAssessmentComplete] = useState(false); + + const currentPersonalityQuery = useCurrentPersonalityResult(); + const savePersonalityMutation = useSaveCurrentPersonalityResult(); + const canViewPersonalityDistribution = userRole === 'director' || userRole === 'superintendent'; + const personalityDistributionQuery = usePersonalityDistribution(undefined, canViewPersonalityDistribution); + + const savedPersonality = currentPersonalityQuery.data; + const personalityResult = savedPersonality?.personalityType ?? null; + const savedAnswers = savedPersonality?.quizAnswers ?? null; + const savedDate = savedPersonality?.updatedAt ?? null; + const distribution = personalityDistributionQuery.data ?? EMPTY_PERSONALITY_DISTRIBUTION; + const distributionTotal = totalPersonalityDistribution(distribution); + const groupDistribution = useMemo( + () => groupPersonalityDistribution(distribution, getPersonalityGroup), + [distribution], + ); + const totalScore = answers.reduce((sum, score) => sum + score, 0); + const assessmentQuestions = assessmentQuestionsQuery.payload; + const maxScore = assessmentQuestions.length * 4; + const percentage = maxScore > 0 ? Math.round((totalScore / maxScore) * 100) : 0; + const assessmentLevel = getEmotionalIntelligenceLevel(percentage); + const personalityError = currentPersonalityQuery.error ?? savePersonalityMutation.error; + const distributionError = personalityDistributionQuery.error; + const contentQueries = [ + assessmentQuestionsQuery, + weeklyTopicsQuery, + growthTipsQuery, + teamWellnessMetricsQuery, + weeklyFocusQuery, + personalityWorkplaceContentQuery, + personalityTypesQuery, + ]; + + const handleAnswer = (scoreIndex: number) => { + const question = assessmentQuestions[currentQuestionIndex]; + if (!question) { + return; + } + const nextAnswers = [...answers, question.scores[scoreIndex]]; + setAnswers(nextAnswers); + + if (currentQuestionIndex < assessmentQuestions.length - 1) { + setCurrentQuestionIndex((index) => index + 1); + return; + } + + setAssessmentComplete(true); + }; + + const resetAssessment = () => { + setAssessmentStarted(false); + setCurrentQuestionIndex(0); + setAnswers([]); + setAssessmentComplete(false); + }; + + const handlePersonalityResult = async (code: string, quizAnswers: Record) => { + await savePersonalityMutation.mutateAsync({ + personalityType: code, + quizAnswers, + }); + }; + + return { + state: { + activeTab, + assessmentStarted, + currentQuestionIndex, + answers, + assessmentComplete, + totalScore, + maxScore, + percentage, + assessmentLevel, + currentPersonalityQuery, + personalityDistributionQuery, + canViewPersonalityDistribution, + savedPersonality, + personalityResult, + savedAnswers, + savedDate, + isSaving: savePersonalityMutation.isPending, + isLoadingSaved: currentPersonalityQuery.isLoading, + distribution, + distributionLoading: personalityDistributionQuery.isLoading || personalityDistributionQuery.isFetching, + distributionTotal, + groupDistribution, + errorMessage: getOptionalErrorMessage(personalityError), + distributionErrorMessage: getOptionalErrorMessage(distributionError), + isDirector: userRole === 'director', + assessmentQuestions, + weeklyTopics: weeklyTopicsQuery.payload, + growthTips: growthTipsQuery.payload, + teamWellnessMetrics: teamWellnessMetricsQuery.payload, + weeklyFocus: weeklyFocusQuery.payload, + personalityWorkplaceContent: personalityWorkplaceContentQuery.payload, + personalityTypes: personalityTypesQuery.payload, + contentLoading: isAnyLoading(...contentQueries), + contentError: getFirstQueryError(...contentQueries), + }, + actions: { + setActiveTab, + setAssessmentStarted, + handleAnswer, + resetAssessment, + handlePersonalityResult, + refreshDistribution: () => personalityDistributionQuery.refetch(), + }, + }; +} diff --git a/frontend/src/business/personality/mappers.test.ts b/frontend/src/business/personality/mappers.test.ts new file mode 100644 index 0000000..5d7a548 --- /dev/null +++ b/frontend/src/business/personality/mappers.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest'; +import { + toPersonalityQuizResultMutationDto, + toPersonalityQuizResultViewModel, +} from '@/business/personality/mappers'; +import type { PersonalityQuizSubmission } from '@/business/personality/types'; +import type { PersonalityQuizResultDto } from '@/shared/types/personality'; + +describe('personality mappers', () => { + it('maps a null backend result to a null view model', () => { + expect(toPersonalityQuizResultViewModel(null)).toBeNull(); + }); + + it('maps backend quiz result DTO fields into the frontend view model shape', () => { + const dto: PersonalityQuizResultDto = { + id: 'personality-1', + personality_type: 'INFJ', + quiz_answers: { + '1': 'I', + '2': 'N', + '3': 'F', + '4': 'J', + }, + completed_at: '2026-06-08T08:00:00.000Z', + organizationId: 'org-1', + campusId: 'campus-1', + userId: 'user-1', + createdById: 'user-1', + updatedById: null, + createdAt: '2026-06-08T08:00:00.000Z', + updatedAt: '2026-06-08T09:00:00.000Z', + }; + + expect(toPersonalityQuizResultViewModel(dto)).toEqual({ + personalityType: 'INFJ', + quizAnswers: { + 1: 'I', + 2: 'N', + 3: 'F', + 4: 'J', + }, + updatedAt: '2026-06-08T09:00:00.000Z', + }); + }); + + it('maps quiz submission state back into the backend mutation DTO shape', () => { + const submission: PersonalityQuizSubmission = { + personalityType: 'ESTP', + quizAnswers: { + 1: 'E', + 2: 'S', + 3: 'T', + 4: 'P', + }, + }; + + expect(toPersonalityQuizResultMutationDto(submission)).toEqual({ + personality_type: 'ESTP', + quiz_answers: { + '1': 'E', + '2': 'S', + '3': 'T', + '4': 'P', + }, + }); + }); +}); diff --git a/frontend/src/business/personality/mappers.ts b/frontend/src/business/personality/mappers.ts new file mode 100644 index 0000000..b58a6e8 --- /dev/null +++ b/frontend/src/business/personality/mappers.ts @@ -0,0 +1,45 @@ +import type { + PersonalityQuizResultDto, + PersonalityQuizResultMutationDto, +} from '@/shared/types/personality'; +import type { + PersonalityQuizResultViewModel, + PersonalityQuizSubmission, +} from '@/business/personality/types'; + +function toNumericAnswerMap(answers: Record): Record { + return Object.entries(answers).reduce>((result, [key, value]) => { + result[Number(key)] = value; + return result; + }, {}); +} + +function toStringAnswerMap(answers: Record): Record { + return Object.entries(answers).reduce>((result, [key, value]) => { + result[key] = value; + return result; + }, {}); +} + +export function toPersonalityQuizResultViewModel( + dto: PersonalityQuizResultDto | null, +): PersonalityQuizResultViewModel | null { + if (!dto) { + return null; + } + + return { + personalityType: dto.personality_type, + quizAnswers: toNumericAnswerMap(dto.quiz_answers), + updatedAt: dto.updatedAt, + }; +} + +export function toPersonalityQuizResultMutationDto( + submission: PersonalityQuizSubmission, +): PersonalityQuizResultMutationDto { + return { + personality_type: submission.personalityType, + quiz_answers: toStringAnswerMap(submission.quizAnswers), + }; +} diff --git a/frontend/src/business/personality/queryHooks.ts b/frontend/src/business/personality/queryHooks.ts new file mode 100644 index 0000000..70ad705 --- /dev/null +++ b/frontend/src/business/personality/queryHooks.ts @@ -0,0 +1,44 @@ +import { useQuery } from '@tanstack/react-query'; +import { + getCurrentPersonalityResult, + listPersonalityDistribution, + saveCurrentPersonalityResult, +} from '@/shared/api/personality'; +import { + toPersonalityQuizResultMutationDto, + toPersonalityQuizResultViewModel, +} from '@/business/personality/mappers'; +import type { PersonalityQuizSubmission } from '@/business/personality/types'; +import { PERSONALITY_QUERY_KEYS } from '@/shared/constants/personality'; +import { getApiListRows } from '@/shared/business/apiListRows'; +import { useInvalidatingMutation } from '@/shared/business/queryMutations'; + +export function useCurrentPersonalityResult() { + return useQuery({ + queryKey: PERSONALITY_QUERY_KEYS.current, + queryFn: async () => { + const response = await getCurrentPersonalityResult(); + return toPersonalityQuizResultViewModel(response); + }, + }); +} + +export function useSaveCurrentPersonalityResult() { + return useInvalidatingMutation({ + mutationFn: (submission: PersonalityQuizSubmission) => saveCurrentPersonalityResult( + toPersonalityQuizResultMutationDto(submission), + ), + invalidateQueryKeys: [ + PERSONALITY_QUERY_KEYS.current, + PERSONALITY_QUERY_KEYS.distribution, + ], + }); +} + +export function usePersonalityDistribution(campusId?: string, enabled = true) { + return useQuery({ + queryKey: campusId ? [...PERSONALITY_QUERY_KEYS.distribution, campusId] : PERSONALITY_QUERY_KEYS.distribution, + enabled, + queryFn: () => getApiListRows(listPersonalityDistribution(campusId)), + }); +} diff --git a/frontend/src/business/personality/quizWorkflowHooks.ts b/frontend/src/business/personality/quizWorkflowHooks.ts new file mode 100644 index 0000000..ec24adc --- /dev/null +++ b/frontend/src/business/personality/quizWorkflowHooks.ts @@ -0,0 +1,161 @@ +import { useMemo, useState } from 'react'; +import { useContentCatalogPayload } from '@/business/content-catalog/hooks'; +import type { + PersonalityQuizFeature, + PersonalityQuizResultTab, + PersonalityQuizWorkflowInput, +} from '@/business/personality/types'; +import { + calculatePersonalityQuizProgress, + formatPersonalitySavedDate, + getPersonalityCommunicationGrowthArea, + getPersonalityCommunicationStrengths, + getPersonalityDimensionProgress, + getPersonalityRelationshipTips, + getPersonalityTypeBreakdown, +} from '@/business/personality/selectors'; +import { + calculateMBTI, + getPersonalityType, +} from '@/shared/constants/personalityCatalog'; +import type { PersonalityType, QuizQuestion } from '@/shared/constants/personalityCatalog'; +import { CONTENT_CATALOG_TYPES } from '@/shared/constants/contentCatalog'; +import { getFirstQueryError, isAnyLoading } from '@/shared/business/queryState'; + +export function usePersonalityQuizWorkflow({ + onResult, + savedType, + savedAnswers, +}: PersonalityQuizWorkflowInput) { + const questionsQuery = useContentCatalogPayload( + CONTENT_CATALOG_TYPES.personalityQuizQuestions, + [], + ); + const personalityTypesQuery = useContentCatalogPayload( + CONTENT_CATALOG_TYPES.personalityTypes, + [], + ); + const featuresQuery = useContentCatalogPayload( + CONTENT_CATALOG_TYPES.personalityQuizFeatures, + [], + ); + const [started, setStarted] = useState(false); + const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0); + const [answers, setAnswers] = useState>({}); + const [resultCode, setResultCode] = useState(null); + const [showResult, setShowResult] = useState(false); + const [selectedAnswer, setSelectedAnswer] = useState(null); + const [activeResultTab, setActiveResultTab] = useState('overview'); + const [showSavedResult, setShowSavedResult] = useState(false); + + const questions = questionsQuery.payload; + const personalityTypes = personalityTypesQuery.payload; + const totalQuestions = questions.length; + const currentQuestion = questions[currentQuestionIndex]; + const savedResult = savedType ? getPersonalityType(savedType, personalityTypes) ?? null : null; + const effectiveResultCode = resultCode ?? (!started ? savedResult?.code ?? null : null); + const effectiveAnswers = useMemo( + () => (Object.keys(answers).length > 0 ? answers : savedAnswers ?? {}), + [answers, savedAnswers], + ); + const effectiveShowSavedResult = showSavedResult || (!started && Boolean(savedResult)); + const effectiveShowResult = showResult || (!started && Boolean(savedResult)); + const result = effectiveResultCode ? getPersonalityType(effectiveResultCode, personalityTypes) ?? null : null; + const progress = totalQuestions > 0 ? calculatePersonalityQuizProgress(currentQuestionIndex, totalQuestions) : 0; + const features = featuresQuery.payload; + const dimensionProgress = useMemo( + () => currentQuestion ? getPersonalityDimensionProgress(currentQuestion, effectiveAnswers, questions) : [], + [effectiveAnswers, currentQuestion, questions], + ); + const typeBreakdown = useMemo( + () => (result ? getPersonalityTypeBreakdown(result) : []), + [result], + ); + const relationshipTips = useMemo( + () => (result ? getPersonalityRelationshipTips(result) : []), + [result], + ); + const communicationStrengths = useMemo( + () => (result ? getPersonalityCommunicationStrengths(result) : []), + [result], + ); + const communicationGrowthArea = result ? getPersonalityCommunicationGrowthArea(result) : ''; + const contentQueries = [questionsQuery, personalityTypesQuery, featuresQuery]; + + const handleSelectAnswer = (value: string) => { + setSelectedAnswer(value); + }; + + const handleNext = () => { + if (!selectedAnswer || !currentQuestion) { + return; + } + + const nextAnswers = { ...answers, [currentQuestion.id]: selectedAnswer }; + setAnswers(nextAnswers); + setSelectedAnswer(null); + + if (currentQuestionIndex < totalQuestions - 1) { + setCurrentQuestionIndex((index) => index + 1); + return; + } + + const code = calculateMBTI(nextAnswers, questions); + setResultCode(code); + setShowResult(true); + setShowSavedResult(false); + void onResult?.(code, nextAnswers); + }; + + const handleBack = () => { + if (currentQuestionIndex === 0) { + return; + } + + const previousQuestion = questions[currentQuestionIndex - 1]; + setCurrentQuestionIndex((index) => index - 1); + setSelectedAnswer(answers[previousQuestion.id] || null); + }; + + const resetQuiz = () => { + setStarted(true); + setCurrentQuestionIndex(0); + setAnswers({}); + setResultCode(null); + setShowResult(false); + setSelectedAnswer(null); + setActiveResultTab('overview'); + setShowSavedResult(false); + }; + + return { + started, + currentQuestionIndex, + answers: effectiveAnswers, + result, + showResult: effectiveShowResult, + selectedAnswer, + activeResultTab, + showSavedResult: effectiveShowSavedResult, + questions, + personalityTypes, + totalQuestions, + currentQuestion, + isContentLoading: isAnyLoading(...contentQueries), + contentError: getFirstQueryError(...contentQueries), + progress, + features, + dimensionProgress, + typeBreakdown, + relationshipTips, + communicationStrengths, + communicationGrowthArea, + formatSavedDate: formatPersonalitySavedDate, + setStarted, + setActiveResultTab, + handleSelectAnswer, + handleNext, + handleBack, + resetQuiz, + }; +} diff --git a/frontend/src/business/personality/selectors.test.ts b/frontend/src/business/personality/selectors.test.ts new file mode 100644 index 0000000..0a23f96 --- /dev/null +++ b/frontend/src/business/personality/selectors.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from 'vitest'; +import { + calculatePersonalityQuizProgress, + formatPersonalitySavedDate, + getEmotionalIntelligenceLevel, + getPersonalityCommunicationGrowthArea, + getPersonalityCommunicationStrengths, + getPersonalityDirectoryGroup, + getPersonalityDirectoryGroupDescription, + getPersonalityDirectoryGroupLabel, + getPersonalityDimensionProgress, + getPersonalityGroup, + getPersonalityRelationshipTips, + getPersonalityTypeBreakdown, + filterPersonalityDirectoryTypes, + groupPersonalityDistribution, + totalPersonalityDistribution, +} from '@/business/personality/selectors'; +import type { PersonalityType, QuizQuestion } from '@/shared/constants/personalityCatalog'; +import type { PersonalityDistributionDto } from '@/shared/types/personality'; + +const quizQuestions: readonly QuizQuestion[] = [ + { id: 1, dimension: 'EI', question: 'Question 1', optionA: { text: 'E', value: 'E' }, optionB: { text: 'I', value: 'I' } }, + { id: 2, dimension: 'EI', question: 'Question 2', optionA: { text: 'E', value: 'E' }, optionB: { text: 'I', value: 'I' } }, + { id: 3, dimension: 'EI', question: 'Question 3', optionA: { text: 'E', value: 'E' }, optionB: { text: 'I', value: 'I' } }, + { id: 4, dimension: 'SN', question: 'Question 4', optionA: { text: 'S', value: 'S' }, optionB: { text: 'N', value: 'N' } }, + { id: 5, dimension: 'SN', question: 'Question 5', optionA: { text: 'S', value: 'S' }, optionB: { text: 'N', value: 'N' } }, + { id: 6, dimension: 'SN', question: 'Question 6', optionA: { text: 'S', value: 'S' }, optionB: { text: 'N', value: 'N' } }, + { id: 7, dimension: 'TF', question: 'Question 7', optionA: { text: 'T', value: 'T' }, optionB: { text: 'F', value: 'F' } }, + { id: 8, dimension: 'TF', question: 'Question 8', optionA: { text: 'T', value: 'T' }, optionB: { text: 'F', value: 'F' } }, + { id: 9, dimension: 'TF', question: 'Question 9', optionA: { text: 'T', value: 'T' }, optionB: { text: 'F', value: 'F' } }, + { id: 10, dimension: 'JP', question: 'Question 10', optionA: { text: 'J', value: 'J' }, optionB: { text: 'P', value: 'P' } }, + { id: 11, dimension: 'JP', question: 'Question 11', optionA: { text: 'J', value: 'J' }, optionB: { text: 'P', value: 'P' } }, + { id: 12, dimension: 'JP', question: 'Question 12', optionA: { text: 'J', value: 'J' }, optionB: { text: 'P', value: 'P' } }, +]; + +const intjType: PersonalityType = { + code: 'INTJ', + name: 'The Architect', + nickname: 'Strategic Visionary', + description: 'Strategic and independent.', + strengths: ['Strategic planning'], + workRelationships: 'Values competence.', + workplaceLanguage: 'Direct and precise.', + idealWorkEnvironment: 'Structured autonomy', + communicationStyle: 'Direct', + color: 'from-indigo-500 to-purple-600', + bgColor: 'bg-indigo-500/10', + borderColor: 'border-indigo-500/20', + icon: 'architect', +}; + +const enfpType: PersonalityType = { + ...intjType, + code: 'ENFP', + name: 'The Campaigner', + nickname: 'Enthusiastic Connector', +}; + +describe('personality selectors', () => { + it('totals and groups personality distribution counts', () => { + const distribution: readonly PersonalityDistributionDto[] = [ + { type: 'INTJ', count: 3 }, + { type: 'ENTJ', count: 2 }, + { type: 'ENFP', count: 4 }, + { type: 'ISFJ', count: 1 }, + ]; + + expect(totalPersonalityDistribution(distribution)).toBe(10); + expect(groupPersonalityDistribution(distribution, getPersonalityGroup)).toEqual({ + Analysts: 5, + Diplomats: 4, + Sentinels: 1, + }); + }); + + it('returns expected EI level thresholds', () => { + expect(getEmotionalIntelligenceLevel(85).label).toBe('Strong EI Foundation'); + expect(getEmotionalIntelligenceLevel(65).label).toBe('Growing EI Skills'); + expect(getEmotionalIntelligenceLevel(45).label).toBe('Developing Awareness'); + expect(getEmotionalIntelligenceLevel(20).label).toBe('Beginning Journey'); + }); + + it('formats valid dates and preserves invalid date strings', () => { + expect(formatPersonalitySavedDate('2026-06-08T12:00:00.000Z')).toBe('Jun 8, 2026'); + expect(formatPersonalitySavedDate('not-a-date')).toBe('not-a-date'); + }); + + it('calculates quiz progress and dimension progress', () => { + const answers: Record = { + 1: 'E', + 4: 'N', + 7: 'T', + }; + + expect(calculatePersonalityQuizProgress(2, quizQuestions.length)).toBe(25); + expect(getPersonalityDimensionProgress(quizQuestions[3], answers, quizQuestions)).toEqual([ + { dimension: 'EI', label: 'E/I', answeredCount: 1, totalCount: 3, current: false }, + { dimension: 'SN', label: 'S/N', answeredCount: 1, totalCount: 3, current: true }, + { dimension: 'TF', label: 'T/F', answeredCount: 1, totalCount: 3, current: false }, + { dimension: 'JP', label: 'J/P', answeredCount: 0, totalCount: 3, current: false }, + ]); + }); + + it('builds type breakdown and communication guidance from the personality code', () => { + expect(getPersonalityTypeBreakdown(intjType)).toEqual([ + { letter: 'I', label: 'Energy', fullLabel: 'Introversion' }, + { letter: 'N', label: 'Info', fullLabel: 'Intuition' }, + { letter: 'T', label: 'Decisions', fullLabel: 'Thinking' }, + { letter: 'J', label: 'Structure', fullLabel: 'Judging' }, + ]); + expect(getPersonalityCommunicationGrowthArea(intjType)).toContain('sharing your ideas earlier'); + expect(getPersonalityCommunicationStrengths(enfpType)).toContain('Engaging others in open dialogue and brainstorming'); + expect(getPersonalityRelationshipTips(intjType)).toHaveLength(4); + }); + + it('groups and filters personality directory types', () => { + const types = [ + intjType, + enfpType, + { ...intjType, code: 'ISFJ', name: 'The Defender', nickname: 'Practical Supporter' }, + { ...intjType, code: 'ESTP', name: 'The Entrepreneur', nickname: 'Action-Oriented Problem Solver' }, + ]; + + expect(getPersonalityDirectoryGroup('INTJ')).toBe('analysts'); + expect(getPersonalityDirectoryGroup('ENFP')).toBe('diplomats'); + expect(getPersonalityDirectoryGroup('ISFJ')).toBe('sentinels'); + expect(getPersonalityDirectoryGroup('ESTP')).toBe('explorers'); + expect(getPersonalityDirectoryGroupLabel('analysts')).toBe('Analysts (NT)'); + expect(getPersonalityDirectoryGroupDescription('all')).toContain('All 16 personality types'); + expect(filterPersonalityDirectoryTypes(types, 'defender', 'all').map((type) => type.code)).toEqual(['ISFJ']); + expect(filterPersonalityDirectoryTypes(types, '', 'explorers').map((type) => type.code)).toEqual(['ESTP']); + }); +}); diff --git a/frontend/src/business/personality/selectors.ts b/frontend/src/business/personality/selectors.ts new file mode 100644 index 0000000..9995197 --- /dev/null +++ b/frontend/src/business/personality/selectors.ts @@ -0,0 +1,299 @@ +import type { PersonalityDistributionDto } from '@/shared/types/personality'; +import type { EmotionalIntelligenceLevel } from '@/shared/types/emotionalIntelligence'; +import type { PersonalityType, QuizQuestion } from '@/shared/constants/personalityCatalog'; +import type { + PersonalityDirectoryFilterGroup, + PersonalityDimensionProgress, + PersonalityRelationshipTip, + PersonalityTypeBreakdownItem, +} from '@/business/personality/types'; + +export function totalPersonalityDistribution(distribution: readonly PersonalityDistributionDto[]): number { + return distribution.reduce((sum, item) => sum + item.count, 0); +} + +export function groupPersonalityDistribution( + distribution: readonly PersonalityDistributionDto[], + getGroup: (code: string) => string, +): Record { + return distribution.reduce>((result, item) => { + const group = getGroup(item.type); + result[group] = (result[group] || 0) + item.count; + return result; + }, {}); +} + +export function getEmotionalIntelligenceLevel(percentage: number): EmotionalIntelligenceLevel { + if (percentage >= 80) { + return { + label: 'Strong EI Foundation', + color: 'text-emerald-400', + bg: 'bg-emerald-500/15 border-emerald-500/20', + desc: 'You demonstrate strong emotional awareness and regulation. Focus on mentoring others and deepening your practice.', + }; + } + + if (percentage >= 60) { + return { + label: 'Growing EI Skills', + color: 'text-blue-400', + bg: 'bg-blue-500/15 border-blue-500/20', + desc: 'You have a good foundation. Focus on consistency in high-stress moments and building your regulation toolkit.', + }; + } + + if (percentage >= 40) { + return { + label: 'Developing Awareness', + color: 'text-amber-400', + bg: 'bg-amber-500/15 border-amber-500/20', + desc: 'You are building awareness. Focus on identifying your triggers and practicing one regulation strategy daily.', + }; + } + + return { + label: 'Beginning Journey', + color: 'text-rose-400', + bg: 'bg-rose-500/15 border-rose-500/20', + desc: 'This is a great starting point. Focus on naming your emotions throughout the day and noticing patterns.', + }; +} + +export function getPersonalityGroup(code: string): string { + const second = code[1]; + const third = code[2]; + const fourth = code[3]; + + if (second === 'N' && third === 'T') { + return 'Analysts'; + } + + if (second === 'N' && third === 'F') { + return 'Diplomats'; + } + + if (second === 'S' && fourth === 'J') { + return 'Sentinels'; + } + + if (second === 'S' && fourth === 'P') { + return 'Explorers'; + } + + return 'Unknown'; +} + +export function formatPersonalitySavedDate(dateStr: string): string { + const date = new Date(dateStr); + + if (Number.isNaN(date.getTime())) { + return dateStr; + } + + return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); +} + +export function getPersonalityDimensionLabel(dimension: QuizQuestion['dimension']): string { + if (dimension === 'EI') return 'Energy Source'; + if (dimension === 'SN') return 'Information Style'; + if (dimension === 'TF') return 'Decision Making'; + return 'Work Structure'; +} + +export function getPersonalityDimensionShortLabel(dimension: QuizQuestion['dimension']): string { + if (dimension === 'EI') return 'E/I'; + if (dimension === 'SN') return 'S/N'; + if (dimension === 'TF') return 'T/F'; + return 'J/P'; +} + +export function calculatePersonalityQuizProgress(currentQuestionIndex: number, totalQuestions: number): number { + return ((currentQuestionIndex + 1) / totalQuestions) * 100; +} + +export function getPersonalityDimensionProgress( + currentQuestion: QuizQuestion, + answers: Record, + questions: readonly QuizQuestion[], +): readonly PersonalityDimensionProgress[] { + const dimensions: readonly QuizQuestion['dimension'][] = ['EI', 'SN', 'TF', 'JP']; + + return dimensions.map((dimension) => { + const dimensionQuestions = questions.filter((question) => question.dimension === dimension); + + return { + dimension, + label: getPersonalityDimensionShortLabel(dimension), + answeredCount: dimensionQuestions.filter((question) => Boolean(answers[question.id])).length, + totalCount: dimensionQuestions.length, + current: currentQuestion.dimension === dimension, + }; + }); +} + +export function getPersonalityTypeBreakdown(type: PersonalityType): readonly PersonalityTypeBreakdownItem[] { + const labels = ['Energy', 'Info', 'Decisions', 'Structure']; + const letters = type.code.split(''); + + return letters.map((letter, index) => { + const fullLabels = [ + letter === 'E' ? 'Extraversion' : 'Introversion', + letter === 'S' ? 'Sensing' : 'Intuition', + letter === 'T' ? 'Thinking' : 'Feeling', + letter === 'J' ? 'Judging' : 'Perceiving', + ]; + + return { + letter, + label: labels[index], + fullLabel: fullLabels[index], + }; + }); +} + +export function getPersonalityRelationshipTips(type: PersonalityType): readonly PersonalityRelationshipTip[] { + return [ + { + type: 'Analysts (NT types)', + tip: type.code.includes('T') + ? 'You naturally connect through shared logic - remember to also acknowledge emotions.' + : 'Bridge the gap by framing your ideas with logical reasoning alongside your values.', + }, + { + type: 'Diplomats (NF types)', + tip: type.code.includes('F') + ? 'You share a values-driven approach - collaborate on vision and purpose.' + : 'Show appreciation for their emotional insights and make space for personal connection.', + }, + { + type: 'Sentinels (SJ types)', + tip: type.code.includes('J') + ? 'You share a love of structure - partner on organizational projects.' + : 'Respect their need for procedures and provide clear timelines when collaborating.', + }, + { + type: 'Explorers (SP types)', + tip: type.code.includes('P') + ? 'You share adaptability - team up for creative, hands-on projects.' + : 'Appreciate their spontaneity and give them room to approach tasks their own way.', + }, + ]; +} + +export function getPersonalityCommunicationStrengths(type: PersonalityType): readonly string[] { + const strengths: string[] = []; + + if (type.code[0] === 'E') { + strengths.push('Engaging others in open dialogue and brainstorming'); + strengths.push('Energizing team meetings with your verbal contributions'); + } else { + strengths.push('Providing thoughtful, well-considered responses'); + strengths.push('Creating space for deeper, one-on-one conversations'); + } + + if (type.code[2] === 'T') { + strengths.push('Delivering clear, logical arguments and analysis'); + } else { + strengths.push('Reading emotional cues and responding with empathy'); + } + + if (type.code[3] === 'J') { + strengths.push('Keeping discussions organized and action-oriented'); + } else { + strengths.push('Adapting your message flexibly to the situation'); + } + + return strengths; +} + +export function getPersonalityCommunicationGrowthArea(type: PersonalityType): string { + if (type.code[0] === 'E') { + return 'Practice active listening - pause before responding and ask clarifying questions to ensure you fully understand before sharing your perspective.'; + } + + return 'Practice sharing your ideas earlier in discussions - your insights are valuable and the team benefits when you speak up sooner.'; +} + +export function getPersonalityDirectoryGroup(code: string): PersonalityDirectoryFilterGroup { + const second = code[1]; + const third = code[2]; + const fourth = code[3]; + + if (second === 'N' && third === 'T') { + return 'analysts'; + } + + if (second === 'N' && third === 'F') { + return 'diplomats'; + } + + if (second === 'S' && fourth === 'J') { + return 'sentinels'; + } + + if (second === 'S' && fourth === 'P') { + return 'explorers'; + } + + return 'all'; +} + +export function getPersonalityDirectoryGroupLabel(group: PersonalityDirectoryFilterGroup): string { + if (group === 'analysts') { + return 'Analysts (NT)'; + } + + if (group === 'diplomats') { + return 'Diplomats (NF)'; + } + + if (group === 'sentinels') { + return 'Sentinels (SJ)'; + } + + if (group === 'explorers') { + return 'Explorers (SP)'; + } + + return 'All Types'; +} + +export function getPersonalityDirectoryGroupDescription( + group: PersonalityDirectoryFilterGroup, +): string { + if (group === 'analysts') { + return 'Strategic, logical thinkers who value competence and innovation'; + } + + if (group === 'diplomats') { + return 'Empathetic, values-driven individuals who seek meaning and harmony'; + } + + if (group === 'sentinels') { + return 'Reliable, practical organizers who value stability and tradition'; + } + + if (group === 'explorers') { + return 'Adaptable, hands-on doers who thrive on action and spontaneity'; + } + + return 'All 16 personality types and their workplace profiles'; +} + +export function filterPersonalityDirectoryTypes( + personalityTypes: readonly PersonalityType[], + searchQuery: string, + filterGroup: PersonalityDirectoryFilterGroup, +): readonly PersonalityType[] { + const normalizedSearch = searchQuery.trim().toLowerCase(); + + return personalityTypes.filter((type) => { + const matchesSearch = normalizedSearch.length === 0 + || type.code.toLowerCase().includes(normalizedSearch) + || type.name.toLowerCase().includes(normalizedSearch) + || type.nickname.toLowerCase().includes(normalizedSearch); + const matchesGroup = filterGroup === 'all' || getPersonalityDirectoryGroup(type.code) === filterGroup; + + return matchesSearch && matchesGroup; + }); +} diff --git a/frontend/src/business/personality/types.ts b/frontend/src/business/personality/types.ts new file mode 100644 index 0000000..9ae6283 --- /dev/null +++ b/frontend/src/business/personality/types.ts @@ -0,0 +1,68 @@ +import type { PersonalityDirectoryFilterGroup as PersonalityDirectoryFilterGroupValue } from '@/shared/constants/personality'; + +export interface PersonalityQuizResultViewModel { + readonly personalityType: string; + readonly quizAnswers: Record; + readonly updatedAt: string; +} + +export interface PersonalityQuizSubmission { + readonly personalityType: string; + readonly quizAnswers: Record; +} + +export type PersonalityQuizResultTab = 'overview' | 'relationships' | 'language'; + +export interface PersonalityQuizFeature { + readonly id: string; + readonly label: string; + readonly description: string; + readonly toneClass: string; +} + +export interface PersonalityDimensionProgress { + readonly dimension: 'EI' | 'SN' | 'TF' | 'JP'; + readonly label: string; + readonly answeredCount: number; + readonly totalCount: number; + readonly current: boolean; +} + +export interface PersonalityTypeBreakdownItem { + readonly letter: string; + readonly label: string; + readonly fullLabel: string; +} + +export interface PersonalityRelationshipTip { + readonly type: string; + readonly tip: string; +} + +export interface PersonalityQuizWorkflowInput { + readonly onResult?: (code: string, answers: Record) => void | Promise; + readonly savedType?: string | null; + readonly savedAnswers?: Record | null; +} + +export type PersonalityDirectoryFilterGroup = PersonalityDirectoryFilterGroupValue; + +export type PersonalityDirectorySection = 'overview' | 'relationships' | 'language'; + +export interface PersonalityDirectoryPage { + readonly personalityTypes: readonly import('@/shared/constants/personalityCatalog').PersonalityType[]; + readonly filteredTypes: readonly import('@/shared/constants/personalityCatalog').PersonalityType[]; + readonly expandedType: string | null; + readonly searchQuery: string; + readonly filterGroup: PersonalityDirectoryFilterGroup; + readonly expandedSections: Readonly>; + readonly isLoading: boolean; + readonly error: unknown; + readonly groupDescription: string; + readonly setSearchQuery: (query: string) => void; + readonly setFilterGroup: (group: PersonalityDirectoryFilterGroup) => void; + readonly toggleExpand: (code: string) => void; + readonly getActiveSection: (code: string) => PersonalityDirectorySection; + readonly setActiveSection: (code: string, section: PersonalityDirectorySection) => void; + readonly clearFilters: () => void; +} diff --git a/frontend/src/business/policies/hooks.ts b/frontend/src/business/policies/hooks.ts new file mode 100644 index 0000000..0f76251 --- /dev/null +++ b/frontend/src/business/policies/hooks.ts @@ -0,0 +1,48 @@ +import { useQuery } from '@tanstack/react-query'; +import { + createDocument, + deleteDocument, + listPolicyDocuments, + updateDocument, +} from '@/shared/api/documents'; +import { POLICY_QUERY_KEYS } from '@/shared/constants/policies'; +import { toPolicyDocumentMutationDto, toPolicyViewModel } from '@/business/policies/mappers'; +import type { PolicyFormInput } from '@/business/policies/types'; +import { mapApiListRows } from '@/shared/business/apiListRows'; +import { useInvalidatingMutation } from '@/shared/business/queryMutations'; + +interface PolicyUpdateInput { + readonly id: string; + readonly policy: PolicyFormInput; +} + +export function usePolicies() { + return useQuery({ + queryKey: POLICY_QUERY_KEYS.documents, + queryFn: () => mapApiListRows(listPolicyDocuments(), toPolicyViewModel), + }); +} + +export function useCreatePolicy() { + return useInvalidatingMutation({ + mutationFn: (input: PolicyFormInput) => createDocument(toPolicyDocumentMutationDto(input)), + invalidateQueryKey: POLICY_QUERY_KEYS.documents, + }); +} + +export function useUpdatePolicy() { + return useInvalidatingMutation({ + mutationFn: (input: PolicyUpdateInput) => updateDocument( + input.id, + toPolicyDocumentMutationDto(input.policy), + ), + invalidateQueryKey: POLICY_QUERY_KEYS.documents, + }); +} + +export function useDeletePolicy() { + return useInvalidatingMutation({ + mutationFn: (id: string) => deleteDocument(id), + invalidateQueryKey: POLICY_QUERY_KEYS.documents, + }); +} diff --git a/frontend/src/business/policies/mappers.test.ts b/frontend/src/business/policies/mappers.test.ts new file mode 100644 index 0000000..7a73683 --- /dev/null +++ b/frontend/src/business/policies/mappers.test.ts @@ -0,0 +1,94 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + toPolicyDocumentMutationDto, + toPolicyViewModel, +} from '@/business/policies/mappers'; +import type { PolicyFormInput } from '@/business/policies/types'; +import { + POLICY_DATE_NOT_RECORDED_LABEL, + POLICY_DEFAULT_CATEGORY, + POLICY_DOCUMENT_CATEGORY, + POLICY_DOCUMENT_ENTITY_TYPE, + POLICY_UPDATED_BY_LABEL, +} from '@/shared/constants/policies'; +import type { DocumentDto } from '@/shared/types/documents'; + +describe('policy mappers', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('maps backend document DTO fields into the frontend policy view model shape', () => { + const dto: DocumentDto = { + id: 'policy-1', + entity_type: 'organization', + entity_reference: 'Safety', + name: 'Incident Response', + category: 'policy', + uploaded_at: '2026-06-07T08:00:00.000Z', + notes: 'Use the approved incident response process.', + organizationId: 'org-1', + campusId: null, + createdById: 'user-1', + updatedById: 'user-2', + createdAt: '2026-06-07T08:00:00.000Z', + updatedAt: '2026-06-08T09:30:00.000Z', + }; + + expect(toPolicyViewModel(dto)).toEqual({ + id: 'policy-1', + title: 'Incident Response', + category: 'Safety', + content: 'Use the approved incident response process.', + lastUpdated: '2026-06-08', + updatedBy: POLICY_UPDATED_BY_LABEL, + }); + }); + + it('defaults missing document fields into explicit policy view model values', () => { + const dto: DocumentDto = { + id: 'policy-2', + entity_type: null, + entity_reference: 'Unknown Category', + name: null, + category: null, + uploaded_at: null, + notes: null, + organizationId: null, + campusId: null, + createdById: null, + updatedById: null, + createdAt: '2026-06-07T08:00:00.000Z', + updatedAt: '', + }; + + expect(toPolicyViewModel(dto)).toEqual({ + id: 'policy-2', + title: '', + category: POLICY_DEFAULT_CATEGORY, + content: '', + lastUpdated: POLICY_DATE_NOT_RECORDED_LABEL, + updatedBy: POLICY_UPDATED_BY_LABEL, + }); + }); + + it('maps policy form input back into the backend document mutation DTO shape', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-06-08T12:34:56.000Z')); + + const input: PolicyFormInput = { + title: ' Communication Standards ', + category: 'Communication', + content: ' Use plain language with families. ', + }; + + expect(toPolicyDocumentMutationDto(input)).toEqual({ + entity_type: POLICY_DOCUMENT_ENTITY_TYPE, + entity_reference: 'Communication', + name: 'Communication Standards', + category: POLICY_DOCUMENT_CATEGORY, + uploaded_at: '2026-06-08T12:34:56.000Z', + notes: 'Use plain language with families.', + }); + }); +}); diff --git a/frontend/src/business/policies/mappers.ts b/frontend/src/business/policies/mappers.ts new file mode 100644 index 0000000..edd657c --- /dev/null +++ b/frontend/src/business/policies/mappers.ts @@ -0,0 +1,50 @@ +import { + POLICY_CATEGORIES, + POLICY_DATE_NOT_RECORDED_LABEL, + POLICY_DEFAULT_CATEGORY, + POLICY_DOCUMENT_CATEGORY, + POLICY_DOCUMENT_ENTITY_TYPE, + POLICY_UPDATED_BY_LABEL, +} from '@/shared/constants/policies'; +import type { DocumentDto, DocumentMutationDto } from '@/shared/types/documents'; +import type { PolicyCategory, PolicyFormInput, PolicyViewModel } from '@/business/policies/types'; + +function isPolicyCategory(value: string | null): value is PolicyCategory { + return POLICY_CATEGORIES.some((category) => category === value); +} + +function toPolicyCategory(value: string | null): PolicyCategory { + return isPolicyCategory(value) ? value : POLICY_DEFAULT_CATEGORY; +} + +function toDateOnly(value: string | null): string { + if (!value) { + return POLICY_DATE_NOT_RECORDED_LABEL; + } + + return value.split('T')[0] || POLICY_DATE_NOT_RECORDED_LABEL; +} + +export function toPolicyViewModel(dto: DocumentDto): PolicyViewModel { + return { + id: dto.id, + title: dto.name || '', + category: toPolicyCategory(dto.entity_reference), + content: dto.notes || '', + lastUpdated: toDateOnly(dto.updatedAt || dto.uploaded_at), + updatedBy: POLICY_UPDATED_BY_LABEL, + }; +} + +export function toPolicyDocumentMutationDto(input: PolicyFormInput): DocumentMutationDto { + const recordedAt = new Date().toISOString(); + + return { + entity_type: POLICY_DOCUMENT_ENTITY_TYPE, + entity_reference: input.category, + name: input.title.trim(), + category: POLICY_DOCUMENT_CATEGORY, + uploaded_at: recordedAt, + notes: input.content.trim(), + }; +} diff --git a/frontend/src/business/policies/pageHooks.ts b/frontend/src/business/policies/pageHooks.ts new file mode 100644 index 0000000..562c903 --- /dev/null +++ b/frontend/src/business/policies/pageHooks.ts @@ -0,0 +1,177 @@ +import { useState } from 'react'; + +import { + useCreatePolicy, + useDeletePolicy, + usePolicies, + useUpdatePolicy, +} from '@/business/policies/hooks'; +import { + canManagePolicies, + filterPolicies, + getPolicyCategoryFilters, + isPolicyFormValid, +} from '@/business/policies/selectors'; +import type { PolicyCategory, PolicyFormInput } from '@/business/policies/types'; +import { POLICY_DEFAULT_CATEGORY } from '@/shared/constants/policies'; +import type { UserRole } from '@/shared/types/app'; + +export interface PoliciesPageState { + readonly canManage: boolean; + readonly filteredPolicies: NonNullable['data']>; + readonly categories: readonly (PolicyCategory | 'all')[]; + readonly searchQuery: string; + readonly categoryFilter: PolicyCategory | 'all'; + readonly expandedPolicyId: string | null; + readonly acknowledgedPolicyIds: ReadonlySet; + readonly showCreateForm: boolean; + readonly editingPolicyId: string | null; + readonly createDraft: PolicyFormInput; + readonly editDraft: PolicyFormInput; + readonly totalCount: number; + readonly acknowledgedCount: number; + readonly isLoading: boolean; + readonly queryError: unknown; + readonly mutationError: unknown; + readonly isMutating: boolean; + readonly isCreating: boolean; + readonly isUpdating: boolean; + readonly isDeleting: boolean; +} + +export interface PoliciesPageActions { + readonly setSearchQuery: (value: string) => void; + readonly clearSearch: () => void; + readonly setCategoryFilter: (value: PolicyCategory | 'all') => void; + readonly setShowCreateForm: (value: boolean) => void; + readonly updateCreateDraft: (patch: Partial) => void; + readonly updateEditDraft: (patch: Partial) => void; + readonly submitCreatePolicy: () => Promise; + readonly startEditingPolicy: (id: string) => void; + readonly cancelEditingPolicy: () => void; + readonly submitEditPolicy: (id: string) => Promise; + readonly deletePolicy: (id: string) => Promise; + readonly togglePolicyExpanded: (id: string) => void; + readonly toggleAcknowledgement: (id: string) => void; +} + +export interface PoliciesPage { + readonly state: PoliciesPageState; + readonly actions: PoliciesPageActions; +} + +const emptyPolicyDraft: PolicyFormInput = { + title: '', + category: POLICY_DEFAULT_CATEGORY, + content: '', +}; + +export function usePoliciesPage(userRole: UserRole): PoliciesPage { + const canManage = canManagePolicies(userRole); + const policiesQuery = usePolicies(); + const createPolicy = useCreatePolicy(); + const updatePolicy = useUpdatePolicy(); + const deletePolicyMutation = useDeletePolicy(); + const [searchQuery, setSearchQuery] = useState(''); + const [categoryFilter, setCategoryFilter] = useState('all'); + const [expandedPolicyId, setExpandedPolicyId] = useState(null); + const [acknowledgedPolicyIds, setAcknowledgedPolicyIds] = useState>(new Set()); + const [showCreateForm, setShowCreateForm] = useState(false); + const [editingPolicyId, setEditingPolicyId] = useState(null); + const [createDraft, setCreateDraft] = useState(emptyPolicyDraft); + const [editDraft, setEditDraft] = useState(emptyPolicyDraft); + + const policies = policiesQuery.data ?? []; + const filteredPolicies = filterPolicies(policies, searchQuery, categoryFilter); + const isMutating = createPolicy.isPending || updatePolicy.isPending || deletePolicyMutation.isPending; + const mutationError = createPolicy.error || updatePolicy.error || deletePolicyMutation.error; + + const resetCreateDraft = () => { + setCreateDraft(emptyPolicyDraft); + }; + + const submitCreatePolicy = async () => { + if (!isPolicyFormValid(createDraft)) return; + + await createPolicy.mutateAsync(createDraft); + resetCreateDraft(); + setShowCreateForm(false); + }; + + const startEditingPolicy = (id: string) => { + const policy = policies.find((item) => item.id === id); + if (!policy) return; + + setEditDraft({ + title: policy.title, + category: policy.category, + content: policy.content, + }); + setEditingPolicyId(id); + }; + + const submitEditPolicy = async (id: string) => { + if (!isPolicyFormValid(editDraft)) return; + + await updatePolicy.mutateAsync({ id, policy: editDraft }); + setEditingPolicyId(null); + }; + + const togglePolicyExpanded = (id: string) => { + setExpandedPolicyId((currentId) => (currentId === id ? null : id)); + }; + + const toggleAcknowledgement = (id: string) => { + setAcknowledgedPolicyIds((currentIds) => { + const nextIds = new Set(currentIds); + if (nextIds.has(id)) { + nextIds.delete(id); + } else { + nextIds.add(id); + } + return nextIds; + }); + }; + + return { + state: { + canManage, + filteredPolicies, + categories: getPolicyCategoryFilters(), + searchQuery, + categoryFilter, + expandedPolicyId, + acknowledgedPolicyIds, + showCreateForm, + editingPolicyId, + createDraft, + editDraft, + totalCount: policies.length, + acknowledgedCount: acknowledgedPolicyIds.size, + isLoading: policiesQuery.isLoading, + queryError: policiesQuery.error, + mutationError, + isMutating, + isCreating: createPolicy.isPending, + isUpdating: updatePolicy.isPending, + isDeleting: deletePolicyMutation.isPending, + }, + actions: { + setSearchQuery, + clearSearch: () => setSearchQuery(''), + setCategoryFilter, + setShowCreateForm, + updateCreateDraft: (patch) => setCreateDraft((currentDraft) => ({ ...currentDraft, ...patch })), + updateEditDraft: (patch) => setEditDraft((currentDraft) => ({ ...currentDraft, ...patch })), + submitCreatePolicy, + startEditingPolicy, + cancelEditingPolicy: () => setEditingPolicyId(null), + submitEditPolicy, + deletePolicy: async (id) => { + await deletePolicyMutation.mutateAsync(id); + }, + togglePolicyExpanded, + toggleAcknowledgement, + }, + }; +} diff --git a/frontend/src/business/policies/selectors.test.ts b/frontend/src/business/policies/selectors.test.ts new file mode 100644 index 0000000..3081874 --- /dev/null +++ b/frontend/src/business/policies/selectors.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from 'vitest'; +import { + canManagePolicies, + filterPolicies, + getPolicyCategoryFilters, + isPolicyFormValid, + toPolicyCategory, + toPolicyCategoryFilter, +} from '@/business/policies/selectors'; +import type { PolicyFormInput, PolicyViewModel } from '@/business/policies/types'; +import { POLICY_CATEGORY_FILTERS } from '@/shared/constants/policies'; + +const policies: readonly PolicyViewModel[] = [ + { + id: 'policy-1', + title: 'Emergency Communication', + category: 'Communication', + content: 'Call families after a campus-wide emergency.', + lastUpdated: '2026-06-08', + updatedBy: 'Director', + }, + { + id: 'policy-2', + title: 'Safety Drill', + category: 'Safety', + content: 'Monthly drill expectations.', + lastUpdated: '2026-06-07', + updatedBy: 'Office', + }, + { + id: 'policy-3', + title: 'Behavior Support', + category: 'Behavior', + content: 'Use approved de-escalation supports.', + lastUpdated: '2026-06-06', + updatedBy: 'Director', + }, +]; + +describe('policy selectors', () => { + it('allows only director and superintendent roles to manage policies', () => { + expect(canManagePolicies('director')).toBe(true); + expect(canManagePolicies('superintendent')).toBe(true); + expect(canManagePolicies('teacher')).toBe(false); + expect(canManagePolicies('para')).toBe(false); + expect(canManagePolicies('office')).toBe(false); + }); + + it('returns the configured category filters without creating a new source of truth', () => { + expect(getPolicyCategoryFilters()).toBe(POLICY_CATEGORY_FILTERS); + }); + + it('normalizes invalid categories and the all filter', () => { + expect(toPolicyCategory('Safety')).toBe('Safety'); + expect(toPolicyCategory('Invalid')).toBe('Operations'); + expect(toPolicyCategoryFilter('all')).toBe('all'); + expect(toPolicyCategoryFilter('Legal')).toBe('Legal'); + expect(toPolicyCategoryFilter('Invalid')).toBe('Operations'); + }); + + it('filters policies by trimmed search query and category', () => { + expect(filterPolicies(policies, ' emergency ', 'all')).toEqual([policies[0]]); + expect(filterPolicies(policies, 'drill', 'Safety')).toEqual([policies[1]]); + expect(filterPolicies(policies, 'support', 'Safety')).toEqual([]); + }); + + it('validates required policy form fields', () => { + const validInput: PolicyFormInput = { + title: 'Safety', + category: 'Safety', + content: 'Required content.', + }; + + expect(isPolicyFormValid(validInput)).toBe(true); + expect(isPolicyFormValid({ ...validInput, title: ' ' })).toBe(false); + expect(isPolicyFormValid({ ...validInput, content: '' })).toBe(false); + }); +}); diff --git a/frontend/src/business/policies/selectors.ts b/frontend/src/business/policies/selectors.ts new file mode 100644 index 0000000..3096f74 --- /dev/null +++ b/frontend/src/business/policies/selectors.ts @@ -0,0 +1,40 @@ +import type { UserRole } from '@/shared/types/app'; +import { POLICY_CATEGORIES, POLICY_CATEGORY_FILTERS, POLICY_DEFAULT_CATEGORY } from '@/shared/constants/policies'; +import type { PolicyCategory, PolicyFormInput, PolicyViewModel } from '@/business/policies/types'; + +export function canManagePolicies(userRole: UserRole): boolean { + return userRole === 'director' || userRole === 'superintendent'; +} + +export function getPolicyCategoryFilters(): readonly (PolicyCategory | 'all')[] { + return POLICY_CATEGORY_FILTERS; +} + +export function toPolicyCategory(value: string): PolicyCategory { + return POLICY_CATEGORIES.find((category) => category === value) ?? POLICY_DEFAULT_CATEGORY; +} + +export function toPolicyCategoryFilter(value: string): PolicyCategory | 'all' { + return value === 'all' ? 'all' : toPolicyCategory(value); +} + +export function filterPolicies( + policies: readonly PolicyViewModel[], + searchQuery: string, + categoryFilter: PolicyCategory | 'all', +): readonly PolicyViewModel[] { + const normalizedSearchQuery = searchQuery.trim().toLowerCase(); + + return policies.filter((policy) => { + const matchesSearch = !normalizedSearchQuery + || policy.title.toLowerCase().includes(normalizedSearchQuery) + || policy.content.toLowerCase().includes(normalizedSearchQuery); + const matchesCategory = categoryFilter === 'all' || policy.category === categoryFilter; + + return matchesSearch && matchesCategory; + }); +} + +export function isPolicyFormValid(input: PolicyFormInput): boolean { + return Boolean(input.title.trim() && input.content.trim()); +} diff --git a/frontend/src/business/policies/types.ts b/frontend/src/business/policies/types.ts new file mode 100644 index 0000000..ad09c20 --- /dev/null +++ b/frontend/src/business/policies/types.ts @@ -0,0 +1,18 @@ +import type { PolicyCategory } from '@/shared/types/policies'; + +export type { PolicyCategory }; + +export interface PolicyViewModel { + readonly id: string; + readonly title: string; + readonly category: PolicyCategory; + readonly content: string; + readonly lastUpdated: string; + readonly updatedBy: string; +} + +export interface PolicyFormInput { + readonly title: string; + readonly category: PolicyCategory; + readonly content: string; +} diff --git a/frontend/src/business/safety-quiz/hooks.ts b/frontend/src/business/safety-quiz/hooks.ts new file mode 100644 index 0000000..c01699a --- /dev/null +++ b/frontend/src/business/safety-quiz/hooks.ts @@ -0,0 +1,235 @@ +import { useMemo, useState } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { + createSafetyQuizResult, + listSafetyQuizResults, +} from '@/shared/api/safetyQuizResults'; +import { + getManagedContentCatalog, + updateManagedContentCatalog, +} from '@/shared/api/contentCatalog'; +import { useContentCatalogPayload } from '@/business/content-catalog/hooks'; +import { + SAFETY_QUIZ_COMPLIANCE_QUERY_SEGMENT, + SAFETY_QUIZ_QUERY_KEYS, +} from '@/shared/constants/safetyQuiz'; +import { + CONTENT_CATALOG_QUERY_KEYS, + CONTENT_CATALOG_TYPES, +} from '@/shared/constants/contentCatalog'; +import { + toSafetyQuizComplianceRow, + toSafetyQuizResultCreateDto, +} from '@/business/safety-quiz/mappers'; +import { + SafetyQuizContentEditor, + SafetyQuizPage, + SafetyQuizSubmission, +} from '@/business/safety-quiz/types'; +import { + calculateSafetyQuizCompletionSummary, + calculateSafetyQuizScore, + getCurrentSafetyQuizWeek, + parseSafetyQuizPayload, + serializeSafetyQuizPayload, +} from '@/business/safety-quiz/selectors'; +import type { SafetyQuiz, UserRole } from '@/shared/types/app'; +import { getApiListRows, mapApiListRows } from '@/shared/business/apiListRows'; +import { useInvalidatingMutation } from '@/shared/business/queryMutations'; +import { getOptionalErrorMessage } from '@/shared/errors/errorMessages'; + +export function useSafetyQuizResults(weekOf?: string) { + return useQuery({ + queryKey: weekOf ? [...SAFETY_QUIZ_QUERY_KEYS.results, weekOf] : SAFETY_QUIZ_QUERY_KEYS.results, + queryFn: () => getApiListRows(listSafetyQuizResults(weekOf)), + }); +} + +export function useSafetyQuizCompliance(weekOf: string, enabled: boolean) { + return useQuery({ + queryKey: [...SAFETY_QUIZ_QUERY_KEYS.results, SAFETY_QUIZ_COMPLIANCE_QUERY_SEGMENT, weekOf], + enabled, + queryFn: () => mapApiListRows(listSafetyQuizResults(weekOf), toSafetyQuizComplianceRow), + }); +} + +export function useSaveSafetyQuizResult() { + return useInvalidatingMutation({ + mutationFn: (submission: SafetyQuizSubmission) => createSafetyQuizResult( + toSafetyQuizResultCreateDto(submission), + ), + invalidateQueryKey: SAFETY_QUIZ_QUERY_KEYS.results, + }); +} + +export function useSafetyQuizPage(userRole: UserRole): SafetyQuizPage { + const quizQuery = useContentCatalogPayload( + CONTENT_CATALOG_TYPES.safetyQbsQuiz, + null, + ); + const [quizStarted, setQuizStarted] = useState(false); + const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0); + const [selectedAnswer, setSelectedAnswer] = useState(null); + const [showExplanation, setShowExplanation] = useState(false); + const [score, setScore] = useState(0); + const [quizComplete, setQuizComplete] = useState(false); + const [answers, setAnswers] = useState([]); + const quiz = quizQuery.payload; + const weekOf = getCurrentSafetyQuizWeek(new Date()); + const canViewCompliance = userRole === 'director' || userRole === 'superintendent'; + const complianceQuery = useSafetyQuizCompliance(weekOf, canViewCompliance); + const saveResultMutation = useSaveSafetyQuizResult(); + const complianceRows = complianceQuery.data ?? []; + + function startQuiz() { + setQuizStarted(true); + } + + function selectAnswer(answerIndex: number) { + const question = quiz?.questions[currentQuestionIndex]; + + if (showExplanation || !question) { + return; + } + + setSelectedAnswer(answerIndex); + setShowExplanation(true); + + if (answerIndex === question.correctIndex) { + setScore((currentScore) => currentScore + 1); + } + } + + async function goToNextQuestion() { + if (selectedAnswer === null || !quiz) { + return; + } + + const nextAnswers = [...answers, selectedAnswer]; + + if (currentQuestionIndex < quiz.questions.length - 1) { + setAnswers(nextAnswers); + setCurrentQuestionIndex((current) => current + 1); + setSelectedAnswer(null); + setShowExplanation(false); + return; + } + + const finalScore = calculateSafetyQuizScore(quiz, nextAnswers); + setQuizComplete(true); + setAnswers(nextAnswers); + setScore(finalScore); + await saveResultMutation.mutateAsync({ + quizId: quiz.id, + quizTitle: quiz.title, + score: finalScore, + totalQuestions: quiz.questions.length, + answers: nextAnswers, + weekOf, + }); + } + + function resetQuiz() { + setQuizStarted(false); + setCurrentQuestionIndex(0); + setSelectedAnswer(null); + setShowExplanation(false); + setScore(0); + setQuizComplete(false); + setAnswers([]); + } + + return { + quiz, + weekOf, + stage: quizComplete ? 'complete' : quizStarted ? 'question' : 'intro', + currentQuestionIndex, + selectedAnswer, + showExplanation, + score, + answers, + complianceRows, + completionSummary: calculateSafetyQuizCompletionSummary(complianceRows), + canViewCompliance, + canManageQuizContent: canViewCompliance, + isQuizLoading: quizQuery.isLoading, + isComplianceLoading: complianceQuery.isLoading, + isSavingResult: saveResultMutation.isPending, + quizError: quizQuery.error, + complianceError: complianceQuery.error, + saveResultError: saveResultMutation.error, + startQuiz, + selectAnswer, + goToNextQuestion, + resetQuiz, + }; +} + +export function useSafetyQuizContentEditor(): SafetyQuizContentEditor { + const queryClient = useQueryClient(); + const [draftOverride, setDraftOverride] = useState(null); + const [validationError, setValidationError] = useState(null); + const [savedMessage, setSavedMessage] = useState(null); + const contentType = CONTENT_CATALOG_TYPES.safetyQbsQuiz; + const managedQuery = useQuery({ + queryKey: [...CONTENT_CATALOG_QUERY_KEYS.content, 'managed', contentType], + queryFn: () => getManagedContentCatalog(contentType), + }); + const saveMutation = useMutation({ + mutationFn: (payload: SafetyQuiz) => updateManagedContentCatalog(contentType, { payload }), + onSuccess: async (response) => { + const serializedPayload = serializeSafetyQuizPayload(response.payload); + setDraftOverride(serializedPayload); + setValidationError(null); + setSavedMessage('Safety quiz content saved.'); + await Promise.all([ + queryClient.invalidateQueries({ queryKey: [...CONTENT_CATALOG_QUERY_KEYS.content, contentType] }), + queryClient.invalidateQueries({ queryKey: [...CONTENT_CATALOG_QUERY_KEYS.content, 'managed', contentType] }), + ]); + }, + }); + const serverDraft = useMemo( + () => (managedQuery.data?.payload ? serializeSafetyQuizPayload(managedQuery.data.payload) : ''), + [managedQuery.data], + ); + const draft = draftOverride ?? serverDraft; + + const canSave = useMemo( + () => Boolean(draft.trim()) && !managedQuery.isLoading && !saveMutation.isPending, + [draft, managedQuery.isLoading, saveMutation.isPending], + ); + + return { + draft, + isLoading: managedQuery.isLoading, + isSaving: saveMutation.isPending, + errorMessage: getOptionalErrorMessage( + managedQuery.error || saveMutation.error, + 'Safety quiz content could not be loaded or saved.', + ), + validationError, + savedMessage, + canSave, + setDraft: (nextDraft) => { + setDraftOverride(nextDraft); + setValidationError(null); + setSavedMessage(null); + }, + reset: () => { + setDraftOverride(null); + setValidationError(null); + setSavedMessage(null); + }, + save: async () => { + const result = parseSafetyQuizPayload(draft); + + if (typeof result === 'string') { + setValidationError(result); + setSavedMessage(null); + return; + } + + await saveMutation.mutateAsync(result); + }, + }; +} diff --git a/frontend/src/business/safety-quiz/mappers.test.ts b/frontend/src/business/safety-quiz/mappers.test.ts new file mode 100644 index 0000000..205cd3a --- /dev/null +++ b/frontend/src/business/safety-quiz/mappers.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from 'vitest'; +import { + toSafetyQuizComplianceRow, + toSafetyQuizResultCreateDto, +} from '@/business/safety-quiz/mappers'; +import { + calculateSafetyQuizCompletionSummary, + calculateSafetyQuizProgress, + calculateSafetyQuizScore, + getSafetyQuizFeedback, +} from '@/business/safety-quiz/selectors'; +import type { SafetyQuizSubmission } from '@/business/safety-quiz/types'; +import type { SafetyQuiz } from '@/shared/types/app'; +import type { SafetyQuizResultDto } from '@/shared/types/safetyQuiz'; + +function createResult(overrides: Partial = {}): SafetyQuizResultDto { + return { + id: 'result-1', + quiz_id: 'quiz-1', + quiz_title: 'QBS Week 1', + week_of: '2026-06-08', + score: 4, + total_questions: 5, + answers: [0, 1, 2, 3, 0], + user_name: 'Ava Lee', + user_role: 'para', + completed_at: '2026-06-08T12:00:00.000Z', + organizationId: 'org-1', + campusId: 'campus-1', + userId: 'user-1', + createdAt: '2026-06-08T12:00:00.000Z', + updatedAt: '2026-06-08T12:00:00.000Z', + ...overrides, + }; +} + +describe('safety quiz mappers', () => { + it('maps result DTOs to compliance rows with user-facing labels', () => { + expect(toSafetyQuizComplianceRow(createResult())).toEqual({ + name: 'Ava Lee', + role: 'Para', + status: 'complete', + score: '4/5', + date: 'Jun 8', + }); + expect(toSafetyQuizComplianceRow(createResult({ user_role: 'superintendent' })).role).toBe('Superintendent'); + }); + + it('maps quiz submission to backend create DTO', () => { + const submission: SafetyQuizSubmission = { + quizId: 'quiz-1', + quizTitle: 'QBS Week 1', + weekOf: '2026-06-08', + score: 4, + totalQuestions: 5, + answers: [0, 1, 2, 3, 0], + }; + + expect(toSafetyQuizResultCreateDto(submission)).toEqual({ + quiz_id: 'quiz-1', + quiz_title: 'QBS Week 1', + week_of: '2026-06-08', + score: 4, + total_questions: 5, + answers: [0, 1, 2, 3, 0], + }); + }); +}); + +describe('safety quiz selectors', () => { + const quiz: SafetyQuiz = { + id: 'quiz-1', + title: 'QBS Week 1', + focus: 'de-escalation', + weeklyFocus: { + title: 'Focus', + description: 'Description', + }, + keyReminders: ['Reminder'], + questions: [ + { + id: 'question-1', + question: 'Question 1', + options: ['A', 'B'], + correctIndex: 1, + explanation: 'Explanation 1', + }, + { + id: 'question-2', + question: 'Question 2', + options: ['A', 'B'], + correctIndex: 0, + explanation: 'Explanation 2', + }, + ], + }; + + it('calculates score and question progress', () => { + expect(calculateSafetyQuizScore(quiz, [1, 1])).toBe(1); + expect(calculateSafetyQuizProgress(0, 2)).toBe(50); + expect(calculateSafetyQuizProgress(0, 0)).toBe(0); + }); + + it('returns result feedback by score', () => { + expect(getSafetyQuizFeedback(2, 2)).toBe('Perfect score! Outstanding safety knowledge.'); + expect(getSafetyQuizFeedback(3, 5)).toBe('Great job! Review the explanations for any missed questions.'); + expect(getSafetyQuizFeedback(2, 5)).toBe('Please review the material and retake when ready.'); + }); + + it('summarizes compliance rows', () => { + expect( + calculateSafetyQuizCompletionSummary([ + { name: 'Ava', role: 'Teacher', status: 'complete', score: '5/5', date: 'Jun 8' }, + { name: 'Ben', role: 'Para', status: 'complete', score: '4/5', date: 'Jun 8' }, + ]), + ).toEqual({ + completedCount: 2, + totalStaff: 2, + completionRate: 100, + }); + }); +}); diff --git a/frontend/src/business/safety-quiz/mappers.ts b/frontend/src/business/safety-quiz/mappers.ts new file mode 100644 index 0000000..da22bbb --- /dev/null +++ b/frontend/src/business/safety-quiz/mappers.ts @@ -0,0 +1,46 @@ +import { SafetyQuizResultCreateDto, SafetyQuizResultDto } from '@/shared/types/safetyQuiz'; +import { SafetyQuizComplianceRow, SafetyQuizSubmission } from '@/business/safety-quiz/types'; + +function toRoleLabel(role: string): string { + if (role === 'para') { + return 'Para'; + } + + if (role === 'director') { + return 'Director'; + } + + if (role === 'superintendent') { + return 'Superintendent'; + } + + if (role === 'office') { + return 'Office'; + } + + return 'Teacher'; +} + +export function toSafetyQuizComplianceRow(dto: SafetyQuizResultDto): SafetyQuizComplianceRow { + return { + name: dto.user_name, + role: toRoleLabel(dto.user_role), + status: 'complete', + score: `${dto.score}/${dto.total_questions}`, + date: new Date(dto.completed_at).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + }), + }; +} + +export function toSafetyQuizResultCreateDto(submission: SafetyQuizSubmission): SafetyQuizResultCreateDto { + return { + quiz_id: submission.quizId, + quiz_title: submission.quizTitle, + week_of: submission.weekOf, + score: submission.score, + total_questions: submission.totalQuestions, + answers: submission.answers, + }; +} diff --git a/frontend/src/business/safety-quiz/selectors.test.ts b/frontend/src/business/safety-quiz/selectors.test.ts new file mode 100644 index 0000000..b9b9dd0 --- /dev/null +++ b/frontend/src/business/safety-quiz/selectors.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; + +import { + parseSafetyQuizPayload, + serializeSafetyQuizPayload, +} from '@/business/safety-quiz/selectors'; +import type { SafetyQuiz } from '@/shared/types/app'; + +const quiz: SafetyQuiz = { + id: 'qbs-weekly', + title: 'Weekly Safety Quiz', + focus: 'de-escalation', + weeklyFocus: { + title: 'De-escalation', + description: 'Practice calm responses.', + }, + keyReminders: ['Use calm voice'], + questions: [ + { + id: 'q1', + question: 'What comes first?', + options: ['Reduce demands', 'Raise voice'], + correctIndex: 0, + explanation: 'Reducing demands helps prevent escalation.', + }, + ], +}; + +describe('safety quiz selectors', () => { + it('serializes and parses editable safety quiz payloads', () => { + expect(parseSafetyQuizPayload(serializeSafetyQuizPayload(quiz))).toEqual(quiz); + }); + + it('rejects incomplete editable safety quiz payloads', () => { + expect(parseSafetyQuizPayload(JSON.stringify({ ...quiz, questions: [] }))).toBe('Questions must be a non-empty array.'); + expect(parseSafetyQuizPayload(JSON.stringify({ ...quiz, focus: 'invalid' }))).toBe( + 'Safety quiz focus must be physical-management, de-escalation, or safety-reminders.', + ); + }); +}); diff --git a/frontend/src/business/safety-quiz/selectors.ts b/frontend/src/business/safety-quiz/selectors.ts new file mode 100644 index 0000000..7a5f0eb --- /dev/null +++ b/frontend/src/business/safety-quiz/selectors.ts @@ -0,0 +1,153 @@ +import type { SafetyQuiz } from '@/shared/types/app'; +import type { + SafetyQuizCompletionSummary, + SafetyQuizComplianceRow, +} from '@/business/safety-quiz/types'; + +export function getCurrentSafetyQuizWeek(date: Date): string { + const weekStart = new Date(date); + weekStart.setHours(0, 0, 0, 0); + weekStart.setDate(weekStart.getDate() - weekStart.getDay()); + + return weekStart.toISOString().slice(0, 10); +} + +export function calculateSafetyQuizScore( + quiz: SafetyQuiz, + answers: readonly number[], +): number { + return answers.filter((answer, index) => answer === quiz.questions[index]?.correctIndex).length; +} + +export function calculateSafetyQuizProgress( + currentQuestionIndex: number, + questionCount: number, +): number { + if (questionCount <= 0) { + return 0; + } + + return Math.round(((currentQuestionIndex + 1) / questionCount) * 100); +} + +export function getSafetyQuizFeedback( + score: number, + totalQuestions: number, +): string { + if (score === totalQuestions) { + return 'Perfect score! Outstanding safety knowledge.'; + } + + if (score >= 3) { + return 'Great job! Review the explanations for any missed questions.'; + } + + return 'Please review the material and retake when ready.'; +} + +export function calculateSafetyQuizCompletionSummary( + complianceRows: readonly SafetyQuizComplianceRow[], +): SafetyQuizCompletionSummary { + const completedCount = complianceRows.filter((row) => row.status === 'complete').length; + const totalStaff = complianceRows.length; + + return { + completedCount, + totalStaff, + completionRate: totalStaff > 0 ? Math.round((completedCount / totalStaff) * 100) : 0, + }; +} + +export function serializeSafetyQuizPayload(payload: SafetyQuiz): string { + return JSON.stringify(payload, null, 2); +} + +export function parseSafetyQuizPayload(draft: string): SafetyQuiz | string { + try { + const parsed: unknown = JSON.parse(draft); + return validateSafetyQuizPayload(parsed); + } catch (error) { + return error instanceof Error && error.message ? error.message : 'Safety quiz JSON is invalid.'; + } +} + +function validateSafetyQuizPayload(value: unknown): SafetyQuiz | string { + if (!isRecord(value)) { + return 'Safety quiz payload must be a JSON object.'; + } + + if (!isNonEmptyString(value.id) || !isNonEmptyString(value.title)) { + return 'Safety quiz payload must include id and title.'; + } + + if (!isSafetyQuizFocus(value.focus)) { + return 'Safety quiz focus must be physical-management, de-escalation, or safety-reminders.'; + } + + if (!isRecord(value.weeklyFocus) + || !isNonEmptyString(value.weeklyFocus.title) + || !isNonEmptyString(value.weeklyFocus.description)) { + return 'Weekly focus must include title and description.'; + } + + const keyReminders = value.keyReminders; + if (!Array.isArray(keyReminders) || !keyReminders.every(isNonEmptyString)) { + return 'Key reminders must be an array of non-empty strings.'; + } + + const questions = value.questions; + if (!Array.isArray(questions) || questions.length === 0) { + return 'Questions must be a non-empty array.'; + } + + for (const question of questions) { + if (!isValidSafetyQuizQuestion(question)) { + return 'Each question must include id, question, options, correctIndex, and explanation.'; + } + } + + return { + id: value.id, + title: value.title, + focus: value.focus, + weeklyFocus: { + title: value.weeklyFocus.title, + description: value.weeklyFocus.description, + }, + keyReminders, + questions, + }; +} + +function isValidSafetyQuizQuestion(value: unknown): value is SafetyQuiz['questions'][number] { + if (!isRecord(value)) { + return false; + } + + const correctIndex = value.correctIndex; + + return isNonEmptyString(value.id) + && isNonEmptyString(value.question) + && Array.isArray(value.options) + && value.options.length > 0 + && value.options.every(isNonEmptyString) + && typeof correctIndex === 'number' + && Number.isInteger(correctIndex) + && correctIndex >= 0 + && correctIndex < value.options.length + && isNonEmptyString(value.explanation); +} + +function isSafetyQuizFocus(value: unknown): value is SafetyQuiz['focus'] { + return value === 'physical-management' + || value === 'de-escalation' + || value === 'safety-reminders'; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0; +} diff --git a/frontend/src/business/safety-quiz/types.ts b/frontend/src/business/safety-quiz/types.ts new file mode 100644 index 0000000..5832512 --- /dev/null +++ b/frontend/src/business/safety-quiz/types.ts @@ -0,0 +1,62 @@ +export interface SafetyQuizComplianceRow { + readonly name: string; + readonly role: string; + readonly status: 'complete'; + readonly score: string; + readonly date: string; +} + +export interface SafetyQuizSubmission { + readonly quizId: string; + readonly quizTitle: string; + readonly weekOf: string; + readonly score: number; + readonly totalQuestions: number; + readonly answers: readonly number[]; +} + +export interface SafetyQuizCompletionSummary { + readonly completedCount: number; + readonly totalStaff: number; + readonly completionRate: number; +} + +export type SafetyQuizStage = 'intro' | 'question' | 'complete'; + +export interface SafetyQuizPage { + readonly quiz: import('@/shared/types/app').SafetyQuiz | null; + readonly weekOf: string; + readonly stage: SafetyQuizStage; + readonly currentQuestionIndex: number; + readonly selectedAnswer: number | null; + readonly showExplanation: boolean; + readonly score: number; + readonly answers: readonly number[]; + readonly complianceRows: readonly SafetyQuizComplianceRow[]; + readonly completionSummary: SafetyQuizCompletionSummary; + readonly canViewCompliance: boolean; + readonly canManageQuizContent: boolean; + readonly isQuizLoading: boolean; + readonly isComplianceLoading: boolean; + readonly isSavingResult: boolean; + readonly quizError: unknown; + readonly complianceError: unknown; + readonly saveResultError: unknown; + readonly startQuiz: () => void; + readonly selectAnswer: (answerIndex: number) => void; + readonly goToNextQuestion: () => Promise; + readonly resetQuiz: () => void; +} + +export interface SafetyQuizContentEditor { + readonly draft: string; + readonly isLoading: boolean; + readonly isSaving: boolean; + readonly errorMessage: string | null; + readonly validationError: string | null; + readonly savedMessage: string | null; + readonly canSave: boolean; + readonly setDraft: (draft: string) => void; + readonly reset: () => void; + readonly save: () => Promise; +} diff --git a/frontend/src/business/sign-language/hooks.ts b/frontend/src/business/sign-language/hooks.ts new file mode 100644 index 0000000..aa3fb8b --- /dev/null +++ b/frontend/src/business/sign-language/hooks.ts @@ -0,0 +1,123 @@ +import { useMemo, useState } from 'react'; + +import { useContentCatalogPayload } from '@/business/content-catalog/hooks'; +import { + buildSignLanguageCategories, + filterSignLanguageItems, + getSignLanguageProgressPercent, +} from '@/business/sign-language/selectors'; +import type { + SignLanguagePage, + SignLanguageVideoModalState, +} from '@/business/sign-language/types'; +import { useLearnedSignsProgress } from '@/business/user-progress/hooks'; +import { CONTENT_CATALOG_TYPES } from '@/shared/constants/contentCatalog'; +import type { + SignLanguageCategoryFilter, + SignLanguageViewMode, +} from '@/shared/constants/signLanguage'; +import { getOptionalErrorMessage } from '@/shared/errors/errorMessages'; +import type { + SignItem, + SignLanguagePageContent, +} from '@/shared/types/app'; + +const EMPTY_SIGN_LANGUAGE_PAGE_CONTENT: SignLanguagePageContent = { + rememberTitle: '', + rememberDescription: '', +}; + +export function useSignLanguagePage(): SignLanguagePage { + const signsQuery = useContentCatalogPayload( + CONTENT_CATALOG_TYPES.signLanguageItems, + [], + ); + const pageContentQuery = useContentCatalogPayload( + CONTENT_CATALOG_TYPES.signLanguagePageContent, + EMPTY_SIGN_LANGUAGE_PAGE_CONTENT, + ); + const progress = useLearnedSignsProgress(); + const [searchQuery, setSearchQuery] = useState(''); + const [categoryFilter, setCategoryFilter] = useState('all'); + const [selectedSignId, setSelectedSignId] = useState(null); + const signs = signsQuery.payload; + const filters = useMemo( + () => ({ + searchQuery, + categoryFilter, + }), + [categoryFilter, searchQuery], + ); + const categories = useMemo( + () => buildSignLanguageCategories(signs), + [signs], + ); + const filteredSigns = useMemo( + () => filterSignLanguageItems(signs, filters), + [filters, signs], + ); + const selectedSign = useMemo( + () => signs.find((sign) => sign.id === selectedSignId) ?? null, + [selectedSignId, signs], + ); + const progressPercent = useMemo( + () => getSignLanguageProgressPercent(signs, progress.learnedSignIds), + [progress.learnedSignIds, signs], + ); + + async function toggleLearned(id: string) { + const sign = signs.find((item) => item.id === id); + + if (!sign) { + return; + } + + await progress.toggleLearnedSign(id, sign.word); + } + + return { + signs, + filteredSigns, + categories, + filters, + learnedSignIds: progress.learnedSignIds, + learnedCount: progress.learnedSignIds.size, + progressPercent, + selectedSign, + pageContent: pageContentQuery.payload, + isLoading: signsQuery.isLoading || pageContentQuery.isLoading || progress.isLoading, + isSaving: progress.isSaving, + signsError: signsQuery.error, + pageContentError: pageContentQuery.error, + progressErrorMessage: getOptionalErrorMessage(progress.error), + setSearchQuery, + clearSearch: () => setSearchQuery(''), + setCategoryFilter, + selectSign: setSelectedSignId, + closeSign: () => setSelectedSignId(null), + toggleLearned, + }; +} + +export function useSignLanguageVideoModalState(): SignLanguageVideoModalState { + const [showSteps, setShowSteps] = useState(false); + const [viewMode, setViewMode] = useState('gif'); + const [gifLoaded, setGifLoaded] = useState(false); + const [gifError, setGifError] = useState(false); + + return { + showSteps, + viewMode, + gifLoaded, + gifError, + showStepGuide: () => setShowSteps(true), + hideStepGuide: () => setShowSteps(false), + toggleStepGuide: () => setShowSteps((current) => !current), + setViewMode, + markGifLoaded: () => setGifLoaded(true), + markGifFailed: () => { + setGifError(true); + setGifLoaded(true); + }, + }; +} diff --git a/frontend/src/business/sign-language/selectors.test.ts b/frontend/src/business/sign-language/selectors.test.ts new file mode 100644 index 0000000..b7117e6 --- /dev/null +++ b/frontend/src/business/sign-language/selectors.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest'; + +import { + buildSignLanguageCategories, + buildSignLanguageYoutubeSearchUrl, + filterSignLanguageItems, + getSignLanguageProgressPercent, + getSignLanguageVideoDurationSeconds, + toSignLanguageCategoryFilter, +} from '@/business/sign-language/selectors'; +import type { SignItem } from '@/shared/types/app'; + +const signs: readonly SignItem[] = [ + { + id: 'help', + word: 'Help', + category: 'basic-needs', + description: 'Help description', + image: 'help.jpg', + tip: 'Help tip', + videoUrl: 'https://example.com/help-video', + gifUrl: 'https://example.com/help.gif', + videoSteps: [ + { step: 1, instruction: 'First', duration: 3 }, + { step: 2, instruction: 'Second', duration: 3 }, + ], + }, + { + id: 'calm', + word: 'Calm', + category: 'emotional', + description: 'Calm description', + image: 'calm.jpg', + tip: 'Calm tip', + videoUrl: 'https://example.com/calm-video', + gifUrl: 'https://example.com/calm.gif', + videoSteps: [ + { step: 1, instruction: 'First', duration: 4 }, + ], + }, + { + id: 'sit', + word: 'Sit', + category: 'classroom', + description: 'Sit description', + image: 'sit.jpg', + tip: 'Sit tip', + videoUrl: 'https://example.com/sit-video', + gifUrl: 'https://example.com/sit.gif', + videoSteps: [], + }, +]; + +describe('sign language selectors', () => { + it('builds category counts from available signs', () => { + expect(buildSignLanguageCategories(signs)).toEqual([ + { value: 'all', label: 'All Signs', count: 3 }, + { value: 'basic-needs', label: 'Basic Needs', count: 1 }, + { value: 'emotional', label: 'Emotional', count: 1 }, + { value: 'classroom', label: 'Classroom', count: 1 }, + ]); + }); + + it('filters signs by normalized search and category', () => { + expect(filterSignLanguageItems(signs, { + searchQuery: ' he ', + categoryFilter: 'basic-needs', + })).toEqual([signs[0]]); + }); + + it('returns zero progress when there are no signs', () => { + expect(getSignLanguageProgressPercent([], new Set(['help']))).toBe(0); + }); + + it('calculates learned sign progress', () => { + expect(getSignLanguageProgressPercent(signs, new Set(['help', 'calm']))).toBeCloseTo(66.67, 2); + }); + + it('calculates video duration from step count and first duration', () => { + expect(getSignLanguageVideoDurationSeconds(signs[0])).toBe(6); + expect(getSignLanguageVideoDurationSeconds(signs[2])).toBe(0); + }); + + it('normalizes unknown category filters to all', () => { + expect(toSignLanguageCategoryFilter('emotional')).toBe('emotional'); + expect(toSignLanguageCategoryFilter('unknown')).toBe('all'); + }); + + it('builds encoded YouTube search URLs', () => { + expect(buildSignLanguageYoutubeSearchUrl('All Done')).toBe( + 'https://www.youtube.com/results?search_query=ASL%20sign%20language%20All%20Done%20tutorial', + ); + }); +}); diff --git a/frontend/src/business/sign-language/selectors.ts b/frontend/src/business/sign-language/selectors.ts new file mode 100644 index 0000000..e69153c --- /dev/null +++ b/frontend/src/business/sign-language/selectors.ts @@ -0,0 +1,68 @@ +import { + SIGN_LANGUAGE_CATEGORY_FILTERS, + SIGN_LANGUAGE_YOUTUBE_QUERY_PREFIX, + SIGN_LANGUAGE_YOUTUBE_QUERY_SUFFIX, + SIGN_LANGUAGE_YOUTUBE_SEARCH_URL, +} from '@/shared/constants/signLanguage'; +import type { SignLanguageCategoryFilter } from '@/shared/constants/signLanguage'; +import type { SignItem } from '@/shared/types/app'; +import type { + SignLanguageCategoryOption, + SignLanguageFilters, +} from '@/business/sign-language/types'; + +export function buildSignLanguageCategories( + signs: readonly SignItem[], +): readonly SignLanguageCategoryOption[] { + return SIGN_LANGUAGE_CATEGORY_FILTERS.map((filter) => ({ + ...filter, + count: filter.value === 'all' + ? signs.length + : signs.filter((sign) => sign.category === filter.value).length, + })); +} + +export function filterSignLanguageItems( + signs: readonly SignItem[], + filters: SignLanguageFilters, +): readonly SignItem[] { + const normalizedSearchQuery = filters.searchQuery.trim().toLowerCase(); + + return signs.filter((sign) => { + const matchesSearch = normalizedSearchQuery.length === 0 + || sign.word.toLowerCase().includes(normalizedSearchQuery); + const matchesCategory = filters.categoryFilter === 'all' + || sign.category === filters.categoryFilter; + + return matchesSearch && matchesCategory; + }); +} + +export function getSignLanguageProgressPercent( + signs: readonly SignItem[], + learnedSignIds: ReadonlySet, +): number { + if (signs.length === 0) { + return 0; + } + + return (learnedSignIds.size / signs.length) * 100; +} + +export function getSignLanguageVideoDurationSeconds(sign: SignItem): number { + const firstStepDuration = sign.videoSteps[0]?.duration ?? 3; + + return sign.videoSteps.length * firstStepDuration; +} + +export function toSignLanguageCategoryFilter(value: string): SignLanguageCategoryFilter { + const category = SIGN_LANGUAGE_CATEGORY_FILTERS.find((filter) => filter.value === value); + + return category?.value ?? 'all'; +} + +export function buildSignLanguageYoutubeSearchUrl(word: string): string { + const searchQuery = `${SIGN_LANGUAGE_YOUTUBE_QUERY_PREFIX} ${word} ${SIGN_LANGUAGE_YOUTUBE_QUERY_SUFFIX}`; + + return `${SIGN_LANGUAGE_YOUTUBE_SEARCH_URL}?search_query=${encodeURIComponent(searchQuery)}`; +} diff --git a/frontend/src/business/sign-language/types.ts b/frontend/src/business/sign-language/types.ts new file mode 100644 index 0000000..6934b99 --- /dev/null +++ b/frontend/src/business/sign-language/types.ts @@ -0,0 +1,55 @@ +import type { + SignItem, + SignLanguagePageContent, +} from '@/shared/types/app'; +import type { + SignLanguageCategoryFilter, + SignLanguageViewMode, +} from '@/shared/constants/signLanguage'; + +export interface SignLanguageCategoryOption { + readonly value: SignLanguageCategoryFilter; + readonly label: string; + readonly count: number; +} + +export interface SignLanguageFilters { + readonly searchQuery: string; + readonly categoryFilter: SignLanguageCategoryFilter; +} + +export interface SignLanguageVideoModalState { + readonly showSteps: boolean; + readonly viewMode: SignLanguageViewMode; + readonly gifLoaded: boolean; + readonly gifError: boolean; + readonly showStepGuide: () => void; + readonly hideStepGuide: () => void; + readonly toggleStepGuide: () => void; + readonly setViewMode: (viewMode: SignLanguageViewMode) => void; + readonly markGifLoaded: () => void; + readonly markGifFailed: () => void; +} + +export interface SignLanguagePage { + readonly signs: readonly SignItem[]; + readonly filteredSigns: readonly SignItem[]; + readonly categories: readonly SignLanguageCategoryOption[]; + readonly filters: SignLanguageFilters; + readonly learnedSignIds: ReadonlySet; + readonly learnedCount: number; + readonly progressPercent: number; + readonly selectedSign: SignItem | null; + readonly pageContent: SignLanguagePageContent; + readonly isLoading: boolean; + readonly isSaving: boolean; + readonly signsError: Error | null; + readonly pageContentError: Error | null; + readonly progressErrorMessage: string | null; + readonly setSearchQuery: (value: string) => void; + readonly clearSearch: () => void; + readonly setCategoryFilter: (value: SignLanguageCategoryFilter) => void; + readonly selectSign: (id: string) => void; + readonly closeSign: () => void; + readonly toggleLearned: (id: string) => Promise; +} diff --git a/frontend/src/business/staff-attendance/hooks.ts b/frontend/src/business/staff-attendance/hooks.ts new file mode 100644 index 0000000..ed6039a --- /dev/null +++ b/frontend/src/business/staff-attendance/hooks.ts @@ -0,0 +1,32 @@ +import { useQuery } from '@tanstack/react-query'; +import { + getStaffAttendanceSummary, + listStaffAttendanceRecords, +} from '@/shared/api/staffAttendance'; +import { STAFF_ATTENDANCE_QUERY_KEYS } from '@/shared/constants/staffAttendance'; +import type { StaffAttendanceFilter } from '@/shared/types/staffAttendance'; +import { + toStaffAttendanceRecordViewModel, + toStaffAttendanceSummaryViewModel, +} from '@/business/staff-attendance/mappers'; +import { mapApiListRows } from '@/shared/business/apiListRows'; + +export function useStaffAttendanceRecords(filter?: StaffAttendanceFilter) { + return useQuery({ + queryKey: [STAFF_ATTENDANCE_QUERY_KEYS.records, filter], + queryFn: () => mapApiListRows( + listStaffAttendanceRecords(filter), + toStaffAttendanceRecordViewModel, + ), + }); +} + +export function useStaffAttendanceSummary(filter?: StaffAttendanceFilter) { + return useQuery({ + queryKey: [STAFF_ATTENDANCE_QUERY_KEYS.summary, filter], + queryFn: async () => { + const response = await getStaffAttendanceSummary(filter); + return toStaffAttendanceSummaryViewModel(response); + }, + }); +} diff --git a/frontend/src/business/staff-attendance/mappers.test.ts b/frontend/src/business/staff-attendance/mappers.test.ts new file mode 100644 index 0000000..3b34177 --- /dev/null +++ b/frontend/src/business/staff-attendance/mappers.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; +import { + toStaffAttendanceRecordViewModel, + toStaffAttendanceSummaryViewModel, +} from '@/business/staff-attendance/mappers'; +import type { + StaffAttendanceRecordDto, + StaffAttendanceSummaryDto, +} from '@/shared/types/staffAttendance'; + +describe('staff attendance mappers', () => { + it('maps backend record DTO fields into the frontend view model shape', () => { + const dto: StaffAttendanceRecordDto = { + id: 'record-1', + date: '2026-06-08', + status: 'late', + note: 'Traffic delay', + user_name: 'Jordan Lee', + user_role: 'Teacher', + organizationId: 'org-1', + campusId: 'campus-1', + userId: 'user-1', + createdAt: '2026-06-08T08:00:00.000Z', + updatedAt: '2026-06-08T08:15:00.000Z', + }; + + expect(toStaffAttendanceRecordViewModel(dto)).toEqual({ + id: 'record-1', + date: '2026-06-08', + status: 'late', + note: 'Traffic delay', + userName: 'Jordan Lee', + userRole: 'Teacher', + }); + }); + + it('maps backend summary DTO fields into the frontend view model shape', () => { + const dto: StaffAttendanceSummaryDto = { + staffCount: 24, + recordsCount: 23, + present: 20, + late: 2, + absent: 1, + }; + + expect(toStaffAttendanceSummaryViewModel(dto)).toEqual({ + staffCount: 24, + recordsCount: 23, + present: 20, + late: 2, + absent: 1, + }); + }); +}); diff --git a/frontend/src/business/staff-attendance/mappers.ts b/frontend/src/business/staff-attendance/mappers.ts new file mode 100644 index 0000000..7a8e388 --- /dev/null +++ b/frontend/src/business/staff-attendance/mappers.ts @@ -0,0 +1,33 @@ +import type { + StaffAttendanceRecordDto, + StaffAttendanceSummaryDto, +} from '@/shared/types/staffAttendance'; +import type { + StaffAttendanceRecordViewModel, + StaffAttendanceSummaryViewModel, +} from '@/business/staff-attendance/types'; + +export function toStaffAttendanceRecordViewModel( + dto: StaffAttendanceRecordDto, +): StaffAttendanceRecordViewModel { + return { + id: dto.id, + date: dto.date, + status: dto.status, + note: dto.note, + userName: dto.user_name, + userRole: dto.user_role, + }; +} + +export function toStaffAttendanceSummaryViewModel( + dto: StaffAttendanceSummaryDto, +): StaffAttendanceSummaryViewModel { + return { + staffCount: dto.staffCount, + recordsCount: dto.recordsCount, + present: dto.present, + late: dto.late, + absent: dto.absent, + }; +} diff --git a/frontend/src/business/staff-attendance/selectors.test.ts b/frontend/src/business/staff-attendance/selectors.test.ts new file mode 100644 index 0000000..56ae4a0 --- /dev/null +++ b/frontend/src/business/staff-attendance/selectors.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; +import { + buildStaffAttendanceRollups, + countStaffAttendanceStatus, + recentStaffAttendanceRecords, + staffAttendanceRate, +} from '@/business/staff-attendance/selectors'; +import type { StaffAttendanceRecordViewModel } from '@/business/staff-attendance/types'; + +const records: readonly StaffAttendanceRecordViewModel[] = [ + { id: '1', date: '2026-06-08', status: 'present', note: null, userName: 'Ava Lee', userRole: 'teacher' }, + { id: '2', date: '2026-06-08', status: 'late', note: null, userName: 'Ava Lee', userRole: 'teacher' }, + { id: '3', date: '2026-06-08', status: 'absent', note: null, userName: 'Ben Kim', userRole: 'para' }, + { id: '4', date: '2026-06-09', status: 'absent', note: null, userName: 'Ben Kim', userRole: 'para' }, + { id: '5', date: '2026-06-10', status: 'absent', note: null, userName: 'Ben Kim', userRole: 'para' }, +]; + +describe('staff attendance selectors', () => { + it('counts records by attendance status', () => { + expect(countStaffAttendanceStatus(records, 'present')).toBe(1); + expect(countStaffAttendanceStatus(records, 'late')).toBe(1); + expect(countStaffAttendanceStatus(records, 'absent')).toBe(3); + }); + + it('calculates present attendance rate from the whole record set', () => { + expect(staffAttendanceRate(records)).toBe(20); + expect(staffAttendanceRate([])).toBe(0); + }); + + it('keeps recent records in source order with a hard limit', () => { + expect(recentStaffAttendanceRecords(records, 2)).toEqual(records.slice(0, 2)); + }); + + it('builds per-staff rollups and marks repeated absences as a downward trend', () => { + expect(buildStaffAttendanceRollups(records)).toEqual([ + { name: 'Ava Lee', present: 1, late: 1, absent: 0, trend: 'up' }, + { name: 'Ben Kim', present: 0, late: 0, absent: 3, trend: 'down' }, + ]); + }); +}); diff --git a/frontend/src/business/staff-attendance/selectors.ts b/frontend/src/business/staff-attendance/selectors.ts new file mode 100644 index 0000000..c522638 --- /dev/null +++ b/frontend/src/business/staff-attendance/selectors.ts @@ -0,0 +1,45 @@ +import type { + StaffAttendanceRecordViewModel, + StaffAttendanceRollup, +} from '@/business/staff-attendance/types'; + +export function countStaffAttendanceStatus( + records: readonly StaffAttendanceRecordViewModel[], + status: StaffAttendanceRecordViewModel['status'], +): number { + return records.filter((record) => record.status === status).length; +} + +export function staffAttendanceRate(records: readonly StaffAttendanceRecordViewModel[]): number { + if (records.length === 0) { + return 0; + } + + return Math.round((countStaffAttendanceStatus(records, 'present') / records.length) * 100); +} + +export function recentStaffAttendanceRecords( + records: readonly StaffAttendanceRecordViewModel[], + limit: number, +): readonly StaffAttendanceRecordViewModel[] { + return records.slice(0, limit); +} + +export function buildStaffAttendanceRollups( + records: readonly StaffAttendanceRecordViewModel[], +): readonly StaffAttendanceRollup[] { + const staffNames = Array.from(new Set(records.map((record) => record.userName))); + + return staffNames.map((name) => { + const staffRecords = records.filter((record) => record.userName === name); + const absent = countStaffAttendanceStatus(staffRecords, 'absent'); + + return { + name, + present: countStaffAttendanceStatus(staffRecords, 'present'), + late: countStaffAttendanceStatus(staffRecords, 'late'), + absent, + trend: absent > 2 ? 'down' : 'up', + }; + }); +} diff --git a/frontend/src/business/staff-attendance/types.ts b/frontend/src/business/staff-attendance/types.ts new file mode 100644 index 0000000..69c2c74 --- /dev/null +++ b/frontend/src/business/staff-attendance/types.ts @@ -0,0 +1,26 @@ +import type { StaffAttendanceStatus } from '@/shared/types/staffAttendance'; + +export interface StaffAttendanceRecordViewModel { + readonly id: string; + readonly date: string; + readonly status: StaffAttendanceStatus; + readonly note: string | null; + readonly userName: string; + readonly userRole: string | null; +} + +export interface StaffAttendanceRollup { + readonly name: string; + readonly present: number; + readonly late: number; + readonly absent: number; + readonly trend: 'up' | 'down'; +} + +export interface StaffAttendanceSummaryViewModel { + readonly staffCount: number; + readonly recordsCount: number; + readonly present: number; + readonly late: number; + readonly absent: number; +} diff --git a/frontend/src/business/top-bar/hooks.ts b/frontend/src/business/top-bar/hooks.ts new file mode 100644 index 0000000..2172ab3 --- /dev/null +++ b/frontend/src/business/top-bar/hooks.ts @@ -0,0 +1,69 @@ +import { useState } from 'react'; + +import { + countUnreadTopBarNotifications, + getTopBarCampusLabel, + getTopBarInitials, + getTopBarRoleLabel, +} from '@/business/top-bar/selectors'; +import type { + TopBarNotification, + TopBarPage, + UseTopBarPageOptions, +} from '@/business/top-bar/types'; + +const EMPTY_TOP_BAR_NOTIFICATIONS: readonly TopBarNotification[] = []; + +export function useTopBarPage({ + userRole, + userName, + campusInfo, + toggleSidebar, + isAuthenticated, + profile, + signOut: signOutAction, +}: UseTopBarPageOptions): TopBarPage { + const [showProfileMenu, setShowProfileMenu] = useState(false); + const [showNotifications, setShowNotifications] = useState(false); + const [showSignInModal, setShowSignInModal] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); + const [signOutError, setSignOutError] = useState(null); + const notifications = EMPTY_TOP_BAR_NOTIFICATIONS; + + async function signOut() { + setShowProfileMenu(false); + setSignOutError(null); + const result = await signOutAction(); + + if (result.error) { + setSignOutError(result.error); + } + } + + return { + userRole, + userName, + campusInfo, + isAuthenticated, + profileRoleLabel: profile ? getTopBarRoleLabel(profile.role) : getTopBarRoleLabel(userRole), + roleLabel: getTopBarRoleLabel(userRole), + initials: getTopBarInitials(userName), + campusLabel: getTopBarCampusLabel(campusInfo), + showProfileMenu, + showNotifications, + showSignInModal, + searchQuery, + signOutError, + notifications, + unreadCount: countUnreadTopBarNotifications(notifications), + toggleSidebar, + toggleProfileMenu: () => setShowProfileMenu((current) => !current), + closeProfileMenu: () => setShowProfileMenu(false), + toggleNotifications: () => setShowNotifications((current) => !current), + closeNotifications: () => setShowNotifications(false), + openSignInModal: () => setShowSignInModal(true), + closeSignInModal: () => setShowSignInModal(false), + setSearchQuery, + signOut, + }; +} diff --git a/frontend/src/business/top-bar/selectors.test.ts b/frontend/src/business/top-bar/selectors.test.ts new file mode 100644 index 0000000..1c9b6bd --- /dev/null +++ b/frontend/src/business/top-bar/selectors.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; + +import { + countUnreadTopBarNotifications, + getTopBarCampusLabel, + getTopBarInitials, + getTopBarRoleLabel, +} from '@/business/top-bar/selectors'; + +describe('top bar selectors', () => { + it('builds initials from display names', () => { + expect(getTopBarInitials('Guest')).toBe('G'); + expect(getTopBarInitials('Ada Lovelace')).toBe('AL'); + expect(getTopBarInitials(' Grace Hopper ')).toBe('GH'); + }); + + it('falls back to default campus label', () => { + expect(getTopBarCampusLabel()).toBe('Current Campus'); + }); + + it('uses shared auth role labels', () => { + expect(getTopBarRoleLabel('para')).toBe('Support Staff'); + expect(getTopBarRoleLabel('office')).toBe('Office Manager'); + }); + + it('counts unread notifications', () => { + expect(countUnreadTopBarNotifications([ + { id: '1', text: 'One', time: 'Now', unread: true }, + { id: '2', text: 'Two', time: 'Earlier', unread: false }, + ])).toBe(1); + }); +}); diff --git a/frontend/src/business/top-bar/selectors.ts b/frontend/src/business/top-bar/selectors.ts new file mode 100644 index 0000000..4bfd657 --- /dev/null +++ b/frontend/src/business/top-bar/selectors.ts @@ -0,0 +1,32 @@ +import { getAuthRoleLabel } from '@/business/auth/selectors'; +import { DEFAULT_CAMPUS_LABEL } from '@/shared/constants/campusDisplay'; +import type { + CampusInfo, + UserRole, +} from '@/shared/types/app'; +import type { TopBarNotification } from '@/business/top-bar/types'; + +export function getTopBarInitials(name: string): string { + return name + .trim() + .split(/\s+/u) + .filter(Boolean) + .map((part) => part[0]) + .join('') + .slice(0, 2) + .toUpperCase(); +} + +export function getTopBarCampusLabel(campusInfo?: CampusInfo): string { + return campusInfo?.fullName ?? DEFAULT_CAMPUS_LABEL; +} + +export function getTopBarRoleLabel(role: UserRole): string { + return getAuthRoleLabel(role); +} + +export function countUnreadTopBarNotifications( + notifications: readonly TopBarNotification[], +): number { + return notifications.filter((notification) => notification.unread).length; +} diff --git a/frontend/src/business/top-bar/types.ts b/frontend/src/business/top-bar/types.ts new file mode 100644 index 0000000..619d03e --- /dev/null +++ b/frontend/src/business/top-bar/types.ts @@ -0,0 +1,52 @@ +import type { AuthSessionState } from '@/business/auth/types'; +import type { + CampusInfo, + UserRole, +} from '@/shared/types/app'; + +export interface TopBarProps { + readonly userRole: UserRole; + readonly userName: string; + readonly campusInfo?: CampusInfo; + readonly toggleSidebar: () => void; +} + +export interface TopBarNotification { + readonly id: string; + readonly text: string; + readonly time: string; + readonly unread: boolean; +} + +export interface UseTopBarPageOptions extends TopBarProps { + readonly isAuthenticated: boolean; + readonly profile: AuthSessionState['profile']; + readonly signOut: AuthSessionState['signOut']; +} + +export interface TopBarPage { + readonly userRole: UserRole; + readonly userName: string; + readonly campusInfo?: CampusInfo; + readonly isAuthenticated: boolean; + readonly profileRoleLabel: string; + readonly roleLabel: string; + readonly initials: string; + readonly campusLabel: string; + readonly showProfileMenu: boolean; + readonly showNotifications: boolean; + readonly showSignInModal: boolean; + readonly searchQuery: string; + readonly signOutError: string | null; + readonly notifications: readonly TopBarNotification[]; + readonly unreadCount: number; + readonly toggleSidebar: () => void; + readonly toggleProfileMenu: () => void; + readonly closeProfileMenu: () => void; + readonly toggleNotifications: () => void; + readonly closeNotifications: () => void; + readonly openSignInModal: () => void; + readonly closeSignInModal: () => void; + readonly setSearchQuery: (value: string) => void; + readonly signOut: () => Promise; +} diff --git a/frontend/src/business/user-progress/hooks.ts b/frontend/src/business/user-progress/hooks.ts new file mode 100644 index 0000000..db72367 --- /dev/null +++ b/frontend/src/business/user-progress/hooks.ts @@ -0,0 +1,108 @@ +import { useQuery } from '@tanstack/react-query'; +import { + deleteUserProgressByItem, + listUserProgress, + upsertUserProgress, +} from '@/shared/api/userProgress'; +import { + USER_PROGRESS_QUERY_KEYS, + USER_PROGRESS_TYPES, + ZONE_CHECKIN_ITEM_ID, +} from '@/shared/constants/userProgress'; +import { toLearnedSignIds, toZoneColor } from '@/business/user-progress/mappers'; +import { LearnedSignsState, ZoneCheckInState } from '@/business/user-progress/types'; +import { ZoneColor } from '@/shared/types/app'; +import { selectApiListRows } from '@/shared/business/apiListRows'; +import { useInvalidatingMutation } from '@/shared/business/queryMutations'; + +const EMPTY_LEARNED_SIGNS = new Set(); + +export function useLearnedSignsProgress(): LearnedSignsState { + const progressQuery = useQuery({ + queryKey: USER_PROGRESS_QUERY_KEYS.signProgress, + queryFn: () => selectApiListRows( + listUserProgress(USER_PROGRESS_TYPES.signLearned), + toLearnedSignIds, + ), + }); + + const saveMutation = useInvalidatingMutation({ + mutationFn: ({ id, word }: { readonly id: string; readonly word: string }) => upsertUserProgress({ + progress_type: USER_PROGRESS_TYPES.signLearned, + item_id: id, + value: word, + }), + invalidateQueryKey: USER_PROGRESS_QUERY_KEYS.signProgress, + }); + + const deleteMutation = useInvalidatingMutation({ + mutationFn: (id: string) => deleteUserProgressByItem(USER_PROGRESS_TYPES.signLearned, id), + invalidateQueryKey: USER_PROGRESS_QUERY_KEYS.signProgress, + }); + + async function toggleLearnedSign(id: string, word: string) { + const learnedSignIds = progressQuery.data ?? EMPTY_LEARNED_SIGNS; + + if (learnedSignIds.has(id)) { + await deleteMutation.mutateAsync(id); + return; + } + + await saveMutation.mutateAsync({ id, word }); + } + + return { + learnedSignIds: progressQuery.data ?? EMPTY_LEARNED_SIGNS, + isLoading: progressQuery.isLoading, + isSaving: saveMutation.isPending || deleteMutation.isPending, + error: progressQuery.error || saveMutation.error || deleteMutation.error, + toggleLearnedSign, + }; +} + +export function useZoneCheckIn(): ZoneCheckInState { + const progressQuery = useQuery({ + queryKey: USER_PROGRESS_QUERY_KEYS.zoneCheckin, + queryFn: () => selectApiListRows( + listUserProgress( + USER_PROGRESS_TYPES.zoneCheckin, + ZONE_CHECKIN_ITEM_ID, + ), + (rows) => toZoneColor(rows[0]?.value || null), + ), + }); + + const saveMutation = useInvalidatingMutation({ + mutationFn: (zone: ZoneColor) => upsertUserProgress({ + progress_type: USER_PROGRESS_TYPES.zoneCheckin, + item_id: ZONE_CHECKIN_ITEM_ID, + value: zone, + }), + invalidateQueryKey: USER_PROGRESS_QUERY_KEYS.zoneCheckin, + }); + + const deleteMutation = useInvalidatingMutation({ + mutationFn: () => deleteUserProgressByItem( + USER_PROGRESS_TYPES.zoneCheckin, + ZONE_CHECKIN_ITEM_ID, + ), + invalidateQueryKey: USER_PROGRESS_QUERY_KEYS.zoneCheckin, + }); + + async function setZone(zone: ZoneColor) { + await saveMutation.mutateAsync(zone); + } + + async function resetZone() { + await deleteMutation.mutateAsync(); + } + + return { + currentZone: progressQuery.data ?? null, + isLoading: progressQuery.isLoading, + isSaving: saveMutation.isPending || deleteMutation.isPending, + error: progressQuery.error || saveMutation.error || deleteMutation.error, + setZone, + resetZone, + }; +} diff --git a/frontend/src/business/user-progress/mappers.test.ts b/frontend/src/business/user-progress/mappers.test.ts new file mode 100644 index 0000000..49c0e1e --- /dev/null +++ b/frontend/src/business/user-progress/mappers.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest'; +import { + toLearnedSignIds, + toZoneColor, +} from '@/business/user-progress/mappers'; +import type { UserProgressDto } from '@/shared/types/userProgress'; + +function createProgress(overrides: Partial = {}): UserProgressDto { + return { + id: 'progress-1', + progress_type: 'sign_learned', + item_id: 'hello', + value: null, + score: null, + metadata: null, + organizationId: 'org-1', + campusId: 'campus-1', + userId: 'user-1', + createdAt: '2026-06-08T12:00:00.000Z', + updatedAt: '2026-06-08T12:00:00.000Z', + ...overrides, + }; +} + +describe('user progress mappers', () => { + it('maps learned sign progress rows to a unique id set', () => { + const learnedSignIds = toLearnedSignIds([ + createProgress({ id: '1', item_id: 'hello' }), + createProgress({ id: '2', item_id: 'help' }), + createProgress({ id: '3', item_id: 'hello' }), + ]); + + expect([...learnedSignIds].sort()).toEqual(['hello', 'help']); + }); + + it('normalizes valid zone colors and rejects invalid values', () => { + expect(toZoneColor('blue')).toBe('blue'); + expect(toZoneColor('green')).toBe('green'); + expect(toZoneColor('yellow')).toBe('yellow'); + expect(toZoneColor('red')).toBe('red'); + expect(toZoneColor('purple')).toBeNull(); + expect(toZoneColor(null)).toBeNull(); + }); +}); diff --git a/frontend/src/business/user-progress/mappers.ts b/frontend/src/business/user-progress/mappers.ts new file mode 100644 index 0000000..0f9dda8 --- /dev/null +++ b/frontend/src/business/user-progress/mappers.ts @@ -0,0 +1,18 @@ +import { UserProgressDto } from '@/shared/types/userProgress'; +import { ZoneColor } from '@/shared/types/app'; + +export function toLearnedSignIds(progress: readonly UserProgressDto[]): ReadonlySet { + return new Set(progress.map((item) => item.item_id)); +} + +export function toZoneColor(value: string | null): ZoneColor | null { + if (!value) { + return null; + } + + if (value === 'blue' || value === 'green' || value === 'yellow' || value === 'red') { + return value; + } + + return null; +} diff --git a/frontend/src/business/user-progress/types.ts b/frontend/src/business/user-progress/types.ts new file mode 100644 index 0000000..b4f3dcb --- /dev/null +++ b/frontend/src/business/user-progress/types.ts @@ -0,0 +1,18 @@ +import { ZoneColor } from '@/shared/types/app'; + +export interface LearnedSignsState { + readonly learnedSignIds: ReadonlySet; + readonly isLoading: boolean; + readonly isSaving: boolean; + readonly error: Error | null; + readonly toggleLearnedSign: (id: string, word: string) => Promise; +} + +export interface ZoneCheckInState { + readonly currentZone: ZoneColor | null; + readonly isLoading: boolean; + readonly isSaving: boolean; + readonly error: Error | null; + readonly setZone: (zone: ZoneColor) => Promise; + readonly resetZone: () => Promise; +} diff --git a/frontend/src/business/vocational/hooks.ts b/frontend/src/business/vocational/hooks.ts new file mode 100644 index 0000000..b0b2879 --- /dev/null +++ b/frontend/src/business/vocational/hooks.ts @@ -0,0 +1,134 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { useContentCatalogPayload } from '@/business/content-catalog/hooks'; +import { CONTENT_CATALOG_TYPES } from '@/shared/constants/contentCatalog'; +import { + VOCATIONAL_CATEGORY_ICON_KEYS, + VOCATIONAL_CATEGORY_PREVIEW_LIMIT, + VOCATIONAL_CATEGORY_STYLE_CLASSES, + VOCATIONAL_SEARCH_DELAY_MS, +} from '@/shared/constants/vocational'; +import type { + VocationalCategoryPreview, + VocationalCategoryFilter, + VocationalOpportunity, +} from '@/shared/types/vocational'; +import { + calculateVocationalStats, + filterVocationalOpportunities, + listVocationalCategories, + normalizeVocationalZipCode, + searchVocationalOpportunitiesByZip, + toVocationalCategoryFilter, +} from '@/business/vocational/selectors'; + +export function useVocationalOpportunities() { + const contentCatalog = useContentCatalogPayload( + CONTENT_CATALOG_TYPES.vocationalOpportunities, + [], + ); + const searchTimeoutRef = useRef(null); + const [zipCode, setZipCode] = useState(''); + const [searchQuery, setSearchQuery] = useState(''); + const [categoryFilter, setCategoryFilter] = useState('all'); + const [expandedOpportunityId, setExpandedOpportunityId] = useState(null); + const [savedOpportunityIds, setSavedOpportunityIds] = useState>(new Set()); + const [hasSearched, setHasSearched] = useState(false); + const [isSearching, setIsSearching] = useState(false); + const [showFilters, setShowFilters] = useState(false); + const [results, setResults] = useState([]); + + const opportunities = contentCatalog.payload; + const categories = useMemo(() => listVocationalCategories(opportunities), [opportunities]); + const categoryPreviews = useMemo( + () => categories.slice(0, VOCATIONAL_CATEGORY_PREVIEW_LIMIT).map((category) => ({ + name: category, + iconKey: VOCATIONAL_CATEGORY_ICON_KEYS[category], + color: VOCATIONAL_CATEGORY_STYLE_CLASSES[category], + })), + [categories], + ); + const displayResults = useMemo( + () => filterVocationalOpportunities(results, searchQuery, categoryFilter), + [categoryFilter, results, searchQuery], + ); + const stats = useMemo( + () => calculateVocationalStats(displayResults, savedOpportunityIds.size), + [displayResults, savedOpportunityIds.size], + ); + + const updateZipCode = (value: string) => { + setZipCode(normalizeVocationalZipCode(value)); + }; + + const updateCategoryFilter = (value: string) => { + setCategoryFilter(toVocationalCategoryFilter(value, categories)); + }; + + const searchByZipCode = () => { + if (zipCode.length < 5 || isSearching) { + return; + } + + setIsSearching(true); + if (searchTimeoutRef.current !== null) { + window.clearTimeout(searchTimeoutRef.current); + } + + searchTimeoutRef.current = window.setTimeout(() => { + setResults(searchVocationalOpportunitiesByZip(opportunities, zipCode)); + setHasSearched(true); + setIsSearching(false); + searchTimeoutRef.current = null; + }, VOCATIONAL_SEARCH_DELAY_MS); + }; + + useEffect(() => () => { + if (searchTimeoutRef.current !== null) { + window.clearTimeout(searchTimeoutRef.current); + } + }, []); + + const toggleFilters = () => { + setShowFilters((current) => !current); + }; + + const toggleExpandedOpportunity = (id: string) => { + setExpandedOpportunityId((current) => (current === id ? null : id)); + }; + + const toggleSavedOpportunity = (id: string) => { + setSavedOpportunityIds((current) => { + const next = new Set(current); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + return next; + }); + }; + + return { + zipCode, + searchQuery, + categoryFilter, + expandedOpportunityId, + savedOpportunityIds, + hasSearched, + isSearching, + showFilters, + categories, + categoryPreviews, + displayResults, + stats, + isLoading: contentCatalog.isLoading, + error: contentCatalog.error, + updateZipCode, + setSearchQuery, + updateCategoryFilter, + searchByZipCode, + toggleFilters, + toggleExpandedOpportunity, + toggleSavedOpportunity, + }; +} diff --git a/frontend/src/business/vocational/selectors.test.ts b/frontend/src/business/vocational/selectors.test.ts new file mode 100644 index 0000000..1968025 --- /dev/null +++ b/frontend/src/business/vocational/selectors.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from 'vitest'; +import { + calculateVocationalStats, + filterVocationalOpportunities, + listVocationalCategories, + normalizeVocationalZipCode, + searchVocationalOpportunitiesByZip, + toVocationalCategoryFilter, +} from '@/business/vocational/selectors'; +import { + VOCATIONAL_EAST_VALLEY_DISTANCE_OFFSET, + VOCATIONAL_OTHER_DISTANCE_OFFSET, +} from '@/shared/constants/vocational'; +import type { VocationalOpportunity } from '@/shared/types/vocational'; + +const opportunities: readonly VocationalOpportunity[] = [ + { + id: 'garden', + company: 'Green Thumb Nursery', + title: 'Horticulture Assistant', + category: 'Agriculture & Gardening', + description: 'Plant care.', + address: '100 Garden Way', + zipCode: '85001', + phone: '(602) 555-0100', + email: 'garden@example.org', + website: 'garden.example.org', + distance: '1.5 mi', + schedule: 'Mon-Wed', + compensation: 'Stipend', + skills: ['Plant care'], + requirements: ['Outdoor comfort'], + accommodations: ['Visual checklist'], + ageGroup: '14-18', + spots: 4, + featured: true, + }, + { + id: 'bakery', + company: 'Sunrise Bakery', + title: 'Kitchen Prep Assistant', + category: 'Food Service', + description: 'Food prep.', + address: '200 Main St', + zipCode: '85003', + phone: '(602) 555-0200', + email: 'bakery@example.org', + website: 'bakery.example.org', + distance: '2.8 mi', + schedule: 'Tue-Thu', + compensation: 'Minimum Wage', + skills: ['Food safety'], + requirements: ['Food handler card'], + accommodations: ['Visual recipes'], + ageGroup: '16-22', + spots: 3, + featured: true, + }, + { + id: 'studio', + company: 'Creative Sparks Art Studio', + title: 'Studio Assistant', + category: 'Arts & Creative', + description: 'Art supply organization.', + address: '300 Art St', + zipCode: '85004', + phone: '(602) 555-0300', + email: 'studio@example.org', + website: 'studio.example.org', + distance: '3.9 mi', + schedule: 'Wed-Fri', + compensation: 'Stipend', + skills: ['Supply organization'], + requirements: ['Interest in art'], + accommodations: ['Quiet prep time'], + ageGroup: '14-22', + spots: 2, + featured: false, + }, +]; + +function getDistance(distance: string): number { + return Number.parseFloat(distance); +} + +describe('vocational selectors', () => { + it('normalizes zip code input to five digits', () => { + expect(normalizeVocationalZipCode('AZ 85001-1234')).toBe('85001'); + expect(normalizeVocationalZipCode('abc')).toBe(''); + }); + + it('lists unique categories in sorted order and normalizes filters', () => { + const categories = listVocationalCategories(opportunities); + + expect(categories).toEqual(['Agriculture & Gardening', 'Arts & Creative', 'Food Service']); + expect(toVocationalCategoryFilter('Food Service', categories)).toBe('Food Service'); + expect(toVocationalCategoryFilter('Unknown', categories)).toBe('all'); + expect(toVocationalCategoryFilter('all', categories)).toBe('all'); + }); + + it('searches by Phoenix, East Valley, and other zip ranges with expected distance offsets', () => { + const phoenixResults = searchVocationalOpportunitiesByZip(opportunities, '85001'); + const eastValleyResults = searchVocationalOpportunitiesByZip(opportunities, '85283'); + const otherResults = searchVocationalOpportunitiesByZip(opportunities, '85301'); + + expect(phoenixResults[0].id).toBe('garden'); + expect(getDistance(eastValleyResults[0].distance)).toBe( + getDistance(opportunities[0].distance) + VOCATIONAL_EAST_VALLEY_DISTANCE_OFFSET, + ); + expect(getDistance(otherResults[0].distance)).toBe( + getDistance(opportunities[0].distance) + VOCATIONAL_OTHER_DISTANCE_OFFSET, + ); + }); + + it('filters opportunities by trimmed search query and category', () => { + expect(filterVocationalOpportunities(opportunities, ' bakery ', 'all')).toEqual([opportunities[1]]); + expect(filterVocationalOpportunities(opportunities, 'assistant', 'Arts & Creative')).toEqual([opportunities[2]]); + expect(filterVocationalOpportunities(opportunities, 'assistant', 'Technology')).toEqual([]); + }); + + it('calculates display-result stats', () => { + expect(calculateVocationalStats(opportunities, 2)).toEqual({ + totalPositions: 9, + companies: 3, + categories: 3, + saved: 2, + }); + }); +}); diff --git a/frontend/src/business/vocational/selectors.ts b/frontend/src/business/vocational/selectors.ts new file mode 100644 index 0000000..4152044 --- /dev/null +++ b/frontend/src/business/vocational/selectors.ts @@ -0,0 +1,101 @@ +import { + VOCATIONAL_EAST_VALLEY_DISTANCE_OFFSET, + VOCATIONAL_EAST_VALLEY_ZIP_MAX, + VOCATIONAL_EAST_VALLEY_ZIP_MIN, + VOCATIONAL_OTHER_DISTANCE_OFFSET, + VOCATIONAL_PHOENIX_ZIP_MAX, + VOCATIONAL_PHOENIX_ZIP_MIN, +} from '@/shared/constants/vocational'; +import type { + VocationalCategory, + VocationalCategoryFilter, + VocationalOpportunity, + VocationalStats, +} from '@/shared/types/vocational'; + +function getDistanceValue(distance: string): number { + return Number.parseFloat(distance); +} + +function withDistanceOffset( + opportunity: VocationalOpportunity, + offset: number, +): VocationalOpportunity { + return { + ...opportunity, + distance: `${(getDistanceValue(opportunity.distance) + offset).toFixed(1)} mi`, + }; +} + +export function normalizeVocationalZipCode(value: string): string { + return value.replace(/\D/g, '').slice(0, 5); +} + +export function listVocationalCategories( + opportunities: readonly VocationalOpportunity[], +): readonly VocationalCategory[] { + return [...new Set(opportunities.map((opportunity) => opportunity.category))].sort(); +} + +export function toVocationalCategoryFilter( + value: string, + categories: readonly VocationalCategory[], +): VocationalCategoryFilter { + if (value === 'all') { + return 'all'; + } + + const found = categories.find((category) => category === value); + return found ?? 'all'; +} + +export function searchVocationalOpportunitiesByZip( + opportunities: readonly VocationalOpportunity[], + zipCode: string, +): readonly VocationalOpportunity[] { + const zipNumber = Number.parseInt(zipCode, 10); + + if (zipNumber >= VOCATIONAL_PHOENIX_ZIP_MIN && zipNumber <= VOCATIONAL_PHOENIX_ZIP_MAX) { + return [...opportunities].sort((a, b) => getDistanceValue(a.distance) - getDistanceValue(b.distance)); + } + + if (zipNumber >= VOCATIONAL_EAST_VALLEY_ZIP_MIN && zipNumber <= VOCATIONAL_EAST_VALLEY_ZIP_MAX) { + return opportunities + .map((opportunity) => withDistanceOffset(opportunity, VOCATIONAL_EAST_VALLEY_DISTANCE_OFFSET)) + .sort((a, b) => getDistanceValue(a.distance) - getDistanceValue(b.distance)); + } + + return opportunities.map((opportunity) => ( + withDistanceOffset(opportunity, VOCATIONAL_OTHER_DISTANCE_OFFSET) + )); +} + +export function filterVocationalOpportunities( + opportunities: readonly VocationalOpportunity[], + searchQuery: string, + categoryFilter: VocationalCategoryFilter, +): readonly VocationalOpportunity[] { + const normalizedQuery = searchQuery.trim().toLowerCase(); + + return opportunities.filter((opportunity) => { + const matchesSearch = normalizedQuery === '' + || opportunity.company.toLowerCase().includes(normalizedQuery) + || opportunity.title.toLowerCase().includes(normalizedQuery) + || opportunity.category.toLowerCase().includes(normalizedQuery); + const matchesCategory = categoryFilter === 'all' || opportunity.category === categoryFilter; + + return matchesSearch && matchesCategory; + }); +} + +export function calculateVocationalStats( + opportunities: readonly VocationalOpportunity[], + savedCount: number, +): VocationalStats { + return { + totalPositions: opportunities.reduce((sum, opportunity) => sum + opportunity.spots, 0), + companies: opportunities.length, + categories: new Set(opportunities.map((opportunity) => opportunity.category)).size, + saved: savedCount, + }; +} diff --git a/frontend/src/business/walkthrough/formHooks.ts b/frontend/src/business/walkthrough/formHooks.ts new file mode 100644 index 0000000..e467e87 --- /dev/null +++ b/frontend/src/business/walkthrough/formHooks.ts @@ -0,0 +1,155 @@ +import { useState } from 'react'; + +import { + toWalkthroughCheckinCreateDto, + type WalkthroughCommentDraft, + type WalkthroughRatingDraft, +} from '@/business/walkthrough/mappers'; +import { + canSubmitWalkthroughForm, + hasAllWalkthroughRatings, +} from '@/business/walkthrough/validators'; +import type { + WalkthroughCheckinCreateDto, + WalkthroughRatingKey, +} from '@/shared/types/walkthrough'; + +interface UseWalkthroughFormInput { + readonly userName: string; + readonly onSubmit: (data: WalkthroughCheckinCreateDto) => Promise; +} + +export interface WalkthroughFormState { + readonly teacherName: string; + readonly classroom: string; + readonly date: string; + readonly time: string; + readonly ratings: WalkthroughRatingDraft; + readonly comments: WalkthroughCommentDraft; + readonly overallNotes: string; + readonly activeTooltip: string | null; + readonly submitted: boolean; + readonly teacherNameTrimmed: string; + readonly classroomTrimmed: string; + readonly canSubmit: boolean; + readonly ratedCount: number; + readonly allRated: boolean; +} + +export interface WalkthroughFormActions { + readonly setTeacherName: (value: string) => void; + readonly setClassroom: (value: string) => void; + readonly setDate: (value: string) => void; + readonly setTime: (value: string) => void; + readonly setOverallNotes: (value: string) => void; + readonly setActiveTooltip: (value: string | null) => void; + readonly updateRating: (key: WalkthroughRatingKey, value: number) => void; + readonly updateComment: (key: WalkthroughRatingKey, value: string) => void; + readonly submit: () => Promise; + readonly reset: () => void; +} + +export interface WalkthroughFormWorkflow { + readonly state: WalkthroughFormState; + readonly actions: WalkthroughFormActions; +} + +function currentDateInputValue(): string { + return new Date().toISOString().split('T')[0]; +} + +function currentTimeInputValue(): string { + return new Date().toTimeString().slice(0, 5); +} + +export function useWalkthroughForm({ + userName, + onSubmit, +}: UseWalkthroughFormInput): WalkthroughFormWorkflow { + const [teacherName, setTeacherName] = useState(''); + const [classroom, setClassroom] = useState(''); + const [date, setDate] = useState(currentDateInputValue); + const [time, setTime] = useState(currentTimeInputValue); + const [ratings, setRatings] = useState({}); + const [comments, setComments] = useState({}); + const [overallNotes, setOverallNotes] = useState(''); + const [activeTooltip, setActiveTooltip] = useState(null); + const [submitted, setSubmitted] = useState(false); + + const teacherNameTrimmed = teacherName.trim(); + const classroomTrimmed = classroom.trim(); + const allRated = hasAllWalkthroughRatings(ratings); + const canSubmit = canSubmitWalkthroughForm({ + teacherName, + classroom, + directorName: userName, + ratings, + }); + + function updateRating(key: WalkthroughRatingKey, value: number) { + setRatings((current) => ({ ...current, [key]: value })); + } + + function updateComment(key: WalkthroughRatingKey, value: string) { + setComments((current) => ({ ...current, [key]: value })); + } + + async function submit() { + if (!canSubmit) return; + + const data = toWalkthroughCheckinCreateDto({ + teacherName: teacherNameTrimmed, + classroom: classroomTrimmed, + directorName: userName.trim(), + date, + time, + ratings, + comments, + overallNotes, + }); + await onSubmit(data); + setSubmitted(true); + } + + function reset() { + setTeacherName(''); + setClassroom(''); + setRatings({}); + setComments({}); + setOverallNotes(''); + setSubmitted(false); + setDate(currentDateInputValue()); + setTime(currentTimeInputValue()); + } + + return { + state: { + teacherName, + classroom, + date, + time, + ratings, + comments, + overallNotes, + activeTooltip, + submitted, + teacherNameTrimmed, + classroomTrimmed, + canSubmit, + ratedCount: Object.keys(ratings).length, + allRated, + }, + actions: { + setTeacherName, + setClassroom, + setDate, + setTime, + setOverallNotes, + setActiveTooltip, + updateRating, + updateComment, + submit, + reset, + }, + }; +} diff --git a/frontend/src/business/walkthrough/hooks.ts b/frontend/src/business/walkthrough/hooks.ts new file mode 100644 index 0000000..bacf91b --- /dev/null +++ b/frontend/src/business/walkthrough/hooks.ts @@ -0,0 +1,188 @@ +import { useMemo, useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { + createWalkthroughCheckin, + deleteWalkthroughCheckin, + listWalkthroughCheckins, +} from '@/shared/api/walkthrough'; +import type { WalkthroughCheckinCreateDto } from '@/shared/types/walkthrough'; +import { WALKTHROUGH_QUERY_KEYS } from '@/shared/constants/walkthrough'; +import type { WalkthroughSummaryRange } from '@/shared/constants/walkthrough'; +import type { + WalkthroughCheckInPage, + WalkthroughCheckInTab, + WalkthroughCheckInTabId, + WalkthroughGrowthPlan, +} from '@/business/walkthrough/types'; +import { + buildWalkthroughAutoSummary, + buildWalkthroughCheckInStats, + buildWalkthroughHistoryRows, + calculateWalkthroughCategoryAverages, + calculateWalkthroughOverallPctFromAverages, + calculateWalkthroughTeacherFrequencies, + calculateWalkthroughTrend, + createWalkthroughCategoryAverageMap, + filterWalkthroughCheckins, + findWalkthroughAttentionFlags, + findWalkthroughRecognitions, + getWalkthroughStatus, + listWalkthroughTeachers, +} from '@/business/walkthrough/selectors'; +import type { WalkthroughCheckinDto } from '@/shared/types/walkthrough'; +import { getApiListRows } from '@/shared/business/apiListRows'; +import { useInvalidatingMutation } from '@/shared/business/queryMutations'; +import { getOptionalErrorMessage } from '@/shared/errors/errorMessages'; + +const EMPTY_GROWTH_PLAN: WalkthroughGrowthPlan = { + strengths: '', + focusAreas: '', + nextSteps: '', + reviewDate: '', +}; + +const EMPTY_WALKTHROUGH_CHECKINS: readonly WalkthroughCheckinDto[] = []; + +const WALKTHROUGH_CHECK_IN_TABS: readonly WalkthroughCheckInTab[] = [ + { id: 'new', label: 'New Check-In' }, + { id: 'summary', label: 'Performance Summary' }, + { id: 'history', label: 'History' }, +]; + +export function useWalkthroughCheckins(teacherName?: string) { + return useQuery({ + queryKey: teacherName ? [...WALKTHROUGH_QUERY_KEYS.checkins, teacherName] : WALKTHROUGH_QUERY_KEYS.checkins, + queryFn: () => getApiListRows(listWalkthroughCheckins(teacherName)), + }); +} + +export function useSaveWalkthroughCheckin() { + return useInvalidatingMutation({ + mutationFn: (request: WalkthroughCheckinCreateDto) => createWalkthroughCheckin(request), + invalidateQueryKey: WALKTHROUGH_QUERY_KEYS.checkins, + }); +} + +export function useDeleteWalkthroughCheckin() { + return useInvalidatingMutation({ + mutationFn: (id: string) => deleteWalkthroughCheckin(id), + invalidateQueryKey: WALKTHROUGH_QUERY_KEYS.checkins, + }); +} + +export function useWalkthroughCheckInPage(userName: string): WalkthroughCheckInPage { + const [activeTab, setActiveTab] = useState('new'); + const checkinsQuery = useWalkthroughCheckins(); + const saveCheckinMutation = useSaveWalkthroughCheckin(); + + const checkins = checkinsQuery.data ?? EMPTY_WALKTHROUGH_CHECKINS; + const errorMessage = getOptionalErrorMessage( + checkinsQuery.error || saveCheckinMutation.error, + 'Walk-through data could not be saved or loaded. Please try again.', + ); + const stats = useMemo(() => buildWalkthroughCheckInStats(checkins), [checkins]); + const historyRows = useMemo(() => buildWalkthroughHistoryRows(checkins), [checkins]); + + return { + activeTab, + tabs: WALKTHROUGH_CHECK_IN_TABS, + checkins, + stats, + historyRows, + loading: checkinsQuery.isLoading, + submitting: saveCheckinMutation.isPending, + errorMessage, + userName, + setActiveTab, + refresh: async () => { + await checkinsQuery.refetch(); + }, + submit: async (data) => { + await saveCheckinMutation.mutateAsync(data); + }, + }; +} + +export function useWalkthroughSummary(checkins: readonly WalkthroughCheckinDto[]) { + const [selectedTeacher, setSelectedTeacher] = useState('all'); + const [timeRange, setTimeRange] = useState('60'); + const [presentationMode, setPresentationMode] = useState(false); + const [showReport, setShowReport] = useState(false); + const [growthPlan, setGrowthPlan] = useState(EMPTY_GROWTH_PLAN); + + const teachers = useMemo(() => listWalkthroughTeachers(checkins), [checkins]); + + const filteredCheckins = useMemo( + () => filterWalkthroughCheckins(checkins, selectedTeacher, timeRange), + [checkins, selectedTeacher, timeRange], + ); + + const categoryAverages = useMemo( + () => calculateWalkthroughCategoryAverages(filteredCheckins), + [filteredCheckins], + ); + + const categoryAverageByKey = useMemo( + () => createWalkthroughCategoryAverageMap(categoryAverages), + [categoryAverages], + ); + + const overallPct = useMemo( + () => calculateWalkthroughOverallPctFromAverages(categoryAverages, filteredCheckins.length), + [categoryAverages, filteredCheckins.length], + ); + + const status = useMemo(() => getWalkthroughStatus(overallPct), [overallPct]); + + const flags = useMemo( + () => findWalkthroughAttentionFlags(filteredCheckins, selectedTeacher), + [filteredCheckins, selectedTeacher], + ); + + const recognitions = useMemo( + () => findWalkthroughRecognitions(filteredCheckins, selectedTeacher), + [filteredCheckins, selectedTeacher], + ); + + const trendData = useMemo(() => calculateWalkthroughTrend(filteredCheckins), [filteredCheckins]); + + const autoSummary = useMemo( + () => buildWalkthroughAutoSummary( + filteredCheckins.length, + categoryAverages, + overallPct, + selectedTeacher, + timeRange, + ), + [categoryAverages, filteredCheckins.length, overallPct, selectedTeacher, timeRange], + ); + + const teacherFrequencies = useMemo( + () => calculateWalkthroughTeacherFrequencies(filteredCheckins, teachers), + [filteredCheckins, teachers], + ); + + return { + selectedTeacher, + timeRange, + presentationMode, + showReport, + growthPlan, + teachers, + filteredCheckins, + categoryAverages, + categoryAverageByKey, + overallPct, + status, + flags, + recognitions, + trendData, + autoSummary, + teacherFrequencies, + setSelectedTeacher, + setTimeRange, + setPresentationMode, + setShowReport, + setGrowthPlan, + }; +} diff --git a/frontend/src/business/walkthrough/mappers.test.ts b/frontend/src/business/walkthrough/mappers.test.ts new file mode 100644 index 0000000..311ec39 --- /dev/null +++ b/frontend/src/business/walkthrough/mappers.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from 'vitest'; +import { + toWalkthroughCheckinCreateDto, + type WalkthroughCommentDraft, + type WalkthroughRatingDraft, +} from '@/business/walkthrough/mappers'; +import { WALKTHROUGH_TEST_SEED } from '@/test-seeds/walkthrough'; + +describe('walkthrough mappers', () => { + it('maps a completed walkthrough draft into the backend create DTO shape', () => { + const ratings: WalkthroughRatingDraft = { + attitude: 4, + classroom_management: 3, + cleanliness: 4, + vibes: 3, + team_dynamics: 4, + emergency_exit: 3, + lesson_plan: 2, + }; + const comments: WalkthroughCommentDraft = { + attitude: ' Warm arrival routine ', + classroom_management: '', + cleanliness: 'Materials labeled', + vibes: undefined, + team_dynamics: ' Strong para coordination ', + emergency_exit: 'Door kit visible', + lesson_plan: ' Needs posting ', + }; + + expect( + toWalkthroughCheckinCreateDto({ + teacherName: WALKTHROUGH_TEST_SEED.teacherName, + classroom: WALKTHROUGH_TEST_SEED.classroom, + directorName: WALKTHROUGH_TEST_SEED.directorName, + date: '2026-06-08', + time: '09:15', + ratings, + comments, + overallNotes: ' Follow up on lesson plan posting. ', + }), + ).toEqual({ + teacher_name: WALKTHROUGH_TEST_SEED.teacherName, + classroom: WALKTHROUGH_TEST_SEED.classroom, + director_name: WALKTHROUGH_TEST_SEED.directorName, + check_in_date: '2026-06-08', + check_in_time: '09:15', + attitude_rating: 4, + attitude_comment: 'Warm arrival routine', + classroom_management_rating: 3, + classroom_management_comment: undefined, + cleanliness_rating: 4, + cleanliness_comment: 'Materials labeled', + vibes_rating: 3, + vibes_comment: undefined, + team_dynamics_rating: 4, + team_dynamics_comment: 'Strong para coordination', + emergency_exit_rating: 3, + emergency_exit_comment: 'Door kit visible', + lesson_plan_rating: 2, + lesson_plan_comment: 'Needs posting', + overall_notes: 'Follow up on lesson plan posting.', + }); + }); + + it('omits blank optional text fields from the backend create DTO shape', () => { + const ratings: WalkthroughRatingDraft = { + attitude: 4, + classroom_management: 4, + cleanliness: 4, + vibes: 4, + team_dynamics: 4, + emergency_exit: 3, + lesson_plan: 3, + }; + + expect( + toWalkthroughCheckinCreateDto({ + teacherName: WALKTHROUGH_TEST_SEED.teacherName, + classroom: WALKTHROUGH_TEST_SEED.classroom, + directorName: WALKTHROUGH_TEST_SEED.directorName, + date: '2026-06-08', + time: '10:30', + ratings, + comments: {}, + overallNotes: ' ', + }), + ).toMatchObject({ + attitude_comment: undefined, + classroom_management_comment: undefined, + cleanliness_comment: undefined, + vibes_comment: undefined, + team_dynamics_comment: undefined, + emergency_exit_comment: undefined, + lesson_plan_comment: undefined, + overall_notes: undefined, + }); + }); + + it('fails explicitly when a required walkthrough rating is missing', () => { + const ratings: WalkthroughRatingDraft = { + attitude: 4, + classroom_management: 4, + cleanliness: 4, + vibes: 4, + team_dynamics: 4, + emergency_exit: 3, + }; + + expect(() => + toWalkthroughCheckinCreateDto({ + teacherName: WALKTHROUGH_TEST_SEED.teacherName, + classroom: WALKTHROUGH_TEST_SEED.classroom, + directorName: WALKTHROUGH_TEST_SEED.directorName, + date: '2026-06-08', + time: '11:00', + ratings, + comments: {}, + overallNotes: '', + }), + ).toThrow('Missing walkthrough rating for lesson_plan'); + }); +}); diff --git a/frontend/src/business/walkthrough/mappers.ts b/frontend/src/business/walkthrough/mappers.ts new file mode 100644 index 0000000..bd90e1e --- /dev/null +++ b/frontend/src/business/walkthrough/mappers.ts @@ -0,0 +1,56 @@ +import type { + WalkthroughCheckinCreateDto, + WalkthroughRatingKey, +} from '@/shared/types/walkthrough'; + +export type WalkthroughRatingDraft = Partial>; +export type WalkthroughCommentDraft = Partial>; + +function optionalText(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +} + +function requiredRating(ratings: WalkthroughRatingDraft, key: WalkthroughRatingKey): number { + const value = ratings[key]; + + if (value === undefined) { + throw new Error(`Missing walkthrough rating for ${key}`); + } + + return value; +} + +export function toWalkthroughCheckinCreateDto(input: { + readonly teacherName: string; + readonly classroom: string; + readonly directorName: string; + readonly date: string; + readonly time: string; + readonly ratings: WalkthroughRatingDraft; + readonly comments: WalkthroughCommentDraft; + readonly overallNotes: string; +}): WalkthroughCheckinCreateDto { + return { + teacher_name: input.teacherName, + classroom: input.classroom, + director_name: input.directorName, + check_in_date: input.date, + check_in_time: input.time, + attitude_rating: requiredRating(input.ratings, 'attitude'), + attitude_comment: optionalText(input.comments.attitude), + classroom_management_rating: requiredRating(input.ratings, 'classroom_management'), + classroom_management_comment: optionalText(input.comments.classroom_management), + cleanliness_rating: requiredRating(input.ratings, 'cleanliness'), + cleanliness_comment: optionalText(input.comments.cleanliness), + vibes_rating: requiredRating(input.ratings, 'vibes'), + vibes_comment: optionalText(input.comments.vibes), + team_dynamics_rating: requiredRating(input.ratings, 'team_dynamics'), + team_dynamics_comment: optionalText(input.comments.team_dynamics), + emergency_exit_rating: requiredRating(input.ratings, 'emergency_exit'), + emergency_exit_comment: optionalText(input.comments.emergency_exit), + lesson_plan_rating: requiredRating(input.ratings, 'lesson_plan'), + lesson_plan_comment: optionalText(input.comments.lesson_plan), + overall_notes: optionalText(input.overallNotes), + }; +} diff --git a/frontend/src/business/walkthrough/selectors.test.ts b/frontend/src/business/walkthrough/selectors.test.ts new file mode 100644 index 0000000..9479a5d --- /dev/null +++ b/frontend/src/business/walkthrough/selectors.test.ts @@ -0,0 +1,173 @@ +import { describe, expect, it } from 'vitest'; +import { + buildWalkthroughCheckInStats, + buildWalkthroughHistoryRows, + buildWalkthroughAutoSummary, + calculateWalkthroughCategoryAverages, + calculateWalkthroughCurrentMonthCount, + calculateWalkthroughOverallPct, + filterWalkthroughCheckins, + findWalkthroughAttentionFlags, + listWalkthroughTeachers, +} from '@/business/walkthrough/selectors'; +import type { WalkthroughCheckinDto } from '@/shared/types/walkthrough'; + +function createCheckin( + overrides: Partial = {}, +): WalkthroughCheckinDto { + return { + id: 'checkin-1', + teacher_name: 'Ava Lee', + classroom: 'Room 101', + director_name: 'Director', + check_in_date: '2026-06-01', + check_in_time: '09:00', + attitude_rating: 4, + attitude_comment: null, + classroom_management_rating: 4, + classroom_management_comment: null, + cleanliness_rating: 4, + cleanliness_comment: null, + vibes_rating: 4, + vibes_comment: null, + team_dynamics_rating: 4, + team_dynamics_comment: null, + emergency_exit_rating: 3, + emergency_exit_comment: null, + lesson_plan_rating: 3, + lesson_plan_comment: null, + overall_notes: null, + organizationId: 'org-1', + campusId: 'campus-1', + createdById: 'user-1', + updatedById: null, + createdAt: '2026-06-01T09:00:00.000Z', + updatedAt: '2026-06-01T09:00:00.000Z', + ...overrides, + }; +} + +describe('walkthrough selectors', () => { + it('lists unique teacher names in sorted order', () => { + const checkins = [ + createCheckin({ id: '1', teacher_name: 'Ben Kim' }), + createCheckin({ id: '2', teacher_name: 'Ava Lee' }), + createCheckin({ id: '3', teacher_name: 'Ben Kim' }), + ]; + + expect(listWalkthroughTeachers(checkins)).toEqual(['Ava Lee', 'Ben Kim']); + }); + + it('filters check-ins by teacher and rolling day range', () => { + const checkins = [ + createCheckin({ id: '1', teacher_name: 'Ava Lee', check_in_date: '2026-06-01' }), + createCheckin({ id: '2', teacher_name: 'Ava Lee', check_in_date: '2026-05-01' }), + createCheckin({ id: '3', teacher_name: 'Ben Kim', check_in_date: '2026-06-01' }), + ]; + + expect( + filterWalkthroughCheckins(checkins, 'Ava Lee', '30', new Date('2026-06-08T12:00:00Z')), + ).toEqual([checkins[0]]); + }); + + it('calculates overall percentage across standard and three-point categories', () => { + expect(calculateWalkthroughOverallPct(createCheckin())).toBe(100); + expect( + calculateWalkthroughOverallPct(createCheckin({ + attitude_rating: 2, + classroom_management_rating: 2, + cleanliness_rating: 2, + vibes_rating: 2, + team_dynamics_rating: 2, + emergency_exit_rating: 2, + lesson_plan_rating: 2, + })), + ).toBe(55); + }); + + it('builds check-in container stats from persisted check-ins', () => { + const checkins = [ + createCheckin({ id: '1', teacher_name: 'Ava Lee', check_in_date: '2026-06-01' }), + createCheckin({ id: '2', teacher_name: 'Ben Kim', check_in_date: '2026-06-02' }), + createCheckin({ id: '3', teacher_name: 'Ava Lee', check_in_date: '2026-05-01' }), + ]; + + expect(calculateWalkthroughCurrentMonthCount(checkins, new Date('2026-06-09T12:00:00Z'))).toBe(2); + expect(buildWalkthroughCheckInStats(checkins, new Date('2026-06-09T12:00:00Z'))).toEqual([ + { id: 'total', label: 'Total Walk-Throughs', value: '3', tone: 'indigo' }, + { id: 'teachers', label: 'Teachers Observed', value: '2', tone: 'violet' }, + { id: 'current-month', label: 'This Month', value: '2', tone: 'cyan' }, + { id: 'average-completion', label: 'Avg Completion', value: '~3 min', tone: 'emerald' }, + ]); + }); + + it('builds history rows with overall and category tones', () => { + const rows = buildWalkthroughHistoryRows([ + createCheckin({ + id: 'low', + teacher_name: 'Ava Lee', + classroom: 'Room 101', + check_in_date: '2026-06-01', + check_in_time: '09:30:00', + attitude_rating: 1, + classroom_management_rating: 1, + cleanliness_rating: 1, + vibes_rating: 1, + team_dynamics_rating: 1, + emergency_exit_rating: 1, + lesson_plan_rating: 1, + overall_notes: 'Needs support', + }), + ]); + + expect(rows[0]).toMatchObject({ + id: 'low', + teacherName: 'Ava Lee', + classroom: 'Room 101', + date: '2026-06-01', + timeLabel: '09:30', + overallPct: 27, + tone: 'priority', + notes: 'Needs support', + }); + expect(rows[0].categories[0]).toEqual({ + key: 'attitude', + label: 'Attitude', + value: 1, + max: 4, + tone: 'priority', + }); + }); + + it('calculates category averages and auto summary labels', () => { + const averages = calculateWalkthroughCategoryAverages([ + createCheckin({ id: '1', attitude_rating: 4, lesson_plan_rating: 3 }), + createCheckin({ id: '2', attitude_rating: 2, lesson_plan_rating: 1 }), + ]); + + expect(averages.find((average) => average.key === 'attitude')).toMatchObject({ + label: 'Attitude', + avg: 3, + max: 4, + count: 2, + }); + expect( + buildWalkthroughAutoSummary(2, averages, 70, 'Ava Lee', '30'), + ).toContain('Overall performance for Ava Lee reflects developing patterns.'); + }); + + it('flags repeated low ratings for a selected teacher', () => { + const checkins = [ + createCheckin({ id: '1', teacher_name: 'Ava Lee', attitude_rating: 2 }), + createCheckin({ id: '2', teacher_name: 'Ava Lee', attitude_rating: 1 }), + createCheckin({ id: '3', teacher_name: 'Ben Kim', attitude_rating: 1 }), + ]; + + const avaCheckins = checkins.filter((checkin) => checkin.teacher_name === 'Ava Lee'); + + expect(findWalkthroughAttentionFlags(avaCheckins, 'Ava Lee')).toEqual([ + { teacher: 'Ava Lee', category: 'Attitude', count: 2 }, + ]); + expect(findWalkthroughAttentionFlags(checkins, 'all')).toEqual([]); + }); +}); diff --git a/frontend/src/business/walkthrough/selectors.ts b/frontend/src/business/walkthrough/selectors.ts new file mode 100644 index 0000000..626c38a --- /dev/null +++ b/frontend/src/business/walkthrough/selectors.ts @@ -0,0 +1,369 @@ +import { + WALKTHROUGH_AVERAGE_COMPLETION_LABEL, + WALKTHROUGH_FORM_CATEGORIES, +} from '@/shared/constants/walkthrough'; +import type { WalkthroughSummaryRange } from '@/shared/constants/walkthrough'; +import type { + WalkthroughCheckinDto, + WalkthroughCommentKey, + WalkthroughRatingField, + WalkthroughRatingKey, +} from '@/shared/types/walkthrough'; +import type { + WalkthroughAttentionFlag, + WalkthroughCategoryAverage, + WalkthroughCheckInStat, + WalkthroughHistoryCategory, + WalkthroughHistoryRow, + WalkthroughHistoryTone, + WalkthroughRecognition, + WalkthroughTeacherFrequency, + WalkthroughTrendPoint, +} from '@/business/walkthrough/types'; + +function findCategory(key: WalkthroughRatingKey) { + return WALKTHROUGH_FORM_CATEGORIES.find((category) => category.key === key); +} + +export function getWalkthroughCategoryLabel(key: WalkthroughRatingKey): string { + return findCategory(key)?.shortLabel ?? key; +} + +export function getWalkthroughCategoryMax(key: WalkthroughRatingKey): number { + return findCategory(key)?.max ?? 4; +} + +export function getWalkthroughRating(checkin: WalkthroughCheckinDto, key: WalkthroughRatingKey): number { + const field: WalkthroughRatingField = `${key}_rating`; + return checkin[field]; +} + +export function getWalkthroughComment(checkin: WalkthroughCheckinDto, key: WalkthroughRatingKey): string | null { + const field: WalkthroughCommentKey = `${key}_comment`; + return checkin[field]; +} + +export function getWalkthroughRatingLabel(value: number, key: WalkthroughRatingKey): string { + if (key === 'emergency_exit') { + if (value === 3) return 'Visible & Accessible'; + if (value === 2) return 'Needs Adjustment'; + return 'Missing Items'; + } + + if (key === 'lesson_plan') { + if (value === 3) return 'Posted & Aligned'; + if (value === 2) return 'Not Posted'; + return 'Incomplete'; + } + + if (value === 4) return 'Excellent'; + if (value === 3) return 'Satisfactory'; + if (value === 2) return 'Needs Attention'; + return 'Immediate Support'; +} + +export function getWalkthroughRatingColor(value: number, maxValue: number): string { + const pct = value / maxValue; + if (pct >= 0.85) return 'text-emerald-400'; + if (pct >= 0.6) return 'text-blue-400'; + if (pct >= 0.4) return 'text-amber-400'; + return 'text-red-400'; +} + +export function getWalkthroughBarColor(value: number, maxValue: number): string { + const pct = value / maxValue; + if (pct >= 0.85) return 'bg-emerald-500'; + if (pct >= 0.6) return 'bg-blue-500'; + if (pct >= 0.4) return 'bg-amber-500'; + return 'bg-red-500'; +} + +export function getWalkthroughStatus(pct: number) { + if (pct >= 85) { + return { text: 'text-emerald-400', bg: 'bg-emerald-500/20', border: 'border-emerald-500/30', label: 'Strong' }; + } + + if (pct >= 60) { + return { text: 'text-blue-400', bg: 'bg-blue-500/20', border: 'border-blue-500/30', label: 'Developing' }; + } + + if (pct >= 40) { + return { text: 'text-amber-400', bg: 'bg-amber-500/20', border: 'border-amber-500/30', label: 'Developing' }; + } + + return { text: 'text-red-400', bg: 'bg-red-500/20', border: 'border-red-500/30', label: 'Priority Support' }; +} + +export function getWalkthroughCategoryPct(avg: number, maxValue: number): number { + return Math.round((avg / maxValue) * 100); +} + +export function listWalkthroughTeachers(checkins: readonly WalkthroughCheckinDto[]): readonly string[] { + return [...new Set(checkins.map((checkin) => checkin.teacher_name))].sort(); +} + +export function filterWalkthroughCheckins( + checkins: readonly WalkthroughCheckinDto[], + selectedTeacher: string, + timeRange: WalkthroughSummaryRange, + currentDate: Date = new Date(), +): readonly WalkthroughCheckinDto[] { + const cutoff = new Date(currentDate); + cutoff.setDate(cutoff.getDate() - Number(timeRange)); + + return checkins.filter((checkin) => { + const teacherMatches = selectedTeacher === 'all' || checkin.teacher_name === selectedTeacher; + return teacherMatches && new Date(checkin.check_in_date) >= cutoff; + }); +} + +export function calculateWalkthroughOverallPctFromAverages( + averages: readonly WalkthroughCategoryAverage[], + checkinCount: number, +): number { + if (averages.length === 0 || checkinCount === 0) { + return 0; + } + + const totalPct = averages.reduce((sum, average) => sum + (average.avg / average.max) * 100, 0); + return Math.round(totalPct / averages.length); +} + +export function createWalkthroughCategoryAverageMap( + averages: readonly WalkthroughCategoryAverage[], +): ReadonlyMap { + return new Map(averages.map((average) => [average.key, average])); +} + +export function calculateWalkthroughOverallPct(checkin: WalkthroughCheckinDto): number { + const totalPct = WALKTHROUGH_FORM_CATEGORIES.reduce((sum, category) => { + const rating = getWalkthroughRating(checkin, category.key); + return sum + (rating / category.max) * 100; + }, 0); + + return Math.round(totalPct / WALKTHROUGH_FORM_CATEGORIES.length); +} + +export function calculateWalkthroughCurrentMonthCount( + checkins: readonly WalkthroughCheckinDto[], + currentDate: Date = new Date(), +): number { + return checkins.filter((checkin) => { + const checkinDate = new Date(checkin.check_in_date); + return checkinDate.getMonth() === currentDate.getMonth() + && checkinDate.getFullYear() === currentDate.getFullYear(); + }).length; +} + +export function buildWalkthroughCheckInStats( + checkins: readonly WalkthroughCheckinDto[], + currentDate: Date = new Date(), +): readonly WalkthroughCheckInStat[] { + return [ + { + id: 'total', + label: 'Total Walk-Throughs', + value: checkins.length.toString(), + tone: 'indigo', + }, + { + id: 'teachers', + label: 'Teachers Observed', + value: listWalkthroughTeachers(checkins).length.toString(), + tone: 'violet', + }, + { + id: 'current-month', + label: 'This Month', + value: calculateWalkthroughCurrentMonthCount(checkins, currentDate).toString(), + tone: 'cyan', + }, + { + id: 'average-completion', + label: 'Avg Completion', + value: WALKTHROUGH_AVERAGE_COMPLETION_LABEL, + tone: 'emerald', + }, + ]; +} + +export function getWalkthroughHistoryTone(overallPct: number): WalkthroughHistoryTone { + if (overallPct >= 75) { + return 'strong'; + } + + if (overallPct >= 50) { + return 'developing'; + } + + return 'priority'; +} + +export function buildWalkthroughHistoryCategory( + checkin: WalkthroughCheckinDto, + key: WalkthroughRatingKey, +): WalkthroughHistoryCategory { + const value = getWalkthroughRating(checkin, key); + const max = getWalkthroughCategoryMax(key); + + return { + key, + label: getWalkthroughCategoryLabel(key), + value, + max, + tone: getWalkthroughHistoryTone((value / max) * 100), + }; +} + +export function buildWalkthroughHistoryRows( + checkins: readonly WalkthroughCheckinDto[], +): readonly WalkthroughHistoryRow[] { + return checkins.map((checkin) => { + const overallPct = calculateWalkthroughOverallPct(checkin); + + return { + id: checkin.id, + teacherName: checkin.teacher_name, + classroom: checkin.classroom, + date: checkin.check_in_date, + timeLabel: checkin.check_in_time?.slice(0, 5) || '', + overallPct, + tone: getWalkthroughHistoryTone(overallPct), + categories: WALKTHROUGH_FORM_CATEGORIES.map((category) => buildWalkthroughHistoryCategory(checkin, category.key)), + notes: checkin.overall_notes, + }; + }); +} + +export function calculateWalkthroughCategoryAverages( + checkins: readonly WalkthroughCheckinDto[], +): readonly WalkthroughCategoryAverage[] { + return WALKTHROUGH_FORM_CATEGORIES.map((category) => { + const values = checkins.map((checkin) => getWalkthroughRating(checkin, category.key)); + + return { + key: category.key, + label: category.shortLabel, + avg: values.length > 0 + ? values.reduce((sum, value) => sum + value, 0) / values.length + : 0, + max: category.max, + count: values.length, + }; + }); +} + +export function calculateWalkthroughTrend( + checkins: readonly WalkthroughCheckinDto[], +): readonly WalkthroughTrendPoint[] { + return [...checkins] + .sort((a, b) => new Date(a.check_in_date).getTime() - new Date(b.check_in_date).getTime()) + .map((checkin) => ({ + date: checkin.check_in_date, + pct: calculateWalkthroughOverallPct(checkin), + teacher: checkin.teacher_name, + })); +} + +export function buildWalkthroughAutoSummary( + checkinCount: number, + categoryAverages: readonly WalkthroughCategoryAverage[], + overallPct: number, + selectedTeacher: string, + timeRange: WalkthroughSummaryRange, +): string { + if (checkinCount === 0) { + return ''; + } + + const strong = categoryAverages + .filter((average) => (average.avg / average.max) >= 0.75) + .map((average) => average.label); + const weak = categoryAverages + .filter((average) => (average.avg / average.max) < 0.6) + .map((average) => average.label); + const teacherLabel = selectedTeacher !== 'all' ? selectedTeacher : 'staff'; + + const parts = [`Over the past ${timeRange} days,`]; + + if (strong.length > 0) { + parts.push(`${strong.join(' and ')} show consistent strength.`); + } + + if (weak.length > 0) { + parts.push(`${weak.join(' and ')} require${weak.length === 1 ? 's' : ''} moderate attention.`); + } + + const progressLabel = overallPct >= 75 + ? 'steady progress' + : overallPct >= 50 + ? 'developing patterns' + : 'areas needing focused support'; + + parts.push(`Overall performance for ${teacherLabel} reflects ${progressLabel}.`); + + return parts.join(' '); +} + +export function calculateWalkthroughTeacherFrequencies( + filteredCheckins: readonly WalkthroughCheckinDto[], + teachers: readonly string[], +): readonly WalkthroughTeacherFrequency[] { + const counts = teachers.map((teacher) => ({ + teacher, + count: filteredCheckins.filter((checkin) => checkin.teacher_name === teacher).length, + })); + const maxCount = Math.max(...counts.map((entry) => entry.count), 1); + + return counts.map((entry) => ({ + ...entry, + pct: (entry.count / maxCount) * 100, + })); +} + +export function findWalkthroughAttentionFlags( + checkins: readonly WalkthroughCheckinDto[], + selectedTeacher: string, +): readonly WalkthroughAttentionFlag[] { + if (selectedTeacher === 'all') { + return []; + } + + return WALKTHROUGH_FORM_CATEGORIES.reduce((flags, category) => { + const needsAttention = checkins.filter((checkin) => getWalkthroughRating(checkin, category.key) <= 2); + + if (needsAttention.length >= 2) { + flags.push({ + teacher: selectedTeacher, + category: category.shortLabel, + count: needsAttention.length, + }); + } + + return flags; + }, []); +} + +export function findWalkthroughRecognitions( + checkins: readonly WalkthroughCheckinDto[], + selectedTeacher: string, +): readonly WalkthroughRecognition[] { + if (selectedTeacher === 'all') { + return []; + } + + return WALKTHROUGH_FORM_CATEGORIES.reduce((recognitions, category) => { + const recent = checkins.slice(0, 3); + if ( + recent.length >= 3 + && recent.every((checkin) => getWalkthroughRating(checkin, category.key) === category.max) + ) { + recognitions.push({ + teacher: selectedTeacher, + category: category.shortLabel, + }); + } + + return recognitions; + }, []); +} diff --git a/frontend/src/business/walkthrough/types.ts b/frontend/src/business/walkthrough/types.ts new file mode 100644 index 0000000..8c274fb --- /dev/null +++ b/frontend/src/business/walkthrough/types.ts @@ -0,0 +1,98 @@ +import { + WalkthroughCheckinDto, + WalkthroughRatingKey, +} from '@/shared/types/walkthrough'; +import type { WalkthroughCheckinCreateDto } from '@/shared/types/walkthrough'; + +export type WalkthroughCheckinViewModel = WalkthroughCheckinDto; + +export interface WalkthroughCategoryAverage { + readonly key: WalkthroughRatingKey; + readonly label: string; + readonly avg: number; + readonly max: number; + readonly count: number; +} + +export interface WalkthroughTrendPoint { + readonly date: string; + readonly pct: number; + readonly teacher: string; +} + +export interface WalkthroughAttentionFlag { + readonly teacher: string; + readonly category: string; + readonly count: number; +} + +export interface WalkthroughRecognition { + readonly teacher: string; + readonly category: string; +} + +export interface WalkthroughGrowthPlan { + readonly strengths: string; + readonly focusAreas: string; + readonly nextSteps: string; + readonly reviewDate: string; +} + +export interface WalkthroughTeacherFrequency { + readonly teacher: string; + readonly count: number; + readonly pct: number; +} + +export type WalkthroughCheckInTabId = 'new' | 'summary' | 'history'; + +export type WalkthroughCheckInStatTone = 'indigo' | 'violet' | 'cyan' | 'emerald'; + +export interface WalkthroughCheckInTab { + readonly id: WalkthroughCheckInTabId; + readonly label: string; +} + +export interface WalkthroughCheckInStat { + readonly id: string; + readonly label: string; + readonly value: string; + readonly tone: WalkthroughCheckInStatTone; +} + +export type WalkthroughHistoryTone = 'strong' | 'developing' | 'priority'; + +export interface WalkthroughHistoryCategory { + readonly key: WalkthroughRatingKey; + readonly label: string; + readonly value: number; + readonly max: number; + readonly tone: WalkthroughHistoryTone; +} + +export interface WalkthroughHistoryRow { + readonly id: string; + readonly teacherName: string; + readonly classroom: string; + readonly date: string; + readonly timeLabel: string; + readonly overallPct: number; + readonly tone: WalkthroughHistoryTone; + readonly categories: readonly WalkthroughHistoryCategory[]; + readonly notes: string | null; +} + +export interface WalkthroughCheckInPage { + readonly activeTab: WalkthroughCheckInTabId; + readonly tabs: readonly WalkthroughCheckInTab[]; + readonly checkins: readonly WalkthroughCheckinDto[]; + readonly stats: readonly WalkthroughCheckInStat[]; + readonly historyRows: readonly WalkthroughHistoryRow[]; + readonly loading: boolean; + readonly submitting: boolean; + readonly errorMessage: string | null; + readonly userName: string; + readonly setActiveTab: (tab: WalkthroughCheckInTabId) => void; + readonly refresh: () => Promise; + readonly submit: (data: WalkthroughCheckinCreateDto) => Promise; +} diff --git a/frontend/src/business/walkthrough/validators.test.ts b/frontend/src/business/walkthrough/validators.test.ts new file mode 100644 index 0000000..8275fb0 --- /dev/null +++ b/frontend/src/business/walkthrough/validators.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; + +import { + canSubmitWalkthroughForm, + hasAllWalkthroughRatings, +} from '@/business/walkthrough/validators'; +import type { WalkthroughRatingDraft } from '@/business/walkthrough/mappers'; + +const completeRatings: WalkthroughRatingDraft = { + attitude: 3, + classroom_management: 3, + cleanliness: 3, + vibes: 3, + team_dynamics: 3, + emergency_exit: 3, + lesson_plan: 3, +}; + +describe('walkthrough validators', () => { + it('requires every walkthrough category rating before submission', () => { + expect(hasAllWalkthroughRatings(completeRatings)).toBe(true); + expect(hasAllWalkthroughRatings({ ...completeRatings, lesson_plan: undefined })).toBe(false); + }); + + it('requires staff, classroom, director, and complete ratings before submission', () => { + expect(canSubmitWalkthroughForm({ + teacherName: ' Alex Rivera ', + classroom: ' Room 4 ', + directorName: ' Director ', + ratings: completeRatings, + })).toBe(true); + + expect(canSubmitWalkthroughForm({ + teacherName: '', + classroom: 'Room 4', + directorName: 'Director', + ratings: completeRatings, + })).toBe(false); + + expect(canSubmitWalkthroughForm({ + teacherName: 'Alex Rivera', + classroom: 'Room 4', + directorName: ' ', + ratings: completeRatings, + })).toBe(false); + + expect(canSubmitWalkthroughForm({ + teacherName: 'Alex Rivera', + classroom: 'Room 4', + directorName: 'Director', + ratings: {}, + })).toBe(false); + }); +}); diff --git a/frontend/src/business/walkthrough/validators.ts b/frontend/src/business/walkthrough/validators.ts new file mode 100644 index 0000000..cbece5f --- /dev/null +++ b/frontend/src/business/walkthrough/validators.ts @@ -0,0 +1,22 @@ +import { WALKTHROUGH_FORM_CATEGORIES } from '@/shared/constants/walkthrough'; +import type { WalkthroughRatingDraft } from '@/business/walkthrough/mappers'; + +interface WalkthroughFormValidationInput { + readonly teacherName: string; + readonly classroom: string; + readonly directorName: string; + readonly ratings: WalkthroughRatingDraft; +} + +export function hasAllWalkthroughRatings(ratings: WalkthroughRatingDraft): boolean { + return WALKTHROUGH_FORM_CATEGORIES.every((category) => ratings[category.key] !== undefined); +} + +export function canSubmitWalkthroughForm(input: WalkthroughFormValidationInput): boolean { + return Boolean( + input.teacherName.trim() + && input.classroom.trim() + && input.directorName.trim() + && hasAllWalkthroughRatings(input.ratings), + ); +} diff --git a/frontend/src/business/zones/hooks.ts b/frontend/src/business/zones/hooks.ts new file mode 100644 index 0000000..013633a --- /dev/null +++ b/frontend/src/business/zones/hooks.ts @@ -0,0 +1,67 @@ +import { useMemo, useState } from 'react'; + +import { useContentCatalogPayload } from '@/business/content-catalog/hooks'; +import { + getSelectedZone, + getZonesSafetyConnection, + toggleExpandedZone as toggleExpandedZoneValue, +} from '@/business/zones/selectors'; +import type { ZonesOfRegulationPage } from '@/business/zones/types'; +import { CONTENT_CATALOG_TYPES } from '@/shared/constants/contentCatalog'; +import { + DEFAULT_EXPANDED_ZONE, + DEFAULT_ZONES_TAB, +} from '@/shared/constants/zonesOfRegulation'; +import type { ZonesOfRegulationTab } from '@/shared/constants/zonesOfRegulation'; +import type { + ZoneColor, + ZoneInfo, + ZonesOfRegulationPageContent, +} from '@/shared/types/app'; + +const EMPTY_ZONES_PAGE_CONTENT: ZonesOfRegulationPageContent = { + safetyConnections: [], + quickDeEscalationFlowTitle: '', + quickDeEscalationFlow: [], +}; + +export function useZonesOfRegulationPage(): ZonesOfRegulationPage { + const zonesQuery = useContentCatalogPayload( + CONTENT_CATALOG_TYPES.regulationZones, + [], + ); + const pageContentQuery = useContentCatalogPayload( + CONTENT_CATALOG_TYPES.zonesOfRegulationPageContent, + EMPTY_ZONES_PAGE_CONTENT, + ); + const [expandedZone, setExpandedZone] = useState(DEFAULT_EXPANDED_ZONE); + const [activeTab, setActiveTab] = useState(DEFAULT_ZONES_TAB); + const zones = zonesQuery.payload; + const pageContent = pageContentQuery.payload; + const selectedZone = useMemo( + () => getSelectedZone(zones, expandedZone), + [expandedZone, zones], + ); + const safetyConnection = useMemo( + () => getZonesSafetyConnection(pageContent.safetyConnections, selectedZone?.color ?? null), + [pageContent.safetyConnections, selectedZone?.color], + ); + + function toggleExpandedZone(zone: ZoneColor) { + setExpandedZone((currentZone) => toggleExpandedZoneValue(currentZone, zone)); + } + + return { + zones, + pageContent, + expandedZone, + selectedZone, + activeTab, + safetyConnection, + isLoading: zonesQuery.isLoading || pageContentQuery.isLoading, + zonesError: zonesQuery.error, + pageContentError: pageContentQuery.error, + setActiveTab, + toggleExpandedZone, + }; +} diff --git a/frontend/src/business/zones/selectors.test.ts b/frontend/src/business/zones/selectors.test.ts new file mode 100644 index 0000000..4c08b94 --- /dev/null +++ b/frontend/src/business/zones/selectors.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest'; + +import { + getSelectedZone, + getZoneBehaviorHeading, + getZonesSafetyConnection, + toggleExpandedZone, +} from '@/business/zones/selectors'; +import type { + ZoneInfo, + ZonesSafetyConnection, +} from '@/shared/types/app'; + +const zones: readonly ZoneInfo[] = [ + { + color: 'green', + name: 'Green Zone', + description: 'Ready', + behaviors: [], + strategies: [], + signs: [], + bgClass: 'bg-green-100', + textClass: 'text-green-700', + borderClass: 'border-green-300', + }, + { + color: 'yellow', + name: 'Yellow Zone', + description: 'Heightened', + behaviors: [], + strategies: [], + signs: [], + bgClass: 'bg-yellow-100', + textClass: 'text-yellow-700', + borderClass: 'border-yellow-300', + }, +]; + +const safetyConnections: readonly ZonesSafetyConnection[] = [ + { + zoneColor: 'yellow', + title: 'Yellow safety', + description: 'Intervene early', + }, +]; + +describe('zones selectors', () => { + it('toggles expanded zones', () => { + expect(toggleExpandedZone('green', 'green')).toBeNull(); + expect(toggleExpandedZone('green', 'yellow')).toBe('yellow'); + expect(toggleExpandedZone(null, 'yellow')).toBe('yellow'); + }); + + it('selects the expanded zone record', () => { + expect(getSelectedZone(zones, 'green')).toEqual(zones[0]); + expect(getSelectedZone(zones, 'blue')).toBeNull(); + expect(getSelectedZone(zones, null)).toBeNull(); + }); + + it('selects safety connection for the active zone', () => { + expect(getZonesSafetyConnection(safetyConnections, 'yellow')).toEqual(safetyConnections[0]); + expect(getZonesSafetyConnection(safetyConnections, 'green')).toBeNull(); + expect(getZonesSafetyConnection(safetyConnections, null)).toBeNull(); + }); + + it('uses adult behavior heading only for the adult tab', () => { + expect(getZoneBehaviorHeading('adult')).toBe('Adult Behaviors'); + expect(getZoneBehaviorHeading('overview')).toBe('What It Looks Like'); + expect(getZoneBehaviorHeading('student')).toBe('What It Looks Like'); + }); +}); diff --git a/frontend/src/business/zones/selectors.ts b/frontend/src/business/zones/selectors.ts new file mode 100644 index 0000000..8c5af56 --- /dev/null +++ b/frontend/src/business/zones/selectors.ts @@ -0,0 +1,38 @@ +import type { + ZoneColor, + ZoneInfo, + ZonesSafetyConnection, +} from '@/shared/types/app'; + +export function toggleExpandedZone( + currentZone: ZoneColor | null, + nextZone: ZoneColor, +): ZoneColor | null { + return currentZone === nextZone ? null : nextZone; +} + +export function getSelectedZone( + zones: readonly ZoneInfo[], + expandedZone: ZoneColor | null, +): ZoneInfo | null { + if (!expandedZone) { + return null; + } + + return zones.find((zone) => zone.color === expandedZone) ?? null; +} + +export function getZonesSafetyConnection( + safetyConnections: readonly ZonesSafetyConnection[], + zoneColor: ZoneColor | null, +): ZonesSafetyConnection | null { + if (!zoneColor) { + return null; + } + + return safetyConnections.find((connection) => connection.zoneColor === zoneColor) ?? null; +} + +export function getZoneBehaviorHeading(activeTab: string): string { + return activeTab === 'adult' ? 'Adult Behaviors' : 'What It Looks Like'; +} diff --git a/frontend/src/business/zones/types.ts b/frontend/src/business/zones/types.ts new file mode 100644 index 0000000..162c14f --- /dev/null +++ b/frontend/src/business/zones/types.ts @@ -0,0 +1,21 @@ +import type { + ZoneColor, + ZoneInfo, + ZonesOfRegulationPageContent, + ZonesSafetyConnection, +} from '@/shared/types/app'; +import type { ZonesOfRegulationTab } from '@/shared/constants/zonesOfRegulation'; + +export interface ZonesOfRegulationPage { + readonly zones: readonly ZoneInfo[]; + readonly pageContent: ZonesOfRegulationPageContent; + readonly expandedZone: ZoneColor | null; + readonly selectedZone: ZoneInfo | null; + readonly activeTab: ZonesOfRegulationTab; + readonly safetyConnection: ZonesSafetyConnection | null; + readonly isLoading: boolean; + readonly zonesError: Error | null; + readonly pageContentError: Error | null; + readonly setActiveTab: (tab: ZonesOfRegulationTab) => void; + readonly toggleExpandedZone: (zone: ZoneColor) => void; +} diff --git a/frontend/src/components/AppLayout.tsx b/frontend/src/components/AppLayout.tsx new file mode 100644 index 0000000..253be2e --- /dev/null +++ b/frontend/src/components/AppLayout.tsx @@ -0,0 +1,80 @@ +import React from 'react'; +import { Outlet } from 'react-router-dom'; +import { useAuth } from '@/contexts/useAuth'; +import { useIsMobile } from '@/hooks/use-mobile'; +import { useAppShell } from '@/business/app-shell/hooks'; +import { GuestBanner } from '@/components/app-shell/GuestBanner'; +import { AppFooter } from '@/components/app-shell/AppFooter'; + +import Sidebar from '@/components/frameworks/Sidebar'; +import TopBar from '@/components/frameworks/TopBar'; +import SignInModal from '@/components/frameworks/SignInModal'; + +import { Loader2 } from 'lucide-react'; + +const AppLayout: React.FC = () => { + const { isAuthenticated, profile, loading: authLoading } = useAuth(); + const isMobile = useIsMobile(); + const { + mobileOverlayVisible, + sidebarProps, + topBarProps, + shellOutletContext, + guestBannerProps, + footerProps, + signInModalProps, + setMobileSidebarOpen, + } = useAppShell({ isAuthenticated, profile, isMobile }); + + // Loading state + if (authLoading) { + return ( +
+
+
+ F +
+ +

Loading FRAMEworks...

+
+
+ ); + } + + return ( +
+ {/* Mobile sidebar overlay */} + {mobileOverlayVisible && ( +
setMobileSidebarOpen(false)} /> + )} + + {/* Sidebar */} +
+ +
+ + + {/* Main content area */} +
+ + +
+ {!isAuthenticated && ( + + )} + +
+ +
+ + +
+
+ + {/* Sign In Modal */} + +
+ ); +}; + +export default AppLayout; diff --git a/frontend/src/components/app-shell/AppFooter.tsx b/frontend/src/components/app-shell/AppFooter.tsx new file mode 100644 index 0000000..70bde89 --- /dev/null +++ b/frontend/src/components/app-shell/AppFooter.tsx @@ -0,0 +1,93 @@ +import { CheckCircle2 } from 'lucide-react'; +import type { AppFooterProps } from '@/business/app-shell/types'; +import { + CORE_FOOTER_LINKS, + OPERATIONS_FOOTER_LINKS, + PLATFORM_FOOTER_ITEMS, +} from '@/shared/constants/footerNavigation'; + +export function AppFooter({ + isAuthenticated, + userName, + userRole, + currentGuestPreviewRole, + setCurrentModule, +}: AppFooterProps) { + return ( +
+
+
+
+
+
+
+ F +
+ + FRAMEworks + +
+

+ A modular, role-based school operations platform designed for autism-focused educational environments. +

+
+
+

Core Modules

+
    + {CORE_FOOTER_LINKS.map((item) => ( +
  • + +
  • + ))} +
+
+ +
+

Operations

+
    + {OPERATIONS_FOOTER_LINKS.map((item) => ( +
  • + +
  • + ))} +
+
+ +
+

Platform

+
    + {PLATFORM_FOOTER_ITEMS.map((item) => ( +
  • {item}
  • + ))} +
+
+
+
+

+ FRAMEworks © 2026 - Built for autism-focused school communities +

+
+ + + {isAuthenticated + ? `Signed in as ${userName} (${userRole})` + : `Browsing as Guest (${currentGuestPreviewRole.label})` + } + +
+
+
+
+
+ ); +} diff --git a/frontend/src/components/app-shell/GuestBanner.tsx b/frontend/src/components/app-shell/GuestBanner.tsx new file mode 100644 index 0000000..1f9b6c5 --- /dev/null +++ b/frontend/src/components/app-shell/GuestBanner.tsx @@ -0,0 +1,76 @@ +import { CheckCircle2, ChevronDown, Eye, LogIn } from 'lucide-react'; +import type { GuestBannerProps } from '@/business/app-shell/types'; + +export function GuestBanner({ + guestPreviewRole, + guestPreviewRoles, + currentGuestPreviewRole, + showGuestRolePicker, + setGuestPreviewRole, + setShowGuestRolePicker, + onSignInClick, +}: GuestBannerProps) { + return ( +
+
+
+
+ +
+
+

You're browsing as a Guest

+

+ Explore all modules freely. Sign in to save your progress and get a personalized experience. +

+
+
+
+
+ + {showGuestRolePicker && ( + <> +
setShowGuestRolePicker(false)} /> +
+

+ Switch Guest View +

+ {guestPreviewRoles.map((role) => ( + + ))} +
+ + )} +
+ + +
+
+
+ ); +} diff --git a/frontend/src/components/campus-attendance/AttendanceSummaryCard.tsx b/frontend/src/components/campus-attendance/AttendanceSummaryCard.tsx new file mode 100644 index 0000000..cb31a0a --- /dev/null +++ b/frontend/src/components/campus-attendance/AttendanceSummaryCard.tsx @@ -0,0 +1,47 @@ +import type { LucideIcon } from 'lucide-react'; + +import { + percentageBackgroundClass, + percentageBorderClass, + percentageTextClass, +} from '@/components/campus-attendance/styles'; + +type AttendanceSummaryCardProps = { + label: string; + value: string | number; + helper: string; + icon: LucideIcon; + color?: 'percentage' | 'blue' | 'violet' | 'red'; + percentage?: number | null; +}; + +export function AttendanceSummaryCard({ + label, + value, + helper, + icon: Icon, + color = 'percentage', + percentage = null, +}: AttendanceSummaryCardProps) { + const fixedColorClasses = { + blue: 'bg-blue-500/10 border-blue-500/20 text-blue-400', + violet: 'bg-violet-500/10 border-violet-500/20 text-violet-400', + red: 'bg-red-500/10 border-red-500/20 text-red-400', + }; + const cardClass = color === 'percentage' + ? `${percentageBackgroundClass(percentage)} ${percentageBorderClass(percentage)} ${percentageTextClass(percentage)}` + : fixedColorClasses[color]; + + return ( +
+
+ + {label} +
+

+ {value} +

+

{helper}

+
+ ); +} diff --git a/frontend/src/components/campus-attendance/CampusAttendanceEntryForm.tsx b/frontend/src/components/campus-attendance/CampusAttendanceEntryForm.tsx new file mode 100644 index 0000000..c28fd04 --- /dev/null +++ b/frontend/src/components/campus-attendance/CampusAttendanceEntryForm.tsx @@ -0,0 +1,100 @@ +import { Calendar, Save, X } from 'lucide-react'; + +import { getDraftAttendancePercentage } from '@/business/campus-attendance/selectors'; +import type { + CampusAttendancePageActions, + CampusAttendancePageState, +} from '@/components/campus-attendance/types'; +import { percentageTextClass } from '@/components/campus-attendance/styles'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; + +type CampusAttendanceEntryFormProps = { + state: CampusAttendancePageState; + actions: CampusAttendancePageActions; +}; + +export function CampusAttendanceEntryForm({ state, actions }: CampusAttendanceEntryFormProps) { + const { campusInfo, entryDraft, entryError, saving } = state; + const draftPercentage = getDraftAttendancePercentage(entryDraft); + + return ( +
+
+

+ + Enter Attendance for {campusInfo?.fullName} +

+ +
+
+ actions.updateEntryDraft({ date: value })} /> + actions.updateEntryDraft({ enrolled: value })} /> + actions.updateEntryDraft({ present: value })} /> + actions.updateEntryDraft({ absent: value })} /> + actions.updateEntryDraft({ tardy: value })} /> + actions.updateEntryDraft({ notes: value })} /> +
+ {draftPercentage !== null && ( +
+

+ Calculated Attendance:{' '} + + {draftPercentage.toFixed(1)}% + +

+
+ )} + {entryError &&

{entryError}

} +
+ +
+
+ ); +} + +type AttendanceEntryInputProps = { + label: string; + value: string; + onChange: (value: string) => void; + type?: 'date' | 'number' | 'text'; + placeholder?: string; +}; + +function AttendanceEntryInput({ + label, + value, + onChange, + type = 'number', + placeholder, +}: AttendanceEntryInputProps) { + return ( +
+ + onChange(event.target.value)} + placeholder={placeholder} + className="w-full px-4 py-2.5 bg-slate-700/50 border border-slate-600/50 rounded-xl text-sm text-white focus:ring-2 focus:ring-orange-500/50 outline-none placeholder:text-slate-500" + /> +
+ ); +} diff --git a/frontend/src/components/campus-attendance/CampusAttendanceHeader.tsx b/frontend/src/components/campus-attendance/CampusAttendanceHeader.tsx new file mode 100644 index 0000000..4be42a9 --- /dev/null +++ b/frontend/src/components/campus-attendance/CampusAttendanceHeader.tsx @@ -0,0 +1,59 @@ +import { ClipboardList, Plus, Printer } from 'lucide-react'; + +import type { + CampusAttendancePageActions, + CampusAttendancePageState, +} from '@/components/campus-attendance/types'; +import { Button } from '@/components/ui/button'; +import { ModuleHeader } from '@/components/ui/module-header'; + +type CampusAttendanceHeaderProps = { + state: CampusAttendancePageState; + actions: CampusAttendancePageActions; +}; + +export function CampusAttendanceHeader({ state, actions }: CampusAttendanceHeaderProps) { + const { roleAccess, campusInfo, userCampus, showEntryForm } = state; + + return ( +
+ +
+ {roleAccess.canPrint && ( + + )} + {roleAccess.canEnterData && ( + + )} +
+
+ ); +} diff --git a/frontend/src/components/campus-attendance/CampusAttendanceLinkConfig.tsx b/frontend/src/components/campus-attendance/CampusAttendanceLinkConfig.tsx new file mode 100644 index 0000000..afa65d2 --- /dev/null +++ b/frontend/src/components/campus-attendance/CampusAttendanceLinkConfig.tsx @@ -0,0 +1,117 @@ +import { Edit3, ExternalLink, Globe, Link2, Save, X } from 'lucide-react'; + +import type { + CampusAttendancePageActions, + CampusAttendancePageState, +} from '@/components/campus-attendance/types'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; + +type CampusAttendanceLinkConfigProps = { + state: CampusAttendancePageState; + actions: CampusAttendancePageActions; +}; + +export function CampusAttendanceLinkConfig({ state, actions }: CampusAttendanceLinkConfigProps) { + const { + campusId, + editingLink, + linkValue, + myCampusConfig, + saving, + } = state; + + if (!campusId) { + return ( +
+

+ + Campus Attendance System Link +

+

Campus catalog is unavailable. Refresh campuses before configuring an attendance link.

+
+ ); + } + + return ( +
+

+ + Campus Attendance System Link +

+

+ Enter the URL to your campus attendance system. This link will be accessible to all staff on your campus for quick access. +

+
+ {editingLink === campusId ? ( + <> + actions.setLinkValue(event.target.value)} + placeholder="https://your-attendance-system.com/campus-link" + className="flex-1 px-4 py-2.5 bg-slate-700/50 border border-orange-500/30 rounded-xl text-sm text-white focus:ring-2 focus:ring-orange-500/50 outline-none placeholder:text-slate-500" + /> +
+ + +
+ + ) : ( + <> +
+ {myCampusConfig?.attendance_link ? ( + <> + + + {myCampusConfig.attendance_link} + + + + ) : ( + No attendance link configured yet + )} +
+ + + )} +
+ {myCampusConfig?.updated_by && ( +

+ Last updated by {myCampusConfig.updated_by} on {new Date(myCampusConfig.updated_at).toLocaleDateString()} +

+ )} +
+ ); +} diff --git a/frontend/src/components/campus-attendance/CampusAttendanceStatus.tsx b/frontend/src/components/campus-attendance/CampusAttendanceStatus.tsx new file mode 100644 index 0000000..8e58f86 --- /dev/null +++ b/frontend/src/components/campus-attendance/CampusAttendanceStatus.tsx @@ -0,0 +1,44 @@ +import { CheckCircle } from 'lucide-react'; +import { StatePanel } from '@/components/ui/state-panel'; + +type CampusAttendanceLoadingStateProps = { + message?: string; +}; + +export function CampusAttendanceLoadingState({ + message = 'Loading attendance data...', +}: CampusAttendanceLoadingStateProps) { + return ( +
+ + {message} + +
+ ); +} + +type CampusAttendanceStatusProps = { + successMessage: string; + errorMessage: string | null; +}; + +export function CampusAttendanceStatus({ + successMessage, + errorMessage, +}: CampusAttendanceStatusProps) { + return ( + <> + {successMessage && ( +
+ +

{successMessage}

+
+ )} + {errorMessage && ( +
+

{errorMessage}

+
+ )} + + ); +} diff --git a/frontend/src/components/campus-attendance/IndividualCampusAttendanceView.tsx b/frontend/src/components/campus-attendance/IndividualCampusAttendanceView.tsx new file mode 100644 index 0000000..8622517 --- /dev/null +++ b/frontend/src/components/campus-attendance/IndividualCampusAttendanceView.tsx @@ -0,0 +1,155 @@ +import { BarChart3, Calendar, ClipboardList, ExternalLink, FileText, Globe, Link2, Users, UserX } from 'lucide-react'; + +import { formatAttendanceDate } from '@/business/campus-attendance/selectors'; +import { AttendanceSummaryCard } from '@/components/campus-attendance/AttendanceSummaryCard'; +import { percentageTextClass } from '@/components/campus-attendance/styles'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import type { + CampusAttendancePageActions, + CampusAttendancePageState, +} from '@/components/campus-attendance/types'; + +type IndividualCampusAttendanceViewProps = { + state: CampusAttendancePageState; + actions: CampusAttendancePageActions; +}; + +export function IndividualCampusAttendanceView({ state, actions }: IndividualCampusAttendanceViewProps) { + const { + today, + weekStart, + myTodayPct, + myWeekAvg, + myCampusData, + myCampusConfig, + roleAccess, + campusInfo, + } = state; + const todayRecord = myCampusData.find((record) => record.date === today); + + return ( + <> +
+ + + {todayRecord && ( + <> + + + + )} +
+ + {myCampusConfig?.attendance_link && !roleAccess.isOfficeManager && ( +
+

+ + Campus Attendance System +

+ + + {myCampusConfig.attendance_link} + + +
+ )} + +
+
+

+ + Recent Attendance History - {campusInfo?.fullName} +

+
+ {myCampusData.length > 0 ? ( + + + + Date + Enrolled + Present + Absent + Tardy + Attendance % + Notes + + + + {myCampusData.slice(0, 15).map((record) => ( + + {formatAttendanceDate(record.date)} + {record.total_enrolled} + + {record.total_present} + + + 3 ? 'bg-red-500/10 text-red-400' : 'bg-slate-700/30 text-slate-400'}`}>{record.total_absent} + + + 2 ? 'bg-amber-500/10 text-amber-400' : 'bg-slate-700/30 text-slate-400'}`}>{record.total_tardy} + + + + {record.attendance_percentage.toFixed(1)}% + + + {record.notes || '-'} + + ))} + +
+ ) : ( +
+ +

No attendance data recorded yet for this campus.

+ {roleAccess.isOfficeManager && ( + + )} +
+ )} +
+ + ); +} diff --git a/frontend/src/components/campus-attendance/SuperintendentAttendanceView.tsx b/frontend/src/components/campus-attendance/SuperintendentAttendanceView.tsx new file mode 100644 index 0000000..d2fa8a1 --- /dev/null +++ b/frontend/src/components/campus-attendance/SuperintendentAttendanceView.tsx @@ -0,0 +1,194 @@ +import { + BarChart3, + Calendar, + ChevronDown, + ChevronUp, + ExternalLink, + Globe, + Link2, + UserCheck, + Users, +} from 'lucide-react'; + +import { formatAttendanceDate } from '@/business/campus-attendance/selectors'; +import { AttendanceSummaryCard } from '@/components/campus-attendance/AttendanceSummaryCard'; +import { + percentageBackgroundClass, + percentageBorderClass, + percentageTextClass, +} from '@/components/campus-attendance/styles'; +import type { + CampusAttendancePageActions, + CampusAttendancePageState, +} from '@/components/campus-attendance/types'; + +type SuperintendentAttendanceViewProps = { + state: CampusAttendancePageState; + actions: CampusAttendancePageActions; +}; + +export function SuperintendentAttendanceView({ state, actions }: SuperintendentAttendanceViewProps) { + const { today, weekStart, overallStats, campusStats, expandedCampus } = state; + + return ( + <> +
+ + + + +
+ +
+ {campusStats.map((campus) => ( +
+
+
+
+

{campus.mascot}

+

{campus.fullName}

+
+ {campus.isOnline && ( + Online + )} +
+
+
+
+
+

Today

+

+ {campus.todayPct !== null ? `${campus.todayPct}%` : 'N/A'} +

+
+
+

Week Avg

+

+ {campus.weekAvg !== null ? `${campus.weekAvg}%` : 'N/A'} +

+
+
+ {campus.todayRecord && ( +
+
+

Enrolled

+

{campus.todayRecord.total_enrolled}

+
+
+

Present

+

{campus.todayRecord.total_present}

+
+
+

Absent

+

{campus.todayRecord.total_absent}

+
+
+ )} + {campus.config?.attendance_link ? ( + + + {campus.config.attendance_link} + + + ) : ( +
+ No link configured +
+ )} + + {expandedCampus === campus.id && campus.recentData.length > 0 && ( +
+ {campus.recentData.map((record) => ( +
+ {formatAttendanceDate(record.date)} +
+ {record.total_present}/{record.total_enrolled} + + {record.attendance_percentage.toFixed(1)}% + +
+
+ ))} +
+ )} +
+
+ ))} +
+ +
+

+ + Campus Attendance System Links +

+
+ {campusStats.map((campus) => ( +
+
+
+ {campus.mascot[0]} +
+
+

{campus.fullName}

+ {campus.config?.updated_by && ( +

Updated by {campus.config.updated_by}

+ )} +
+
+ {campus.config?.attendance_link ? ( + + + Open Link + + + ) : ( + Not configured + )} +
+ ))} +
+
+ + ); +} diff --git a/frontend/src/components/campus-attendance/styles.ts b/frontend/src/components/campus-attendance/styles.ts new file mode 100644 index 0000000..b883d3e --- /dev/null +++ b/frontend/src/components/campus-attendance/styles.ts @@ -0,0 +1,55 @@ +export const percentageTextClass = (percentage: number | null): string => { + if (percentage === null) { + return 'text-slate-400'; + } + + if (percentage >= 95) { + return 'text-emerald-500'; + } + + if (percentage >= 90) { + return 'text-emerald-400'; + } + + if (percentage >= 85) { + return 'text-amber-400'; + } + + if (percentage >= 80) { + return 'text-amber-500'; + } + + return 'text-red-500'; +}; + +export const percentageBackgroundClass = (percentage: number | null): string => { + if (percentage === null) { + return 'bg-slate-500/10'; + } + + if (percentage >= 90) { + return 'bg-emerald-500/10'; + } + + if (percentage >= 80) { + return 'bg-amber-500/10'; + } + + return 'bg-red-500/10'; +}; + +export const percentageBorderClass = (percentage: number | null): string => { + if (percentage === null) { + return 'border-slate-500/20'; + } + + if (percentage >= 90) { + return 'border-emerald-500/20'; + } + + if (percentage >= 80) { + return 'border-amber-500/20'; + } + + return 'border-red-500/20'; +}; diff --git a/frontend/src/components/campus-attendance/types.ts b/frontend/src/components/campus-attendance/types.ts new file mode 100644 index 0000000..0717d76 --- /dev/null +++ b/frontend/src/components/campus-attendance/types.ts @@ -0,0 +1,4 @@ +import type { useCampusAttendancePage } from '@/business/campus-attendance/hooks'; + +export type CampusAttendancePageState = ReturnType['state']; +export type CampusAttendancePageActions = ReturnType['actions']; diff --git a/frontend/src/components/classroom-support/ClassroomStrategyCard.tsx b/frontend/src/components/classroom-support/ClassroomStrategyCard.tsx new file mode 100644 index 0000000..e13f5b9 --- /dev/null +++ b/frontend/src/components/classroom-support/ClassroomStrategyCard.tsx @@ -0,0 +1,80 @@ +import { Bookmark, BookmarkPlus } from 'lucide-react'; + +import type { KeyboardEvent } from 'react'; + +import { Button } from '@/components/ui/button'; +import { + CLASSROOM_SUPPORT_CATEGORY_COLOR_CLASSES, + CLASSROOM_SUPPORT_ZONE_COLOR_CLASSES, +} from '@/shared/constants/classroomSupport'; +import type { Strategy } from '@/shared/types/app'; + +interface ClassroomStrategyCardProps { + readonly strategy: Strategy; + readonly isFavorite: boolean; + readonly onSelect: (strategy: Strategy) => void; + readonly onToggleFavorite: (id: string) => void; +} + +export function ClassroomStrategyCard({ + strategy, + isFavorite, + onSelect, + onToggleFavorite, +}: ClassroomStrategyCardProps) { + function openStrategy() { + onSelect(strategy); + } + + function handleKeyDown(event: KeyboardEvent) { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + openStrategy(); + } + } + + return ( +
+
+ {strategy.title} +
+ +
+ + {strategy.category.replace('-', ' ')} + + + {strategy.zone} zone + +
+
+
+

{strategy.title}

+

{strategy.description}

+
+ {strategy.ageGroup} +
+
+
+ ); +} diff --git a/frontend/src/components/classroom-support/ClassroomStrategyDetailModal.tsx b/frontend/src/components/classroom-support/ClassroomStrategyDetailModal.tsx new file mode 100644 index 0000000..ff3afab --- /dev/null +++ b/frontend/src/components/classroom-support/ClassroomStrategyDetailModal.tsx @@ -0,0 +1,83 @@ +import { Bookmark, X } from 'lucide-react'; + +import { Button } from '@/components/ui/button'; +import { + CLASSROOM_SUPPORT_CATEGORY_COLOR_CLASSES, +} from '@/shared/constants/classroomSupport'; +import type { Strategy } from '@/shared/types/app'; + +interface ClassroomStrategyDetailModalProps { + readonly strategy: Strategy | null; + readonly isFavorite: boolean; + readonly onToggleFavorite: (id: string) => void; + readonly onClose: () => void; +} + +export function ClassroomStrategyDetailModal({ + strategy, + isFavorite, + onToggleFavorite, + onClose, +}: ClassroomStrategyDetailModalProps) { + if (!strategy) { + return null; + } + + return ( +
+
event.stopPropagation()} + > +
+ {strategy.title} +
+ +
+
+
+ + {strategy.category.replace('-', ' ')} + + {strategy.ageGroup} +
+

{strategy.title}

+

{strategy.description}

+ {strategy.implementationTip && ( +
+

Implementation Tip

+

{strategy.implementationTip}

+
+ )} +
+ +
+
+
+
+ ); +} diff --git a/frontend/src/components/classroom-support/ClassroomStrategyGrid.tsx b/frontend/src/components/classroom-support/ClassroomStrategyGrid.tsx new file mode 100644 index 0000000..85c4c94 --- /dev/null +++ b/frontend/src/components/classroom-support/ClassroomStrategyGrid.tsx @@ -0,0 +1,51 @@ +import { Search } from 'lucide-react'; + +import type { ClassroomSupportPage } from '@/business/classroom-support/types'; +import { StatePanel } from '@/components/ui/state-panel'; +import { ClassroomStrategyCard } from '@/components/classroom-support/ClassroomStrategyCard'; + +interface ClassroomStrategyGridProps { + readonly page: ClassroomSupportPage; +} + +export function ClassroomStrategyGrid({ page }: ClassroomStrategyGridProps) { + if (page.isLoading) { + return ( + + Loading classroom strategies... + + ); + } + + if (page.error) { + return ( + + Strategies could not be loaded from the backend. + + ); + } + + if (page.filteredStrategies.length === 0) { + return ( +
+ +

No strategies found

+

Try adjusting your filters or search terms

+
+ ); + } + + return ( +
+ {page.filteredStrategies.map((strategy) => ( + + ))} +
+ ); +} diff --git a/frontend/src/components/classroom-support/ClassroomSupportFilters.tsx b/frontend/src/components/classroom-support/ClassroomSupportFilters.tsx new file mode 100644 index 0000000..5001a8f --- /dev/null +++ b/frontend/src/components/classroom-support/ClassroomSupportFilters.tsx @@ -0,0 +1,115 @@ +import { Bookmark, Search, X } from 'lucide-react'; + +import { + toClassroomSupportAgeFilter, + toClassroomSupportCategoryFilter, +} from '@/business/classroom-support/selectors'; +import type { ClassroomSupportFilters as ClassroomSupportFilterState } from '@/business/classroom-support/types'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { NativeSelect } from '@/components/ui/native-select'; +import { + CLASSROOM_SUPPORT_AGE_FILTERS, + CLASSROOM_SUPPORT_CATEGORY_FILTERS, + CLASSROOM_SUPPORT_ZONE_FILTERS, +} from '@/shared/constants/classroomSupport'; +import type { + ClassroomSupportAgeFilter, + ClassroomSupportCategoryFilter, + ClassroomSupportZoneFilter, +} from '@/shared/constants/classroomSupport'; + +interface ClassroomSupportFiltersProps { + readonly filters: ClassroomSupportFilterState; + readonly favoriteCount: number; + readonly onSearchChange: (query: string) => void; + readonly onCategoryChange: (filter: ClassroomSupportCategoryFilter) => void; + readonly onAgeChange: (filter: ClassroomSupportAgeFilter) => void; + readonly onZoneChange: (filter: ClassroomSupportZoneFilter) => void; + readonly onToggleFavoritesOnly: () => void; + readonly onClearSearch: () => void; +} + +export function ClassroomSupportFilters({ + filters, + favoriteCount, + onSearchChange, + onCategoryChange, + onAgeChange, + onZoneChange, + onToggleFavoritesOnly, + onClearSearch, +}: ClassroomSupportFiltersProps) { + return ( +
+
+ + onSearchChange(event.target.value)} + placeholder="Search strategies by name, behavior, or keyword..." + className="w-full pl-10 pr-10 py-2.5 bg-slate-700/50 border border-slate-600/50 rounded-xl text-sm text-white placeholder-slate-500 focus:ring-2 focus:ring-emerald-500/50 focus:border-emerald-500/50 outline-none" + /> + {filters.searchQuery && ( + + )} +
+
+ onCategoryChange(toClassroomSupportCategoryFilter(event.target.value))} + className="px-3 py-2 bg-slate-700/50 border border-slate-600/50 rounded-xl text-sm text-white focus:ring-2 focus:ring-emerald-500/50 outline-none" + > + {CLASSROOM_SUPPORT_CATEGORY_FILTERS.map((category) => ( + + ))} + + onAgeChange(toClassroomSupportAgeFilter(event.target.value))} + className="px-3 py-2 bg-slate-700/50 border border-slate-600/50 rounded-xl text-sm text-white focus:ring-2 focus:ring-emerald-500/50 outline-none" + > + {CLASSROOM_SUPPORT_AGE_FILTERS.map((age) => ( + + ))} + +
+ {CLASSROOM_SUPPORT_ZONE_FILTERS.map((zone) => ( + + ))} +
+ +
+
+ ); +} diff --git a/frontend/src/components/classroom-support/ClassroomSupportHeader.tsx b/frontend/src/components/classroom-support/ClassroomSupportHeader.tsx new file mode 100644 index 0000000..d68f961 --- /dev/null +++ b/frontend/src/components/classroom-support/ClassroomSupportHeader.tsx @@ -0,0 +1,15 @@ +import { Lightbulb } from 'lucide-react'; + +import { ModuleHeader } from '@/components/ui/module-header'; + +export function ClassroomSupportHeader() { + return ( + + ); +} diff --git a/frontend/src/components/classroom-support/ClassroomSupportTryToday.tsx b/frontend/src/components/classroom-support/ClassroomSupportTryToday.tsx new file mode 100644 index 0000000..0014e07 --- /dev/null +++ b/frontend/src/components/classroom-support/ClassroomSupportTryToday.tsx @@ -0,0 +1,47 @@ +import { AlertTriangle, Sparkles } from 'lucide-react'; + +import { StatePanel } from '@/components/ui/state-panel'; +import type { Strategy } from '@/shared/types/app'; + +interface ClassroomSupportTryTodayProps { + readonly strategy: Strategy | null; + readonly isLoading: boolean; + readonly error: unknown; +} + +export function ClassroomSupportTryToday({ + strategy, + isLoading, + error, +}: ClassroomSupportTryTodayProps) { + return ( +
+
+
+ +
+
+

+ Try This Today +

+ {isLoading ? ( + + Loading strategies... + + ) : error ? ( +

+ + Strategies could not be loaded. +

+ ) : strategy ? ( + <> +

{strategy.title}

+

{strategy.description}

+ + ) : ( +

No strategies are available yet.

+ )} +
+
+ ); +} diff --git a/frontend/src/components/classroom-support/ClassroomSupportView.tsx b/frontend/src/components/classroom-support/ClassroomSupportView.tsx new file mode 100644 index 0000000..4875fea --- /dev/null +++ b/frontend/src/components/classroom-support/ClassroomSupportView.tsx @@ -0,0 +1,41 @@ +import type { ClassroomSupportPage } from '@/business/classroom-support/types'; +import { ClassroomSupportFilters } from '@/components/classroom-support/ClassroomSupportFilters'; +import { ClassroomSupportHeader } from '@/components/classroom-support/ClassroomSupportHeader'; +import { ClassroomSupportTryToday } from '@/components/classroom-support/ClassroomSupportTryToday'; +import { ClassroomStrategyDetailModal } from '@/components/classroom-support/ClassroomStrategyDetailModal'; +import { ClassroomStrategyGrid } from '@/components/classroom-support/ClassroomStrategyGrid'; + +interface ClassroomSupportViewProps { + readonly page: ClassroomSupportPage; +} + +export function ClassroomSupportView({ page }: ClassroomSupportViewProps) { + return ( +
+ + + +

{page.filteredStrategies.length} strategies found

+ + +
+ ); +} diff --git a/frontend/src/components/classroom-timer/CircularProgress.tsx b/frontend/src/components/classroom-timer/CircularProgress.tsx new file mode 100644 index 0000000..5b84c6a --- /dev/null +++ b/frontend/src/components/classroom-timer/CircularProgress.tsx @@ -0,0 +1,43 @@ +type CircularProgressProps = { + progress: number; + size: number; + strokeWidth: number; + ringClass: string; + trackClass: string; +}; + +export function CircularProgress({ + progress, + size, + strokeWidth, + ringClass, + trackClass, +}: CircularProgressProps) { + const radius = (size - strokeWidth) / 2; + const circumference = 2 * Math.PI * radius; + const offset = circumference - progress * circumference; + + return ( + + + + + ); +} diff --git a/frontend/src/components/classroom-timer/TimerAnimations.tsx b/frontend/src/components/classroom-timer/TimerAnimations.tsx new file mode 100644 index 0000000..07a6f7c --- /dev/null +++ b/frontend/src/components/classroom-timer/TimerAnimations.tsx @@ -0,0 +1,26 @@ +export function TimerAnimations() { + return ( + + ); +} diff --git a/frontend/src/components/classroom-timer/TimerControls.tsx b/frontend/src/components/classroom-timer/TimerControls.tsx new file mode 100644 index 0000000..ff930a1 --- /dev/null +++ b/frontend/src/components/classroom-timer/TimerControls.tsx @@ -0,0 +1,90 @@ +import { Maximize, Minimize, Pause, Play, RotateCcw, Volume2, VolumeX } from 'lucide-react'; + +type TimerControlsProps = { + isRunning: boolean; + isFinished: boolean; + remainingSeconds: number; + totalSeconds: number; + soundEnabled: boolean; + isProjection?: boolean; + onStart: () => void; + onPause: () => void; + onReset: () => void; + onToggleSound: () => void; + onToggleFullscreen: () => void; +}; + +export function TimerControls({ + isRunning, + isFinished, + remainingSeconds, + totalSeconds, + soundEnabled, + isProjection = false, + onStart, + onPause, + onReset, + onToggleSound, + onToggleFullscreen, +}: TimerControlsProps) { + const primarySizeClass = isProjection ? 'w-16 h-16 md:w-20 md:h-20' : 'w-14 h-14'; + const secondarySizeClass = isProjection ? 'w-12 h-12 md:w-14 md:h-14' : 'w-11 h-11'; + const primaryIconSize = isProjection ? 32 : 26; + const secondaryIconSize = isProjection ? 24 : 20; + const primaryButtonClass = `${primarySizeClass} rounded-full bg-white/20 backdrop-blur-md border border-white/30 flex items-center justify-center hover:bg-white/30 transition-all shadow-xl hover:scale-105 active:scale-95 disabled:opacity-30`; + const secondaryButtonClass = `${secondarySizeClass} rounded-full bg-white/10 backdrop-blur-md border border-white/20 flex items-center justify-center hover:bg-white/20 transition-all hover:scale-105 active:scale-95`; + + return ( +
+ {!isRunning && !isFinished && ( + + )} + {isRunning && ( + + )} + {(isFinished || (!isRunning && remainingSeconds < totalSeconds)) && ( + + )} + + + +
+ ); +} diff --git a/frontend/src/components/classroom-timer/TimerDisplay.tsx b/frontend/src/components/classroom-timer/TimerDisplay.tsx new file mode 100644 index 0000000..1b761cc --- /dev/null +++ b/frontend/src/components/classroom-timer/TimerDisplay.tsx @@ -0,0 +1,121 @@ +import { Monitor } from 'lucide-react'; + +import { CircularProgress } from '@/components/classroom-timer/CircularProgress'; +import { TimerControls } from '@/components/classroom-timer/TimerControls'; +import { TimerParticles } from '@/components/classroom-timer/TimerParticles'; +import type { ClassroomTimerActions, ClassroomTimerState } from '@/components/classroom-timer/types'; + +type TimerDisplayProps = { + state: ClassroomTimerState; + actions: ClassroomTimerActions; +}; + +export function TimerDisplay({ state, actions }: TimerDisplayProps) { + const { + selectedBackground, + isFinished, + progress, + urgencyColor, + formattedTime, + isRunning, + remainingSeconds, + totalSeconds, + soundEnabled, + displayParticles, + presets, + } = state; + + if (!selectedBackground) { + return null; + } + + return ( +
+
+ +
+ + +
+
+ +
+ + {formattedTime} + + {isFinished && ( + + Time's Up! + + )} + {!isRunning && !isFinished && remainingSeconds === totalSeconds && ( + + Ready + + )} + {isRunning && ( + + Running... + + )} +
+
+ +
+ actions.setSoundEnabled(!soundEnabled)} + onToggleFullscreen={actions.toggleFullscreen} + /> +
+ +
+ {presets.map((preset) => ( + + ))} +
+
+ +
+ +
+
+
+ ); +} diff --git a/frontend/src/components/classroom-timer/TimerParticles.tsx b/frontend/src/components/classroom-timer/TimerParticles.tsx new file mode 100644 index 0000000..8eae275 --- /dev/null +++ b/frontend/src/components/classroom-timer/TimerParticles.tsx @@ -0,0 +1,26 @@ +import type { TimerParticle } from '@/shared/types/classroomTimer'; + +type TimerParticlesProps = { + particles: readonly TimerParticle[]; +}; + +export function TimerParticles({ particles }: TimerParticlesProps) { + return ( +
+ {particles.map((particle) => ( +
+ ))} +
+ ); +} diff --git a/frontend/src/components/classroom-timer/TimerProjectionView.tsx b/frontend/src/components/classroom-timer/TimerProjectionView.tsx new file mode 100644 index 0000000..842192f --- /dev/null +++ b/frontend/src/components/classroom-timer/TimerProjectionView.tsx @@ -0,0 +1,106 @@ +import type { RefObject } from 'react'; +import { CircularProgress } from '@/components/classroom-timer/CircularProgress'; +import { TimerControls } from '@/components/classroom-timer/TimerControls'; +import { TimerParticles } from '@/components/classroom-timer/TimerParticles'; +import type { + ClassroomTimerActions, + ClassroomTimerState, +} from '@/components/classroom-timer/types'; + +type TimerProjectionViewProps = { + state: ClassroomTimerState; + actions: ClassroomTimerActions; + fullscreenRef: RefObject; +}; + +export function TimerProjectionView({ state, actions, fullscreenRef }: TimerProjectionViewProps) { + const { + selectedBackground, + isFinished, + progress, + urgencyColor, + formattedTime, + isRunning, + remainingSeconds, + totalSeconds, + soundEnabled, + fullscreenParticles, + presets, + } = state; + + if (!selectedBackground) { + return null; + } + + return ( +
+ +
+ + +
+
+ +
+ + {formattedTime} + + {isFinished && ( + + Time's Up! + + )} +
+
+ + actions.setSoundEnabled(!soundEnabled)} + onToggleFullscreen={actions.toggleFullscreen} + /> + +
+ {presets.slice(0, 6).map((preset) => ( + + ))} +
+
+
+ ); +} diff --git a/frontend/src/components/classroom-timer/TimerSettingsPanel.tsx b/frontend/src/components/classroom-timer/TimerSettingsPanel.tsx new file mode 100644 index 0000000..86f9ae3 --- /dev/null +++ b/frontend/src/components/classroom-timer/TimerSettingsPanel.tsx @@ -0,0 +1,214 @@ +import { + Check, + CloudRain, + Fish, + Minus, + Monitor, + Moon, + Mountain, + Music, + Palette, + Plus, + Settings, + Sparkles, + Sun, + TreePine, + Volume2, + Waves, +} from 'lucide-react'; +import type { LucideIcon } from 'lucide-react'; + +import type { ClassroomTimerActions, ClassroomTimerState } from '@/components/classroom-timer/types'; +import type { SensoryBackgroundIconId } from '@/shared/types/classroomTimer'; + +const BACKGROUND_ICONS: Record = { + waves: Waves, + sparkles: Sparkles, + sun: Sun, + moon: Moon, + 'tree-pine': TreePine, + 'cloud-rain': CloudRain, + fish: Fish, + mountain: Mountain, +}; + +type TimerSettingsPanelProps = { + state: ClassroomTimerState; + actions: ClassroomTimerActions; +}; + +export function TimerSettingsPanel({ state, actions }: TimerSettingsPanelProps) { + const { + customMinutes, + customSeconds, + selectedSound, + selectedBackground, + sounds, + backgrounds, + } = state; + + return ( +
+
+

+ + Custom Time +

+
+
+ +
+ + actions.parseCustomMinutes(event.target.value)} + className="w-14 h-8 bg-slate-700/50 border border-slate-600/50 rounded-lg text-center text-white text-sm font-mono focus:ring-2 focus:ring-cyan-500/50 outline-none" + /> + +
+
+ : +
+ +
+ + actions.parseCustomSeconds(event.target.value)} + className="w-14 h-8 bg-slate-700/50 border border-slate-600/50 rounded-lg text-center text-white text-sm font-mono focus:ring-2 focus:ring-cyan-500/50 outline-none" + /> + +
+
+
+ +
+ +
+

+ + Timer Sound +

+
+ {sounds.map((sound) => ( + + ))} +
+
+ +
+

+ + Sensory Background +

+
+ {backgrounds.map((background) => { + const BackgroundIcon = BACKGROUND_ICONS[background.iconId]; + return ( + + ); + })} +
+
+ + +
+ ); +} diff --git a/frontend/src/components/classroom-timer/TimerTips.tsx b/frontend/src/components/classroom-timer/TimerTips.tsx new file mode 100644 index 0000000..3e9605f --- /dev/null +++ b/frontend/src/components/classroom-timer/TimerTips.tsx @@ -0,0 +1,30 @@ +import { Sparkles } from 'lucide-react'; + +import type { ClassroomTimerState } from '@/components/classroom-timer/types'; + +interface TimerTipsProps { + readonly state: ClassroomTimerState; +} + +export function TimerTips({ state }: TimerTipsProps) { + if (state.tips.length === 0) { + return null; + } + + return ( +
+

+ + Timer Tips for Autism-Focused Classrooms +

+
+ {state.tips.map((tip) => ( +
+

{tip.title}

+

{tip.body}

+
+ ))} +
+
+ ); +} diff --git a/frontend/src/components/classroom-timer/types.ts b/frontend/src/components/classroom-timer/types.ts new file mode 100644 index 0000000..6ee3a51 --- /dev/null +++ b/frontend/src/components/classroom-timer/types.ts @@ -0,0 +1,5 @@ +import type { useClassroomTimer } from '@/business/classroom-timer/hooks'; + +export type ClassroomTimerState = ReturnType['state']; +export type ClassroomTimerActions = ReturnType['actions']; +export type ClassroomTimerRefs = ReturnType['refs']; diff --git a/frontend/src/components/community-service/CommunityEmptyResults.tsx b/frontend/src/components/community-service/CommunityEmptyResults.tsx new file mode 100644 index 0000000..4de9e78 --- /dev/null +++ b/frontend/src/components/community-service/CommunityEmptyResults.tsx @@ -0,0 +1,10 @@ +import { Globe } from 'lucide-react'; +import { StatePanel } from '@/components/ui/state-panel'; + +export function CommunityEmptyResults() { + return ( + + Try adjusting your search or filters to find more opportunities. + + ); +} diff --git a/frontend/src/components/community-service/CommunityFilters.tsx b/frontend/src/components/community-service/CommunityFilters.tsx new file mode 100644 index 0000000..d99dad7 --- /dev/null +++ b/frontend/src/components/community-service/CommunityFilters.tsx @@ -0,0 +1,81 @@ +import { ChevronDown, ChevronUp, Filter, Search } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { NativeSelect } from '@/components/ui/native-select'; +import { COMMUNITY_AGE_GROUPS } from '@/shared/constants/community'; +import type { CommunityServiceWorkflow } from '@/components/community-service/types'; + +interface CommunityFiltersProps { + readonly workflow: CommunityServiceWorkflow; +} + +export function CommunityFilters({ workflow }: CommunityFiltersProps) { + return ( +
+
+
+ + workflow.setSearchQuery(event.target.value)} + placeholder="Search organizations, categories, or keywords..." + className="w-full pl-9 pr-4 py-2.5 bg-slate-700/50 border border-slate-600/50 rounded-xl text-sm text-white placeholder-slate-500 focus:ring-2 focus:ring-green-500/50 focus:border-green-500/50 outline-none" + /> +
+ +
+ + {workflow.showFilters && ( +
+ + + +
+ )} +
+ ); +} diff --git a/frontend/src/components/community-service/CommunityIcon.tsx b/frontend/src/components/community-service/CommunityIcon.tsx new file mode 100644 index 0000000..d6cf19d --- /dev/null +++ b/frontend/src/components/community-service/CommunityIcon.tsx @@ -0,0 +1,29 @@ +import { + BookOpen, + Building2, + Handshake, + Heart, + Paintbrush, + Star, + TreePine, + Users, + Utensils, +} from 'lucide-react'; +import type { CommunityCategoryIconKey } from '@/shared/constants/community'; + +interface CommunityIconProps { + readonly iconKey: CommunityCategoryIconKey; + readonly size?: number; +} + +export function CommunityIcon({ iconKey, size = 16 }: CommunityIconProps) { + if (iconKey === 'utensils') return ; + if (iconKey === 'heart') return ; + if (iconKey === 'users') return ; + if (iconKey === 'bookOpen') return ; + if (iconKey === 'building') return ; + if (iconKey === 'tree') return ; + if (iconKey === 'star') return ; + if (iconKey === 'paintbrush') return ; + return ; +} diff --git a/frontend/src/components/community-service/CommunityOrganizationCard.tsx b/frontend/src/components/community-service/CommunityOrganizationCard.tsx new file mode 100644 index 0000000..158b652 --- /dev/null +++ b/frontend/src/components/community-service/CommunityOrganizationCard.tsx @@ -0,0 +1,97 @@ +import type { KeyboardEvent } from 'react'; +import { ChevronDown, ChevronUp, Globe, MapPin, Star } from 'lucide-react'; +import { + COMMUNITY_CATEGORY_ICON_KEYS, + COMMUNITY_PARTNERSHIP_TYPE_CLASSES, + COMMUNITY_PARTNERSHIP_TYPE_LABELS, +} from '@/shared/constants/community'; +import type { CommunityOrganization } from '@/shared/types/community'; +import { CommunityIcon } from '@/components/community-service/CommunityIcon'; +import { CommunityOrganizationDetails } from '@/components/community-service/CommunityOrganizationDetails'; + +interface CommunityOrganizationCardProps { + readonly organization: CommunityOrganization; + readonly expanded: boolean; + readonly saved: boolean; + readonly onToggleExpanded: () => void; + readonly onToggleSaved: () => void; +} + +export function CommunityOrganizationCard({ + organization, + expanded, + saved, + onToggleExpanded, + onToggleSaved, +}: CommunityOrganizationCardProps) { + const handleCardKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + onToggleExpanded(); + } + }; + + return ( +
+
+
+
+
+ {COMMUNITY_CATEGORY_ICON_KEYS[organization.category] + ? + : } +
+
+
+

{organization.name}

+ {organization.featured && ( + FEATURED + )} +
+
+ + {COMMUNITY_PARTNERSHIP_TYPE_LABELS[organization.partnershipType]} + + + {organization.distance} + + {organization.category} +
+

{organization.description}

+
+
+
+ + {expanded ? : } +
+
+
+ + {expanded && ( + + )} +
+ ); +} diff --git a/frontend/src/components/community-service/CommunityOrganizationDetails.tsx b/frontend/src/components/community-service/CommunityOrganizationDetails.tsx new file mode 100644 index 0000000..30a26d4 --- /dev/null +++ b/frontend/src/components/community-service/CommunityOrganizationDetails.tsx @@ -0,0 +1,83 @@ +import { Building2, CheckCircle, ExternalLink, Mail, MapPin, Phone } from 'lucide-react'; +import type { CommunityOrganization } from '@/shared/types/community'; + +interface CommunityOrganizationDetailsProps { + readonly organization: CommunityOrganization; + readonly saved: boolean; + readonly onToggleSaved: () => void; +} + +export function CommunityOrganizationDetails({ + organization, + saved, + onToggleSaved, +}: CommunityOrganizationDetailsProps) { + return ( +
+
+
+

+ Contact Information +

+
+
+ + {organization.address} +
+
+ + {organization.phone} +
+ + +
+
+ +
+

+ Available Opportunities +

+
+ {organization.opportunities.map((opportunity) => ( +
+
+ {opportunity} +
+ ))} +
+
+
+ +
+
+ Age Groups: + {organization.ageGroups.map((ageGroup) => ( + {ageGroup} + ))} +
+
+ + +
+
+
+ ); +} diff --git a/frontend/src/components/community-service/CommunityResults.tsx b/frontend/src/components/community-service/CommunityResults.tsx new file mode 100644 index 0000000..a3570d0 --- /dev/null +++ b/frontend/src/components/community-service/CommunityResults.tsx @@ -0,0 +1,32 @@ +import type { CommunityServiceWorkflow } from '@/components/community-service/types'; +import { CommunityEmptyResults } from '@/components/community-service/CommunityEmptyResults'; +import { CommunityOrganizationCard } from '@/components/community-service/CommunityOrganizationCard'; + +interface CommunityResultsProps { + readonly workflow: CommunityServiceWorkflow; +} + +export function CommunityResults({ workflow }: CommunityResultsProps) { + return ( +
+
+

+ {workflow.filteredOrganizations.length} organization{workflow.filteredOrganizations.length !== 1 ? 's' : ''} found +

+
+ + {workflow.filteredOrganizations.map((organization) => ( + workflow.toggleExpandedOrganization(organization.id)} + onToggleSaved={() => workflow.toggleSavedOrganization(organization.id)} + /> + ))} + + {workflow.filteredOrganizations.length === 0 && } +
+ ); +} diff --git a/frontend/src/components/community-service/CommunityServiceHeader.tsx b/frontend/src/components/community-service/CommunityServiceHeader.tsx new file mode 100644 index 0000000..cc78f0f --- /dev/null +++ b/frontend/src/components/community-service/CommunityServiceHeader.tsx @@ -0,0 +1,30 @@ +import { Globe, Handshake } from 'lucide-react'; +import { ModuleHeader } from '@/components/ui/module-header'; + +export function CommunityServiceHeader() { + return ( + <> + + +
+
+
+ +
+
+

Building Community Connections

+

+ Explore local organizations that welcome school partnerships and community service projects. These opportunities help students develop social skills, build confidence, and contribute meaningfully to their community. +

+
+
+
+ + ); +} diff --git a/frontend/src/components/community-service/CommunityServiceView.tsx b/frontend/src/components/community-service/CommunityServiceView.tsx new file mode 100644 index 0000000..cdad0d5 --- /dev/null +++ b/frontend/src/components/community-service/CommunityServiceView.tsx @@ -0,0 +1,35 @@ +import type { CommunityServiceWorkflow } from '@/components/community-service/types'; +import { CommunityFilters } from '@/components/community-service/CommunityFilters'; +import { CommunityResults } from '@/components/community-service/CommunityResults'; +import { CommunityServiceHeader } from '@/components/community-service/CommunityServiceHeader'; +import { CommunityStatsGrid } from '@/components/community-service/CommunityStatsGrid'; +import { StatePanel } from '@/components/ui/state-panel'; + +interface CommunityServiceViewProps { + readonly workflow: CommunityServiceWorkflow; +} + +export function CommunityServiceView({ workflow }: CommunityServiceViewProps) { + return ( +
+ + {workflow.isLoading && ( + + Loading community organizations... + + )} + {workflow.error && ( + + Community organizations could not be loaded from the backend. + + )} + {!workflow.isLoading && !workflow.error && ( + <> + + + + + )} +
+ ); +} diff --git a/frontend/src/components/community-service/CommunityStatsGrid.tsx b/frontend/src/components/community-service/CommunityStatsGrid.tsx new file mode 100644 index 0000000..cf43031 --- /dev/null +++ b/frontend/src/components/community-service/CommunityStatsGrid.tsx @@ -0,0 +1,53 @@ +import { Building2, Handshake, Heart, Star } from 'lucide-react'; +import type { CommunityServiceWorkflow } from '@/components/community-service/types'; + +interface CommunityStatsGridProps { + readonly workflow: CommunityServiceWorkflow; +} + +export function CommunityStatsGrid({ workflow }: CommunityStatsGridProps) { + const stats = [ + { + label: 'Organizations', + value: workflow.stats.organizations.toString(), + color: 'from-green-500 to-emerald-600', + icon: , + }, + { + label: 'Service Projects', + value: workflow.stats.serviceProjects.toString(), + color: 'from-emerald-500 to-teal-600', + icon: , + }, + { + label: 'School Partners', + value: workflow.stats.schoolPartners.toString(), + color: 'from-blue-500 to-indigo-600', + icon: , + }, + { + label: 'Saved', + value: workflow.stats.saved.toString(), + color: 'from-amber-500 to-orange-600', + icon: , + }, + ]; + + return ( +
+ {stats.map((stat) => ( +
+
+
+ {stat.icon} +
+
+

{stat.value}

+

{stat.label}

+
+
+
+ ))} +
+ ); +} diff --git a/frontend/src/components/community-service/types.ts b/frontend/src/components/community-service/types.ts new file mode 100644 index 0000000..87ca23d --- /dev/null +++ b/frontend/src/components/community-service/types.ts @@ -0,0 +1,3 @@ +import type { useCommunityService } from '@/business/community/hooks'; + +export type CommunityServiceWorkflow = ReturnType; diff --git a/frontend/src/components/dashboard/DashboardFramePreview.tsx b/frontend/src/components/dashboard/DashboardFramePreview.tsx new file mode 100644 index 0000000..3cd0da1 --- /dev/null +++ b/frontend/src/components/dashboard/DashboardFramePreview.tsx @@ -0,0 +1,68 @@ +import { ArrowRight } from 'lucide-react'; + +import type { FrameEntryViewModel } from '@/business/frame/types'; +import { Button } from '@/components/ui/button'; +import { FRAME_SECTION_LABELS } from '@/shared/constants/frame'; +import type { ModuleId } from '@/shared/types/app'; + +interface DashboardFramePreviewProps { + readonly latestFrame: FrameEntryViewModel | null; + readonly onNavigate: (module: ModuleId) => void; +} + +export function DashboardFramePreview({ + latestFrame, + onNavigate, +}: DashboardFramePreviewProps) { + return ( +
+
+
+

+ + F + + This Week's F.R.A.M.E. +

+

+ {latestFrame + ? `Posted ${latestFrame.postedDate} by ${latestFrame.author}` + : 'Waiting for the first published entry'} +

+
+ +
+
+ {latestFrame ? ( + FRAME_SECTION_LABELS.map((section) => ( +
+
+ {section.letter} +
+
+

{section.label}

+

{latestFrame[section.key]}

+
+
+ )) + ) : ( +
+

No F.R.A.M.E. entry is published yet.

+

+ Directors and superintendents can create the first entry in the F.R.A.M.E. module. +

+
+ )} +
+
+ ); +} diff --git a/frontend/src/components/dashboard/DashboardHero.tsx b/frontend/src/components/dashboard/DashboardHero.tsx new file mode 100644 index 0000000..2ae8e6c --- /dev/null +++ b/frontend/src/components/dashboard/DashboardHero.tsx @@ -0,0 +1,39 @@ +import { Sparkles } from 'lucide-react'; + +import type { FrameEntryViewModel } from '@/business/frame/types'; +import { HERO_IMAGE } from '@/shared/constants/appData'; + +interface DashboardHeroProps { + readonly greeting: string; + readonly userName: string; + readonly latestFrame: FrameEntryViewModel | null; +} + +export function DashboardHero({ + greeting, + userName, + latestFrame, +}: DashboardHeroProps) { + return ( +
+ Classroom +
+
+
+
+ + + {latestFrame ? `Week of ${latestFrame.weekOf}` : 'No weekly focus posted'} + +
+

+ {greeting}, {userName}! +

+

+ Welcome to FRAMEworks. Your campus operations hub for today. +

+
+
+
+ ); +} diff --git a/frontend/src/components/dashboard/DashboardQuickActions.tsx b/frontend/src/components/dashboard/DashboardQuickActions.tsx new file mode 100644 index 0000000..3279e69 --- /dev/null +++ b/frontend/src/components/dashboard/DashboardQuickActions.tsx @@ -0,0 +1,52 @@ +import { AlertTriangle, BookOpen, FileText, Heart, Shield, Timer } from 'lucide-react'; +import type { LucideIcon } from 'lucide-react'; + +import { Button } from '@/components/ui/button'; +import type { + DashboardQuickAction, + DashboardQuickActionIconId, +} from '@/shared/constants/dashboard'; +import type { ModuleId } from '@/shared/types/app'; + +const QUICK_ACTION_ICONS: Record = { + book: BookOpen, + timer: Timer, + shield: Shield, + heart: Heart, + file: FileText, + alert: AlertTriangle, +}; + +interface DashboardQuickActionsProps { + readonly actions: readonly DashboardQuickAction[]; + readonly onNavigate: (module: ModuleId) => void; +} + +export function DashboardQuickActions({ + actions, + onNavigate, +}: DashboardQuickActionsProps) { + return ( +
+ {actions.map((action) => { + const Icon = QUICK_ACTION_ICONS[action.iconId]; + + return ( + + ); + })} +
+ ); +} diff --git a/frontend/src/components/dashboard/DashboardQuotePanel.tsx b/frontend/src/components/dashboard/DashboardQuotePanel.tsx new file mode 100644 index 0000000..3f650df --- /dev/null +++ b/frontend/src/components/dashboard/DashboardQuotePanel.tsx @@ -0,0 +1,42 @@ +import { Quote, Sun } from 'lucide-react'; + +import type { DashboardContentState } from '@/business/dashboard/types'; +import type { DashboardEncouragingQuote } from '@/shared/types/dashboard'; + +interface DashboardQuotePanelProps { + readonly quote: DashboardEncouragingQuote | null; + readonly state: DashboardContentState; +} + +export function DashboardQuotePanel({ quote, state }: DashboardQuotePanelProps) { + return ( +
+
+
+
+ +
+
+

+ + Encouraging Word of the Day +

+ {state.isLoading ? ( +

Loading today's quote...

+ ) : state.isError ? ( +

Today's quote could not be loaded.

+ ) : quote ? ( + <> +

+ "{quote.quote}" +

+

{quote.author}

+ + ) : ( +

No quote is published for today.

+ )} +
+
+
+ ); +} diff --git a/frontend/src/components/dashboard/DashboardSignOfWeek.tsx b/frontend/src/components/dashboard/DashboardSignOfWeek.tsx new file mode 100644 index 0000000..bfadeb5 --- /dev/null +++ b/frontend/src/components/dashboard/DashboardSignOfWeek.tsx @@ -0,0 +1,53 @@ +import { HandMetal } from 'lucide-react'; + +import type { DashboardContentState } from '@/business/dashboard/types'; +import { Button } from '@/components/ui/button'; +import type { ModuleId } from '@/shared/types/app'; +import type { DashboardSignOfWeek as DashboardSignOfWeekPayload } from '@/shared/types/dashboard'; + +interface DashboardSignOfWeekProps { + readonly sign: DashboardSignOfWeekPayload | null; + readonly state: DashboardContentState; + readonly onNavigate: (module: ModuleId) => void; +} + +export function DashboardSignOfWeek({ + sign, + state, + onNavigate, +}: DashboardSignOfWeekProps) { + return ( +
+

+ + Sign of the Week +

+
+ {state.isLoading ? ( +

Loading sign...

+ ) : state.isError ? ( +

Sign of the week could not be loaded.

+ ) : sign ? ( + <> +
+ {sign.alt} +
+

"{sign.word}"

+

{sign.description}

+ + + ) : ( +

No sign is published for this week.

+ )} +
+
+ ); +} diff --git a/frontend/src/components/dashboard/DashboardUpcomingEvents.tsx b/frontend/src/components/dashboard/DashboardUpcomingEvents.tsx new file mode 100644 index 0000000..7635ec9 --- /dev/null +++ b/frontend/src/components/dashboard/DashboardUpcomingEvents.tsx @@ -0,0 +1,60 @@ +import { Calendar, Clock } from 'lucide-react'; + +import type { DashboardContentState } from '@/business/dashboard/types'; +import { COMMUNICATION_EVENT_TYPE_COLORS } from '@/shared/constants/communications'; +import type { CommunicationEventDto } from '@/shared/types/communications'; + +interface DashboardUpcomingEventsProps { + readonly events: readonly CommunicationEventDto[]; + readonly state: DashboardContentState; +} + +export function DashboardUpcomingEvents({ + events, + state, +}: DashboardUpcomingEventsProps) { + return ( +
+

+ + Upcoming Events +

+
+ {state.isLoading ? ( +
+ +
+ ) : state.isError ? ( +
+

Events could not be loaded.

+
+ ) : events.length > 0 ? ( + events.map((event) => ( +
+ + {event.type.toUpperCase()} + +
+

{event.title}

+

+ {new Date(event.date).toLocaleDateString('en-US', { + weekday: 'short', + month: 'short', + day: 'numeric', + })} +

+
+
+ )) + ) : ( +
+

No upcoming events are posted yet.

+
+ )} +
+
+ ); +} diff --git a/frontend/src/components/dashboard/DashboardView.tsx b/frontend/src/components/dashboard/DashboardView.tsx new file mode 100644 index 0000000..7ee9157 --- /dev/null +++ b/frontend/src/components/dashboard/DashboardView.tsx @@ -0,0 +1,64 @@ +import type { DashboardPage } from '@/business/dashboard/types'; +import { DashboardFramePreview } from '@/components/dashboard/DashboardFramePreview'; +import { DashboardHero } from '@/components/dashboard/DashboardHero'; +import { DashboardQuickActions } from '@/components/dashboard/DashboardQuickActions'; +import { DashboardQuotePanel } from '@/components/dashboard/DashboardQuotePanel'; +import { DashboardSignOfWeek } from '@/components/dashboard/DashboardSignOfWeek'; +import { DashboardUpcomingEvents } from '@/components/dashboard/DashboardUpcomingEvents'; +import { DashboardWeeklyProgress } from '@/components/dashboard/DashboardWeeklyProgress'; +import { DashboardZoneCheckIn } from '@/components/dashboard/DashboardZoneCheckIn'; + +interface DashboardViewProps { + readonly page: DashboardPage; +} + +export function DashboardView({ page }: DashboardViewProps) { + return ( +
+ + + + + + +
+ + +
+ + + +
+
+ + +
+ ); +} diff --git a/frontend/src/components/dashboard/DashboardWeeklyProgress.tsx b/frontend/src/components/dashboard/DashboardWeeklyProgress.tsx new file mode 100644 index 0000000..8e4f3be --- /dev/null +++ b/frontend/src/components/dashboard/DashboardWeeklyProgress.tsx @@ -0,0 +1,44 @@ +import { TrendingUp } from 'lucide-react'; + +import type { DashboardContentState } from '@/business/dashboard/types'; +import type { DashboardComplianceItem } from '@/shared/types/dashboard'; + +interface DashboardWeeklyProgressProps { + readonly items: readonly DashboardComplianceItem[]; + readonly state: DashboardContentState; +} + +export function DashboardWeeklyProgress({ + items, + state, +}: DashboardWeeklyProgressProps) { + return ( +
+

+ + Weekly Progress +

+
+ {state.isLoading ? ( +

Loading weekly progress...

+ ) : state.isError ? ( +

Weekly progress could not be loaded.

+ ) : items.length > 0 ? ( + items.map((item) => ( +
+
+ {item.label} + {item.status} +
+
+
+
+
+ )) + ) : ( +

No weekly progress items are published yet.

+ )} +
+
+ ); +} diff --git a/frontend/src/components/dashboard/DashboardZoneCheckIn.tsx b/frontend/src/components/dashboard/DashboardZoneCheckIn.tsx new file mode 100644 index 0000000..970b11c --- /dev/null +++ b/frontend/src/components/dashboard/DashboardZoneCheckIn.tsx @@ -0,0 +1,67 @@ +import { Button } from '@/components/ui/button'; +import type { DashboardZoneOption } from '@/shared/constants/dashboard'; +import type { ZoneColor } from '@/shared/types/app'; +import { cn } from '@/lib/utils'; + +interface DashboardZoneCheckInProps { + readonly zones: readonly DashboardZoneOption[]; + readonly activeZone: ZoneColor | null; + readonly isSaving: boolean; + readonly errorMessage: string | null; + readonly onCheckIn: (zone: ZoneColor) => Promise; + readonly onReset: () => Promise; +} + +export function DashboardZoneCheckIn({ + zones, + activeZone, + isSaving, + errorMessage, + onCheckIn, + onReset, +}: DashboardZoneCheckInProps) { + return ( +
+
+
+

Today's Zone Check-In

+

How are you feeling right now? Saved to your profile.

+
+ {activeZone && ( + + )} +
+
+ {zones.map((zone) => ( + + ))} +
+ {errorMessage &&

{errorMessage}

} +
+ ); +} diff --git a/frontend/src/components/director-dashboard/DirectorDashboardHeader.tsx b/frontend/src/components/director-dashboard/DirectorDashboardHeader.tsx new file mode 100644 index 0000000..959f560 --- /dev/null +++ b/frontend/src/components/director-dashboard/DirectorDashboardHeader.tsx @@ -0,0 +1,40 @@ +import { BarChart3, Database } from 'lucide-react'; + +import { ModuleHeader } from '@/components/ui/module-header'; +import type { DirectorDashboardTimeRange } from '@/shared/constants/directorDashboard'; +import { DirectorTimeRangeTabs } from '@/components/director-dashboard/DirectorTimeRangeTabs'; + +interface DirectorDashboardHeaderProps { + readonly timeRange: DirectorDashboardTimeRange; + readonly onTimeRangeChange: (timeRange: DirectorDashboardTimeRange) => void; +} + +export function DirectorDashboardHeader({ + timeRange, + onTimeRangeChange, +}: DirectorDashboardHeaderProps) { + return ( +
+ + Campus oversight, compliance tracking, and risk management + + + Live Database + + + )} + /> + +
+ ); +} diff --git a/frontend/src/components/director-dashboard/DirectorDashboardView.tsx b/frontend/src/components/director-dashboard/DirectorDashboardView.tsx new file mode 100644 index 0000000..2857d53 --- /dev/null +++ b/frontend/src/components/director-dashboard/DirectorDashboardView.tsx @@ -0,0 +1,70 @@ +import type { ModuleId } from '@/shared/types/app'; +import type { DirectorDashboardPage } from '@/business/director-dashboard/types'; +import { DirectorDashboardHeader } from '@/components/director-dashboard/DirectorDashboardHeader'; +import { DirectorOverviewCards } from '@/components/director-dashboard/DirectorOverviewCards'; +import { DirectorQuickActions } from '@/components/director-dashboard/DirectorQuickActions'; +import { DirectorQuizResultsPanel } from '@/components/director-dashboard/DirectorQuizResultsPanel'; +import { DirectorRecentFramePanel } from '@/components/director-dashboard/DirectorRecentFramePanel'; +import { DirectorRiskList } from '@/components/director-dashboard/DirectorRiskList'; +import { StatePanel } from '@/components/ui/state-panel'; +import { getOptionalErrorMessage } from '@/shared/errors/errorMessages'; + +interface DirectorDashboardViewProps { + readonly page: DirectorDashboardPage; + readonly onOpenModule: (module: ModuleId) => void; +} + +export function DirectorDashboardView({ + page, + onOpenModule, +}: DirectorDashboardViewProps) { + const errorMessage = getOptionalErrorMessage(page.error); + + if (page.isLoading) { + return ( + + Loading director dashboard... + + ); + } + + if (errorMessage) { + return ( + + {errorMessage} + + ); + } + + return ( +
+ + +
+
+ + +
+
+ + +
+
+
+ ); +} diff --git a/frontend/src/components/director-dashboard/DirectorOverviewCards.tsx b/frontend/src/components/director-dashboard/DirectorOverviewCards.tsx new file mode 100644 index 0000000..8fbef58 --- /dev/null +++ b/frontend/src/components/director-dashboard/DirectorOverviewCards.tsx @@ -0,0 +1,51 @@ +import type { ModuleId } from '@/shared/types/app'; +import type { DirectorOverviewCard } from '@/business/director-dashboard/types'; +import { Button } from '@/components/ui/button'; +import { + directorOverviewIcons, + directorOverviewToneClasses, + directorTrendClasses, + directorTrendIcons, +} from '@/components/director-dashboard/directorDashboardViewConfig'; + +interface DirectorOverviewCardsProps { + readonly cards: readonly DirectorOverviewCard[]; + readonly onOpenModule: (module: ModuleId) => void; +} + +export function DirectorOverviewCards({ + cards, + onOpenModule, +}: DirectorOverviewCardsProps) { + return ( +
+ {cards.map((card) => { + const OverviewIcon = directorOverviewIcons[card.iconId]; + const TrendIcon = directorTrendIcons[card.trend]; + + return ( + + ); + })} +
+ ); +} diff --git a/frontend/src/components/director-dashboard/DirectorQuickActions.tsx b/frontend/src/components/director-dashboard/DirectorQuickActions.tsx new file mode 100644 index 0000000..6aecf63 --- /dev/null +++ b/frontend/src/components/director-dashboard/DirectorQuickActions.tsx @@ -0,0 +1,40 @@ +import type { ModuleId } from '@/shared/types/app'; +import type { DirectorQuickActionConfig } from '@/shared/constants/directorDashboard'; +import { Button } from '@/components/ui/button'; +import { + directorQuickActionIcons, + directorQuickActionToneClasses, +} from '@/components/director-dashboard/directorDashboardViewConfig'; + +interface DirectorQuickActionsProps { + readonly actions: readonly DirectorQuickActionConfig[]; + readonly onOpenModule: (module: ModuleId) => void; +} + +export function DirectorQuickActions({ + actions, + onOpenModule, +}: DirectorQuickActionsProps) { + return ( +
+

Quick Actions

+
+ {actions.map((action) => { + const ActionIcon = action.iconId ? directorQuickActionIcons[action.iconId] : undefined; + + return ( + + ); + })} +
+
+ ); +} diff --git a/frontend/src/components/director-dashboard/DirectorQuizResultsPanel.tsx b/frontend/src/components/director-dashboard/DirectorQuizResultsPanel.tsx new file mode 100644 index 0000000..1153619 --- /dev/null +++ b/frontend/src/components/director-dashboard/DirectorQuizResultsPanel.tsx @@ -0,0 +1,59 @@ +import { Users } from 'lucide-react'; + +import { StatePanel } from '@/components/ui/state-panel'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import type { SafetyQuizResultDto } from '@/shared/types/safetyQuiz'; + +interface DirectorQuizResultsPanelProps { + readonly results: readonly SafetyQuizResultDto[]; +} + +export function DirectorQuizResultsPanel({ results }: DirectorQuizResultsPanelProps) { + return ( +
+

+ + Quiz Results from Database +

+ {results.length > 0 ? ( + + + + Staff + Role + Score + Date + + + + {results.map((result) => ( + + {result.user_name} + {result.user_role} + + + {result.score}/{result.total_questions} + + + + {new Date(result.completed_at).toLocaleDateString()} + + + ))} + +
+ ) : ( + + No quiz results yet. Staff will appear here after completing quizzes. + + )} +
+ ); +} diff --git a/frontend/src/components/director-dashboard/DirectorRecentFramePanel.tsx b/frontend/src/components/director-dashboard/DirectorRecentFramePanel.tsx new file mode 100644 index 0000000..9330041 --- /dev/null +++ b/frontend/src/components/director-dashboard/DirectorRecentFramePanel.tsx @@ -0,0 +1,60 @@ +import { Eye } from 'lucide-react'; + +import type { ModuleId } from '@/shared/types/app'; +import type { DirectorFramePreview } from '@/business/director-dashboard/types'; +import { Button } from '@/components/ui/button'; +import { StatePanel } from '@/components/ui/state-panel'; +import { + directorFrameSectionClasses, + directorNavigateIcon, +} from '@/components/director-dashboard/directorDashboardViewConfig'; + +interface DirectorRecentFramePanelProps { + readonly framePreviews: readonly DirectorFramePreview[]; + readonly onOpenModule: (module: ModuleId) => void; +} + +export function DirectorRecentFramePanel({ + framePreviews, + onOpenModule, +}: DirectorRecentFramePanelProps) { + const NavigateIcon = directorNavigateIcon; + + return ( +
+

+ + F.R.A.M.E. Tracker +

+ {framePreviews.length > 0 ? ( +
+ {framePreviews.map((preview, index) => ( +
+

{preview.week}

+
+ {preview.sections.map((section) => ( +
+ {section.letter} + {section.text} +
+ ))} +
+
+ ))} +
+ ) : ( + + No F.R.A.M.E. entries are available yet. + + )} + +
+ ); +} diff --git a/frontend/src/components/director-dashboard/DirectorRiskList.tsx b/frontend/src/components/director-dashboard/DirectorRiskList.tsx new file mode 100644 index 0000000..c20f190 --- /dev/null +++ b/frontend/src/components/director-dashboard/DirectorRiskList.tsx @@ -0,0 +1,48 @@ +import type { ModuleId } from '@/shared/types/app'; +import type { DirectorRiskArea } from '@/business/director-dashboard/types'; +import { Button } from '@/components/ui/button'; +import { + directorNavigateIcon, + directorRiskIcon, + directorRiskSeverityClasses, +} from '@/components/director-dashboard/directorDashboardViewConfig'; + +interface DirectorRiskListProps { + readonly risks: readonly DirectorRiskArea[]; + readonly onOpenModule: (module: ModuleId) => void; +} + +export function DirectorRiskList({ + risks, + onOpenModule, +}: DirectorRiskListProps) { + const RiskIcon = directorRiskIcon; + const NavigateIcon = directorNavigateIcon; + + return ( +
+

+ + Risk Areas +

+
+ {risks.map((risk) => ( + + ))} +
+
+ ); +} diff --git a/frontend/src/components/director-dashboard/DirectorTimeRangeTabs.tsx b/frontend/src/components/director-dashboard/DirectorTimeRangeTabs.tsx new file mode 100644 index 0000000..24546c2 --- /dev/null +++ b/frontend/src/components/director-dashboard/DirectorTimeRangeTabs.tsx @@ -0,0 +1,34 @@ +import { Button } from '@/components/ui/button'; +import { + DIRECTOR_DASHBOARD_TIME_RANGES, + type DirectorDashboardTimeRange, +} from '@/shared/constants/directorDashboard'; + +interface DirectorTimeRangeTabsProps { + readonly timeRange: DirectorDashboardTimeRange; + readonly onTimeRangeChange: (timeRange: DirectorDashboardTimeRange) => void; +} + +export function DirectorTimeRangeTabs({ + timeRange, + onTimeRangeChange, +}: DirectorTimeRangeTabsProps) { + return ( +
+ {DIRECTOR_DASHBOARD_TIME_RANGES.map((range) => ( + + ))} +
+ ); +} diff --git a/frontend/src/components/director-dashboard/directorDashboardViewConfig.ts b/frontend/src/components/director-dashboard/directorDashboardViewConfig.ts new file mode 100644 index 0000000..05186fe --- /dev/null +++ b/frontend/src/components/director-dashboard/directorDashboardViewConfig.ts @@ -0,0 +1,78 @@ +import { + AlertTriangle, + ArrowRight, + ClipboardCheck, + Clock, + Eye, + Shield, + TrendingDown, + TrendingUp, + Users, +} from 'lucide-react'; +import type { LucideIcon } from 'lucide-react'; + +import type { + DirectorDashboardRiskSeverity, + DirectorDashboardTrend, + DirectorFrameSectionLetter, + DirectorOverviewIconId, + DirectorOverviewTone, +} from '@/business/director-dashboard/types'; +import type { + DirectorQuickActionIconId, + DirectorQuickActionTone, +} from '@/shared/constants/directorDashboard'; + +export const directorOverviewIcons: Record = { + clock: Clock, + shield: Shield, + eye: Eye, + users: Users, +}; + +export const directorTrendIcons: Record = { + up: TrendingUp, + down: TrendingDown, +}; + +export const directorRiskIcon = AlertTriangle; +export const directorNavigateIcon = ArrowRight; + +export const directorQuickActionIcons: Partial> = { + clipboard: ClipboardCheck, +}; + +export const directorOverviewToneClasses: Record = { + orange: 'from-orange-400 to-orange-600', + blue: 'from-blue-400 to-blue-600', + amber: 'from-amber-400 to-amber-600', + purple: 'from-purple-400 to-purple-600', +}; + +export const directorTrendClasses: Record = { + up: 'text-emerald-600', + down: 'text-red-600', +}; + +export const directorRiskSeverityClasses: Record = { + high: 'bg-red-100 text-red-700 border-red-200', + medium: 'bg-amber-100 text-amber-700 border-amber-200', + low: 'bg-blue-100 text-blue-700 border-blue-200', +}; + +export const directorFrameSectionClasses: Record = { + F: 'text-violet-600', + R: 'text-amber-600', + A: 'text-emerald-600', + M: 'text-blue-600', + E: 'text-pink-600', +}; + +export const directorQuickActionToneClasses: Record = { + indigo: 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200', + amber: 'bg-amber-100 text-amber-700 hover:bg-amber-200', + blue: 'bg-blue-100 text-blue-700 hover:bg-blue-200', + orange: 'bg-orange-100 text-orange-700 hover:bg-orange-200', + rose: 'bg-rose-100 text-rose-700 hover:bg-rose-200', + red: 'bg-red-100 text-red-700 hover:bg-red-200', +}; diff --git a/frontend/src/components/emotional-intelligence/AssessmentTab.tsx b/frontend/src/components/emotional-intelligence/AssessmentTab.tsx new file mode 100644 index 0000000..62461c0 --- /dev/null +++ b/frontend/src/components/emotional-intelligence/AssessmentTab.tsx @@ -0,0 +1,275 @@ +import { + ArrowRight, + Brain, + CheckCircle, + Eye, + Fingerprint, + Heart, + RefreshCw, + RotateCcw, + Shield, + Sparkles, + TrendingUp, + Users, +} from 'lucide-react'; +import type { LucideIcon } from 'lucide-react'; + +import { StatePanel } from '@/components/ui/state-panel'; +import type { + EmotionalIntelligencePageActions, + EmotionalIntelligencePageState, +} from '@/components/emotional-intelligence/types'; +import type { EmotionalIntelligenceTopicIconId } from '@/shared/types/emotionalIntelligence'; +import { getPersonalityType } from '@/shared/constants/personalityCatalog'; + +const TOPIC_ICONS: Record = { + shield: Shield, + brain: Brain, + heart: Heart, + eye: Eye, +}; + +type AssessmentTabProps = { + state: EmotionalIntelligencePageState; + actions: EmotionalIntelligencePageActions; +}; + +export function AssessmentTab({ state, actions }: AssessmentTabProps) { + return ( +
+
+ {state.contentLoading ? ( + + Loading assessment content... + + ) : state.contentError ? ( + + Emotional intelligence content could not be loaded from the backend. + + ) : state.assessmentQuestions.length === 0 ? ( + + Emotional intelligence assessment content is not available yet. + + ) : !state.assessmentStarted && !state.assessmentComplete ? ( + + ) : state.assessmentComplete ? ( + + ) : ( + + )} +
+ + +
+ ); +} + +function AssessmentIntro({ state, actions }: AssessmentTabProps) { + return ( +
+
+
+ +
+

EI Self-Assessment

+

{state.assessmentQuestions.length} reflective questions - Private results - Takes 5 minutes

+
+ +
+ {state.weeklyTopics.map((topic) => { + const TopicIcon = TOPIC_ICONS[topic.iconId]; + return ( +
+
+

{topic.title}

+

{topic.desc}

+
+ ); + })} +
+
+ ); +} + +function AssessmentResults({ state, actions }: AssessmentTabProps) { + const { assessmentLevel } = state; + + return ( +
+
+
+ {state.percentage}% +
+

{assessmentLevel.label}

+

{assessmentLevel.desc}

+
+
+
+ Your Score + {state.totalScore}/{state.maxScore} +
+
+
+
+
+
+

Your Growth Path

+
    + {state.growthTips.slice(0, 3).map((tip) => ( +
  • + + {tip} +
  • + ))} +
+
+
+ + +
+
+ ); +} + +function AssessmentQuestion({ state, actions }: AssessmentTabProps) { + const question = state.assessmentQuestions[state.currentQuestionIndex]; + + if (!question) { + return ( +
+ Current assessment question is unavailable. +
+ ); + } + + return ( +
+
+ Question {state.currentQuestionIndex + 1} of {state.assessmentQuestions.length} + Private & Confidential +
+
+
+
+

{question.q}

+
+ {question.options.map((option, index) => ( + + ))} +
+
+ ); +} + +function AssessmentSidebar({ state, actions }: AssessmentTabProps) { + const personalityType = state.personalityResult ? getPersonalityType(state.personalityResult, state.personalityTypes) : null; + + return ( +
+
+
+
+ +
+
+

Personality Type Quiz

+

+ {state.personalityResult ? `Your type: ${state.personalityResult}` : 'Discover your MBTI type'} +

+
+
+ {state.personalityResult ? ( + <> +
+
+ {state.personalityResult} + {personalityType?.name} +
+

{personalityType?.nickname}

+ {state.savedDate && ( +

Last taken: {new Date(state.savedDate).toLocaleDateString()}

+ )} +
+
+ + +
+ + ) : ( + <> +

Find out if you are an INFJ, ESTP, or one of 16 personality types. Learn how your type shapes your work relationships and communication style.

+ + + )} +
+ +
+

Daily Growth Tips

+
+ {state.growthTips.map((tip) => ( +
+ +

{tip}

+
+ ))} +
+
+ + {state.isDirector && } +
+ ); +} + +function TeamWellnessCard({ metrics }: { readonly metrics: readonly EmotionalIntelligencePageState['teamWellnessMetrics'][number][] }) { + return ( +
+

Team Wellness (Aggregated)

+

No individual emotional data shown

+
+ {metrics.map((item) => ( +
+
+ {item.label} + + {item.label === 'Growth Trend' && } + {item.value} + +
+
+
+ ))} +
+
+ ); +} diff --git a/frontend/src/components/emotional-intelligence/EmotionalIntelligenceHeader.tsx b/frontend/src/components/emotional-intelligence/EmotionalIntelligenceHeader.tsx new file mode 100644 index 0000000..fc9c631 --- /dev/null +++ b/frontend/src/components/emotional-intelligence/EmotionalIntelligenceHeader.tsx @@ -0,0 +1,14 @@ +import { Heart } from 'lucide-react'; +import { ModuleHeader } from '@/components/ui/module-header'; + +export function EmotionalIntelligenceHeader() { + return ( + + ); +} diff --git a/frontend/src/components/emotional-intelligence/EmotionalIntelligenceTabs.tsx b/frontend/src/components/emotional-intelligence/EmotionalIntelligenceTabs.tsx new file mode 100644 index 0000000..f6cac90 --- /dev/null +++ b/frontend/src/components/emotional-intelligence/EmotionalIntelligenceTabs.tsx @@ -0,0 +1,54 @@ +import { BookOpen, Fingerprint, Heart } from 'lucide-react'; +import type { ReactNode } from 'react'; + +import type { + EmotionalIntelligencePageActions, + EmotionalIntelligencePageState, +} from '@/components/emotional-intelligence/types'; +import type { EmotionalIntelligenceTab } from '@/shared/types/emotionalIntelligence'; + +const TABS: readonly { + readonly id: EmotionalIntelligenceTab; + readonly label: string; + readonly mobileLabel: string; + readonly icon: ReactNode; +}[] = [ + { id: 'assessment', label: 'EI Self-Assessment', mobileLabel: 'EI Quiz', icon: }, + { id: 'personality', label: 'Personality Type Quiz', mobileLabel: 'MBTI', icon: }, + { id: 'directory', label: 'Personality Directory', mobileLabel: 'Directory', icon: }, +]; + +type EmotionalIntelligenceTabsProps = { + state: EmotionalIntelligencePageState; + actions: EmotionalIntelligencePageActions; +}; + +export function EmotionalIntelligenceTabs({ state, actions }: EmotionalIntelligenceTabsProps) { + return ( +
+
+ {TABS.map((tab) => ( + + ))} +
+
+ ); +} diff --git a/frontend/src/components/emotional-intelligence/PersonalityDistributionPanel.tsx b/frontend/src/components/emotional-intelligence/PersonalityDistributionPanel.tsx new file mode 100644 index 0000000..2380613 --- /dev/null +++ b/frontend/src/components/emotional-intelligence/PersonalityDistributionPanel.tsx @@ -0,0 +1,179 @@ +import { BarChart3, Loader2, PieChart, RefreshCw, Shield } from 'lucide-react'; + +import { getPersonalityGroup } from '@/business/personality/selectors'; +import { getPersonalityGroupColor } from '@/components/emotional-intelligence/groupStyles'; +import type { + EmotionalIntelligencePageActions, + EmotionalIntelligencePageState, +} from '@/components/emotional-intelligence/types'; +import { getPersonalityType } from '@/shared/constants/personalityCatalog'; + +type PersonalityDistributionPanelProps = { + state: EmotionalIntelligencePageState; + actions: EmotionalIntelligencePageActions; +}; + +export function PersonalityDistributionPanel({ state, actions }: PersonalityDistributionPanelProps) { + return ( +
+
+
+
+
+ +
+
+

Team Personality Distribution

+

Anonymized, aggregated view across all campuses

+
+
+ +
+
+ +
+ {state.distributionErrorMessage ? ( +
+ {state.distributionErrorMessage} +
+ ) : state.distributionLoading ? ( +
+ +
+ ) : state.distribution.length === 0 ? ( +
+ +

No personality quiz results yet.

+

Results will appear here as staff members complete the quiz.

+
+ ) : ( +
+ + + + +
+ )} +
+
+ ); +} + +function DistributionSummary({ state }: Pick) { + return ( +
+ + + + +
+ ); +} + +type DistributionSummaryCardProps = { + value: string | number; + label: string; + valueClassName: string; +}; + +function DistributionSummaryCard({ value, label, valueClassName }: DistributionSummaryCardProps) { + return ( +
+
{value}
+
{label}
+
+ ); +} + +function DistributionByGroup({ state }: Pick) { + return ( +
+

+ Distribution by Group +

+
+ {['Analysts', 'Diplomats', 'Sentinels', 'Explorers'].map((group) => { + const count = state.groupDistribution[group] || 0; + const percentage = state.distributionTotal > 0 ? Math.round((count / state.distributionTotal) * 100) : 0; + const colors = getPersonalityGroupColor(group); + return ( +
+
+ {group} + {count} +
+
+
+
+ {percentage}% of team +
+ ); + })} +
+
+ ); +} + +function DistributionByType({ state }: Pick) { + return ( +
+

+ Distribution by Type +

+
+ {state.distribution.map((item) => { + const typeInfo = getPersonalityType(item.type, state.personalityTypes); + const percentage = state.distributionTotal > 0 ? Math.round((item.count / state.distributionTotal) * 100) : 0; + const group = getPersonalityGroup(item.type); + const colors = getPersonalityGroupColor(group); + return ( +
+
+ {item.type} +
+
+
+
+ {typeInfo?.name || item.type} + + {group} + +
+ {item.count} staff ({percentage}%) +
+
+
+
+
+
+ ); + })} +
+
+ ); +} + +function PrivacyNotice() { + return ( +
+
+ +
+

Privacy Notice

+

+ This view shows anonymized, aggregated data only. Individual staff members' personality types are not identified. + Results are grouped to show team composition patterns and help inform professional development planning. +

+
+
+
+ ); +} diff --git a/frontend/src/components/emotional-intelligence/PersonalityQuizTab.tsx b/frontend/src/components/emotional-intelligence/PersonalityQuizTab.tsx new file mode 100644 index 0000000..6705cb3 --- /dev/null +++ b/frontend/src/components/emotional-intelligence/PersonalityQuizTab.tsx @@ -0,0 +1,107 @@ +import { BookOpen, Brain, CheckCircle, Sparkles } from 'lucide-react'; + +import PersonalityQuiz from '@/components/frameworks/PersonalityQuiz'; +import type { + EmotionalIntelligencePageActions, + EmotionalIntelligencePageState, +} from '@/components/emotional-intelligence/types'; +import { StatePanel } from '@/components/ui/state-panel'; + +type PersonalityQuizTabProps = { + state: EmotionalIntelligencePageState; + actions: EmotionalIntelligencePageActions; +}; + +export function PersonalityQuizTab({ state, actions }: PersonalityQuizTabProps) { + const workplaceContent = state.personalityWorkplaceContent; + const hasContentError = Boolean(state.contentError); + + return ( +
+
+ {state.isLoadingSaved ? ( + + Loading your saved results... + + ) : ( + actions.setActiveTab('directory')} + onResult={actions.handlePersonalityResult} + savedType={state.personalityResult} + savedAnswers={state.savedAnswers} + savedDate={state.savedDate} + isSaving={state.isSaving} + /> + )} +
+ +
+ {state.contentLoading && ( + + Loading personality workplace content... + + )} + + {hasContentError && ( + + Personality workplace content could not be loaded from the backend. + + )} + + {!state.contentLoading && !hasContentError && workplaceContent && ( +
+

+ What is MBTI? +

+

+ {workplaceContent.mbtiDescription} +

+
+ {workplaceContent.dimensions.map((item) => ( +
+
+ {item.dim} + {item.label} +
+

{item.desc}

+
+ ))} +
+
+ )} + +
+
+
+ +
+
+

Type Directory

+

Explore all 16 types

+
+
+

Browse all personality types to understand colleagues' work styles, communication preferences, and relationship needs.

+ +
+ + {!state.contentLoading && !hasContentError && workplaceContent && ( +
+

+ Why This Matters at Work +

+
+ {workplaceContent.workplaceTips.map((tip) => ( +
+ +

{tip}

+
+ ))} +
+
+ )} +
+
+ ); +} diff --git a/frontend/src/components/emotional-intelligence/SavedPersonalityBanner.tsx b/frontend/src/components/emotional-intelligence/SavedPersonalityBanner.tsx new file mode 100644 index 0000000..0d97af1 --- /dev/null +++ b/frontend/src/components/emotional-intelligence/SavedPersonalityBanner.tsx @@ -0,0 +1,45 @@ +import { Eye, Save } from 'lucide-react'; + +import type { EmotionalIntelligencePageActions } from '@/components/emotional-intelligence/types'; +import { getPersonalityType } from '@/shared/constants/personalityCatalog'; +import type { PersonalityType } from '@/shared/constants/personalityCatalog'; + +type SavedPersonalityBannerProps = { + personalityResult: string; + personalityTypes: readonly PersonalityType[]; + actions: EmotionalIntelligencePageActions; +}; + +export function SavedPersonalityBanner({ personalityResult, personalityTypes, actions }: SavedPersonalityBannerProps) { + const personalityType = getPersonalityType(personalityResult, personalityTypes); + + return ( +
+
+
+
+ {personalityResult} +
+
+
+

Your Personality Type: {personalityResult}

+ + Saved + +
+

+ {personalityType?.name} - {personalityType?.nickname} +

+
+
+ +
+
+ ); +} diff --git a/frontend/src/components/emotional-intelligence/WeeklyFocusBanner.tsx b/frontend/src/components/emotional-intelligence/WeeklyFocusBanner.tsx new file mode 100644 index 0000000..05e20a1 --- /dev/null +++ b/frontend/src/components/emotional-intelligence/WeeklyFocusBanner.tsx @@ -0,0 +1,22 @@ +import { Sparkles } from 'lucide-react'; + +import type { EmotionalIntelligencePageState } from '@/components/emotional-intelligence/types'; + +type WeeklyFocusBannerProps = { + readonly state: EmotionalIntelligencePageState; +}; + +export function WeeklyFocusBanner({ state }: WeeklyFocusBannerProps) { + if (state.contentLoading || state.contentError || !state.weeklyFocus) { + return null; + } + + return ( +
+

+ This Week's EI Focus: {state.weeklyFocus.title} +

+

{state.weeklyFocus.description}

+
+ ); +} diff --git a/frontend/src/components/emotional-intelligence/groupStyles.ts b/frontend/src/components/emotional-intelligence/groupStyles.ts new file mode 100644 index 0000000..1133ff4 --- /dev/null +++ b/frontend/src/components/emotional-intelligence/groupStyles.ts @@ -0,0 +1,14 @@ +export const getPersonalityGroupColor = (group: string) => { + switch (group) { + case 'Analysts': + return { bg: 'bg-purple-500/15', text: 'text-purple-400', border: 'border-purple-500/20', bar: 'bg-purple-500' }; + case 'Diplomats': + return { bg: 'bg-emerald-500/15', text: 'text-emerald-400', border: 'border-emerald-500/20', bar: 'bg-emerald-500' }; + case 'Sentinels': + return { bg: 'bg-blue-500/15', text: 'text-blue-400', border: 'border-blue-500/20', bar: 'bg-blue-500' }; + case 'Explorers': + return { bg: 'bg-amber-500/15', text: 'text-amber-400', border: 'border-amber-500/20', bar: 'bg-amber-500' }; + default: + return { bg: 'bg-slate-500/15', text: 'text-slate-400', border: 'border-slate-500/20', bar: 'bg-slate-500' }; + } +}; diff --git a/frontend/src/components/emotional-intelligence/types.ts b/frontend/src/components/emotional-intelligence/types.ts new file mode 100644 index 0000000..0122ada --- /dev/null +++ b/frontend/src/components/emotional-intelligence/types.ts @@ -0,0 +1,4 @@ +import type { useEmotionalIntelligencePage } from '@/business/personality/emotionalIntelligenceHooks'; + +export type EmotionalIntelligencePageState = ReturnType['state']; +export type EmotionalIntelligencePageActions = ReturnType['actions']; diff --git a/frontend/src/components/esa-funding/EsaFundingAcknowledgement.tsx b/frontend/src/components/esa-funding/EsaFundingAcknowledgement.tsx new file mode 100644 index 0000000..e0702ad --- /dev/null +++ b/frontend/src/components/esa-funding/EsaFundingAcknowledgement.tsx @@ -0,0 +1,53 @@ +import { CheckCircle2 } from 'lucide-react'; + +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; + +interface EsaFundingAcknowledgementProps { + readonly acknowledged: boolean; + readonly onToggle: () => void; +} + +export function EsaFundingAcknowledgement({ + acknowledged, + onToggle, +}: EsaFundingAcknowledgementProps) { + return ( +
+
+ +
+

+ {acknowledged + ? 'Thank you for reviewing the ESA Funding Information!' + : 'I have read and understand the ESA Funding Information'} +

+

+ {acknowledged + ? 'Your acknowledgment has been recorded. You can revisit this page anytime for reference.' + : 'Click the checkbox to acknowledge that you have reviewed this information. This helps us track staff awareness for compliance purposes.'} +

+
+
+
+ ); +} diff --git a/frontend/src/components/esa-funding/EsaFundingApprovedUses.tsx b/frontend/src/components/esa-funding/EsaFundingApprovedUses.tsx new file mode 100644 index 0000000..a57991e --- /dev/null +++ b/frontend/src/components/esa-funding/EsaFundingApprovedUses.tsx @@ -0,0 +1,39 @@ +import { CheckCircle2 } from 'lucide-react'; + +import { EsaFundingIcon } from '@/components/esa-funding/EsaFundingIcon'; +import type { EsaApprovedUse } from '@/shared/types/esaFunding'; + +interface EsaFundingApprovedUsesProps { + readonly items: readonly EsaApprovedUse[]; +} + +export function EsaFundingApprovedUses({ items }: EsaFundingApprovedUsesProps) { + if (items.length === 0) { + return null; + } + + return ( +
+
+ +

What Can ESA Funds Be Used For?

+
+
+ {items.map((item) => ( +
+
+ + + +
+

{item.title}

+

{item.description}

+
+ ))} +
+
+ ); +} diff --git a/frontend/src/components/esa-funding/EsaFundingFaq.tsx b/frontend/src/components/esa-funding/EsaFundingFaq.tsx new file mode 100644 index 0000000..7fa929a --- /dev/null +++ b/frontend/src/components/esa-funding/EsaFundingFaq.tsx @@ -0,0 +1,78 @@ +import { ChevronDown, ChevronUp, HelpCircle } from 'lucide-react'; + +import { Button } from '@/components/ui/button'; +import type { EsaFaqItem } from '@/shared/types/esaFunding'; +import { cn } from '@/lib/utils'; + +interface EsaFundingFaqProps { + readonly items: readonly EsaFaqItem[]; + readonly expandedFAQ: number | null; + readonly onToggleFAQ: (index: number) => void; +} + +export function EsaFundingFaq({ + items, + expandedFAQ, + onToggleFAQ, +}: EsaFundingFaqProps) { + if (items.length === 0) { + return null; + } + + return ( +
+
+ +

Frequently Asked Questions

+
+
+ {items.map((faq, index) => { + const expanded = expandedFAQ === index; + + return ( +
+ + {expanded && ( +
+
+ {faq.answer} +
+
+ )} +
+ ); + })} +
+
+ ); +} diff --git a/frontend/src/components/esa-funding/EsaFundingHeader.tsx b/frontend/src/components/esa-funding/EsaFundingHeader.tsx new file mode 100644 index 0000000..0cd15ed --- /dev/null +++ b/frontend/src/components/esa-funding/EsaFundingHeader.tsx @@ -0,0 +1,17 @@ +import { Wallet } from 'lucide-react'; + +import { ModuleHeader } from '@/components/ui/module-header'; + +export function EsaFundingHeader() { + return ( + + ); +} diff --git a/frontend/src/components/esa-funding/EsaFundingHero.tsx b/frontend/src/components/esa-funding/EsaFundingHero.tsx new file mode 100644 index 0000000..789a0bf --- /dev/null +++ b/frontend/src/components/esa-funding/EsaFundingHero.tsx @@ -0,0 +1,35 @@ +import { DollarSign, Lightbulb } from 'lucide-react'; + +import { ESA_FUNDING_STATIC_COPY } from '@/shared/constants/esaFunding'; + +export function EsaFundingHero() { + return ( +
+
+
+
+
+ + + {ESA_FUNDING_STATIC_COPY.heroEyebrow} + +
+

+ {ESA_FUNDING_STATIC_COPY.heroTitle} +

+
+
+ +
+

{ESA_FUNDING_STATIC_COPY.heroSimpleLabel}

+

{ESA_FUNDING_STATIC_COPY.heroSimpleDescription}

+
+
+
+

+ {ESA_FUNDING_STATIC_COPY.heroDescription} +

+
+
+ ); +} diff --git a/frontend/src/components/esa-funding/EsaFundingIcon.tsx b/frontend/src/components/esa-funding/EsaFundingIcon.tsx new file mode 100644 index 0000000..11ff3f7 --- /dev/null +++ b/frontend/src/components/esa-funding/EsaFundingIcon.tsx @@ -0,0 +1,37 @@ +import { + ArrowRight, + BookOpen, + CheckCircle2, + GraduationCap, + Heart, + Puzzle, + School, + Star, + Users, +} from 'lucide-react'; +import type { LucideIcon } from 'lucide-react'; + +import type { EsaIconId } from '@/shared/types/esaFunding'; + +const ESA_ICONS: Record = { + school: School, + heart: Heart, + book: BookOpen, + puzzle: Puzzle, + graduation: GraduationCap, + users: Users, + arrow: ArrowRight, + check: CheckCircle2, + star: Star, +}; + +interface EsaFundingIconProps { + readonly iconId: EsaIconId; + readonly size: number; +} + +export function EsaFundingIcon({ iconId, size }: EsaFundingIconProps) { + const Icon = ESA_ICONS[iconId]; + + return ; +} diff --git a/frontend/src/components/esa-funding/EsaFundingImpactRoles.tsx b/frontend/src/components/esa-funding/EsaFundingImpactRoles.tsx new file mode 100644 index 0000000..8ba28a6 --- /dev/null +++ b/frontend/src/components/esa-funding/EsaFundingImpactRoles.tsx @@ -0,0 +1,56 @@ +import { CheckCircle2, Info, Shield } from 'lucide-react'; + +import type { EsaStaffRoleItem } from '@/shared/types/esaFunding'; + +interface EsaFundingImpactRolesProps { + readonly schoolImpactItems: readonly string[]; + readonly staffRoleItems: readonly EsaStaffRoleItem[]; +} + +export function EsaFundingImpactRoles({ + schoolImpactItems, + staffRoleItems, +}: EsaFundingImpactRolesProps) { + if (schoolImpactItems.length === 0 && staffRoleItems.length === 0) { + return null; + } + + return ( +
+
+
+ +

Why This Matters for Our School

+
+
+ {schoolImpactItems.map((item) => ( +
+ + {item} +
+ ))} +
+
+ +
+
+ +

Your Role as Staff

+
+
+ {staffRoleItems.map((item, index) => ( +
+
+ {index + 1} +
+
+ {item.title} +

{item.description}

+
+
+ ))} +
+
+
+ ); +} diff --git a/frontend/src/components/esa-funding/EsaFundingKeyPoints.tsx b/frontend/src/components/esa-funding/EsaFundingKeyPoints.tsx new file mode 100644 index 0000000..b8d192a --- /dev/null +++ b/frontend/src/components/esa-funding/EsaFundingKeyPoints.tsx @@ -0,0 +1,28 @@ +import { EsaFundingIcon } from '@/components/esa-funding/EsaFundingIcon'; +import type { EsaKeyPoint } from '@/shared/types/esaFunding'; + +interface EsaFundingKeyPointsProps { + readonly items: readonly EsaKeyPoint[]; +} + +export function EsaFundingKeyPoints({ items }: EsaFundingKeyPointsProps) { + if (items.length === 0) { + return null; + } + + return ( +
+ {items.map((point) => ( +
+
+ +
+ {point.label} +
+ ))} +
+ ); +} diff --git a/frontend/src/components/esa-funding/EsaFundingQuickReference.tsx b/frontend/src/components/esa-funding/EsaFundingQuickReference.tsx new file mode 100644 index 0000000..74c72dc --- /dev/null +++ b/frontend/src/components/esa-funding/EsaFundingQuickReference.tsx @@ -0,0 +1,33 @@ +import { FileText, Info } from 'lucide-react'; + +import { ESA_FUNDING_STATIC_COPY } from '@/shared/constants/esaFunding'; +import type { EsaFundingContent } from '@/shared/types/esaFunding'; + +interface EsaFundingQuickReferenceProps { + readonly content: EsaFundingContent; +} + +export function EsaFundingQuickReference({ content }: EsaFundingQuickReferenceProps) { + if (!content.parentConversationScript) { + return null; + } + + return ( +
+
+ +

Quick Reference for Parent Conversations

+
+
+

{ESA_FUNDING_STATIC_COPY.parentConversationIntro}

+
+

{content.parentConversationScript}

+
+
+ + {ESA_FUNDING_STATIC_COPY.parentConversationFooter} +
+
+
+ ); +} diff --git a/frontend/src/components/esa-funding/EsaFundingResources.tsx b/frontend/src/components/esa-funding/EsaFundingResources.tsx new file mode 100644 index 0000000..46fe421 --- /dev/null +++ b/frontend/src/components/esa-funding/EsaFundingResources.tsx @@ -0,0 +1,62 @@ +import { ExternalLink } from 'lucide-react'; + +import { isValidEsaResourceUrl } from '@/business/esa-funding/selectors'; +import { Button } from '@/components/ui/button'; +import type { EsaResource } from '@/shared/types/esaFunding'; +import { cn } from '@/lib/utils'; + +interface EsaFundingResourcesProps { + readonly resources: readonly EsaResource[]; +} + +export function EsaFundingResources({ resources }: EsaFundingResourcesProps) { + if (resources.length === 0) { + return null; + } + + return ( +
+
+ +

Helpful Resources

+
+
+ {resources.map((resource) => { + const validUrl = isValidEsaResourceUrl(resource.url); + const cardClassName = cn( + 'bg-slate-900/60 rounded-xl p-4 border border-slate-700/40 transition-all text-left group h-auto items-start whitespace-normal', + validUrl ? 'hover:border-emerald-500/30' : 'opacity-70 cursor-not-allowed', + ); + const content = ( + <> + + + {resource.title} + + + + {resource.description} + {!validUrl && Link not configured} + + ); + + if (validUrl) { + return ( + + ); + } + + return ( +
+ {content} +
+ ); + })} +
+
+ ); +} diff --git a/frontend/src/components/esa-funding/EsaFundingStateNotice.tsx b/frontend/src/components/esa-funding/EsaFundingStateNotice.tsx new file mode 100644 index 0000000..7a17c68 --- /dev/null +++ b/frontend/src/components/esa-funding/EsaFundingStateNotice.tsx @@ -0,0 +1,40 @@ +import { CheckCircle2, Info } from 'lucide-react'; + +import { ESA_FUNDING_STATIC_COPY } from '@/shared/constants/esaFunding'; +import type { EsaFundingContent } from '@/shared/types/esaFunding'; + +interface EsaFundingStateNoticeProps { + readonly content: EsaFundingContent; +} + +export function EsaFundingStateNotice({ content }: EsaFundingStateNoticeProps) { + if (content.stateChecklist.length === 0) { + return null; + } + + return ( +
+
+
+ +
+
+

{ESA_FUNDING_STATIC_COPY.stateNoticeTitle}

+

{ESA_FUNDING_STATIC_COPY.stateNoticeDescription}

+
+

{ESA_FUNDING_STATIC_COPY.stateNoticeChecklistTitle}

+
+ {content.stateChecklist.map((item) => ( +
+ + {item} +
+ ))} +
+
+

{ESA_FUNDING_STATIC_COPY.stateNoticeFooter}

+
+
+
+ ); +} diff --git a/frontend/src/components/esa-funding/EsaFundingView.tsx b/frontend/src/components/esa-funding/EsaFundingView.tsx new file mode 100644 index 0000000..c718650 --- /dev/null +++ b/frontend/src/components/esa-funding/EsaFundingView.tsx @@ -0,0 +1,76 @@ +import { AlertTriangle, Wallet } from 'lucide-react'; + +import type { EsaFundingPage } from '@/business/esa-funding/types'; +import { EsaFundingAcknowledgement } from '@/components/esa-funding/EsaFundingAcknowledgement'; +import { EsaFundingApprovedUses } from '@/components/esa-funding/EsaFundingApprovedUses'; +import { EsaFundingFaq } from '@/components/esa-funding/EsaFundingFaq'; +import { EsaFundingHeader } from '@/components/esa-funding/EsaFundingHeader'; +import { EsaFundingHero } from '@/components/esa-funding/EsaFundingHero'; +import { EsaFundingImpactRoles } from '@/components/esa-funding/EsaFundingImpactRoles'; +import { EsaFundingKeyPoints } from '@/components/esa-funding/EsaFundingKeyPoints'; +import { EsaFundingQuickReference } from '@/components/esa-funding/EsaFundingQuickReference'; +import { EsaFundingResources } from '@/components/esa-funding/EsaFundingResources'; +import { EsaFundingStateNotice } from '@/components/esa-funding/EsaFundingStateNotice'; +import { StatePanel } from '@/components/ui/state-panel'; + +interface EsaFundingViewProps { + readonly page: EsaFundingPage; +} + +export function EsaFundingView({ page }: EsaFundingViewProps) { + return ( +
+ + + + + {page.isLoading && ( + + Loading current ESA funding information. + + )} + + {page.error && ( + + Please try again. + + )} + + {!page.isLoading && !page.error && ( + <> + + + + + + + + + )} + + +
+ ); +} diff --git a/frontend/src/components/frame/FrameDefinitionPanel.tsx b/frontend/src/components/frame/FrameDefinitionPanel.tsx new file mode 100644 index 0000000..77deb8e --- /dev/null +++ b/frontend/src/components/frame/FrameDefinitionPanel.tsx @@ -0,0 +1,20 @@ +import { FRAME_SECTION_LABELS } from '@/shared/constants/frame'; + +export function FrameDefinitionPanel() { + return ( +
+

What F.R.A.M.E. Stands For

+
+ {FRAME_SECTION_LABELS.map((section) => ( +
+
+ {section.letter} +
+

{section.label}

+

{section.description}

+
+ ))} +
+
+ ); +} diff --git a/frontend/src/components/frame/FrameEmptyState.tsx b/frontend/src/components/frame/FrameEmptyState.tsx new file mode 100644 index 0000000..dd8785b --- /dev/null +++ b/frontend/src/components/frame/FrameEmptyState.tsx @@ -0,0 +1,9 @@ +import { StatePanel } from '@/components/ui/state-panel'; + +export function FrameEmptyState() { + return ( + + Create the first weekly focus entry from the button above. + + ); +} diff --git a/frontend/src/components/frame/FrameEntryCard.tsx b/frontend/src/components/frame/FrameEntryCard.tsx new file mode 100644 index 0000000..e801bc8 --- /dev/null +++ b/frontend/src/components/frame/FrameEntryCard.tsx @@ -0,0 +1,62 @@ +import { ChevronDown, ChevronUp, User } from 'lucide-react'; + +import type { FrameEntryViewModel } from '@/business/frame/types'; +import { Button } from '@/components/ui/button'; +import { FrameEntryDetails } from '@/components/frame/FrameEntryDetails'; +import { FrameEntryEditForm } from '@/components/frame/FrameEntryEditForm'; +import type { FrameModuleWorkflow } from '@/components/frame/types'; + +interface FrameEntryCardProps { + readonly entry: FrameEntryViewModel; + readonly index: number; + readonly workflow: FrameModuleWorkflow; +} + +export function FrameEntryCard({ entry, index, workflow }: FrameEntryCardProps) { + const isExpanded = workflow.expandedId === entry.id; + const isCurrent = index === 0; + + return ( +
+ + + {isExpanded && ( +
+ {workflow.isEditing && workflow.editEntry?.id === entry.id ? ( + + ) : ( + + )} +
+ )} +
+ ); +} diff --git a/frontend/src/components/frame/FrameEntryDetails.tsx b/frontend/src/components/frame/FrameEntryDetails.tsx new file mode 100644 index 0000000..b6cb8d6 --- /dev/null +++ b/frontend/src/components/frame/FrameEntryDetails.tsx @@ -0,0 +1,39 @@ +import { Edit3 } from 'lucide-react'; + +import type { FrameEntryViewModel } from '@/business/frame/types'; +import { FRAME_SECTION_LABELS } from '@/shared/constants/frame'; +import { Button } from '@/components/ui/button'; + +interface FrameEntryDetailsProps { + readonly entry: FrameEntryViewModel; + readonly canEdit: boolean; + readonly startEditing: (entry: FrameEntryViewModel) => void; +} + +export function FrameEntryDetails({ entry, canEdit, startEditing }: FrameEntryDetailsProps) { + return ( + <> + {FRAME_SECTION_LABELS.map((section) => ( +
+
+ + {section.letter} + + {section.label} +
+

{entry[section.key]}

+
+ ))} + {canEdit && ( + + )} + + ); +} diff --git a/frontend/src/components/frame/FrameEntryEditForm.tsx b/frontend/src/components/frame/FrameEntryEditForm.tsx new file mode 100644 index 0000000..861c8dc --- /dev/null +++ b/frontend/src/components/frame/FrameEntryEditForm.tsx @@ -0,0 +1,50 @@ +import { Save } from 'lucide-react'; + +import type { FrameEntryViewModel } from '@/business/frame/types'; +import { FRAME_SECTION_LABELS } from '@/shared/constants/frame'; +import { Button } from '@/components/ui/button'; +import { FrameSectionField } from '@/components/frame/FrameSectionField'; +import type { FrameModuleWorkflow } from '@/components/frame/types'; + +interface FrameEntryEditFormProps { + readonly editEntry: FrameEntryViewModel; + readonly workflow: FrameModuleWorkflow; +} + +export function FrameEntryEditForm({ editEntry, workflow }: FrameEntryEditFormProps) { + return ( + <> + {FRAME_SECTION_LABELS.map((section) => ( + workflow.updateEditEntrySection(section.key, value)} + /> + ))} + {workflow.formError &&

{workflow.formError}

} +
+ + +
+ + ); +} diff --git a/frontend/src/components/frame/FrameEntryForm.tsx b/frontend/src/components/frame/FrameEntryForm.tsx new file mode 100644 index 0000000..70004e9 --- /dev/null +++ b/frontend/src/components/frame/FrameEntryForm.tsx @@ -0,0 +1,62 @@ +import { Edit3, Save } from 'lucide-react'; + +import { FRAME_SECTION_LABELS } from '@/shared/constants/frame'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { FrameSectionField } from '@/components/frame/FrameSectionField'; +import type { FrameModuleViewProps } from '@/components/frame/types'; + +export function FrameEntryForm({ workflow }: FrameModuleViewProps) { + if (!workflow.showNewForm || !workflow.canEdit) { + return null; + } + + return ( +
+

+ Create New F.R.A.M.E. Entry +

+
+ + workflow.updateNewEntryField('weekOf', event.target.value)} + placeholder="Week label or date" + className="w-full mt-1 px-4 py-2.5 bg-slate-700/50 border border-slate-600/50 rounded-xl text-sm text-white placeholder-slate-500 focus:ring-2 focus:ring-violet-500 outline-none" + /> +
+ {FRAME_SECTION_LABELS.map((section) => ( + workflow.updateNewEntrySection(section.key, value)} + /> + ))} + {workflow.formError &&

{workflow.formError}

} +
+ + +
+
+ ); +} diff --git a/frontend/src/components/frame/FrameEntryList.tsx b/frontend/src/components/frame/FrameEntryList.tsx new file mode 100644 index 0000000..ea0ce4c --- /dev/null +++ b/frontend/src/components/frame/FrameEntryList.tsx @@ -0,0 +1,17 @@ +import { FrameEmptyState } from '@/components/frame/FrameEmptyState'; +import { FrameEntryCard } from '@/components/frame/FrameEntryCard'; +import type { FrameModuleViewProps } from '@/components/frame/types'; + +export function FrameEntryList({ workflow }: FrameModuleViewProps) { + if (workflow.entries.length === 0) { + return ; + } + + return ( +
+ {workflow.entries.map((entry, index) => ( + + ))} +
+ ); +} diff --git a/frontend/src/components/frame/FrameHeader.tsx b/frontend/src/components/frame/FrameHeader.tsx new file mode 100644 index 0000000..8dcd13b --- /dev/null +++ b/frontend/src/components/frame/FrameHeader.tsx @@ -0,0 +1,45 @@ +import { Plus, RefreshCw } from 'lucide-react'; + +import { Button } from '@/components/ui/button'; +import type { FrameModuleViewProps } from '@/components/frame/types'; + +export function FrameHeader({ workflow }: FrameModuleViewProps) { + return ( +
+
+

+
+ F +
+ F.R.A.M.E. Weekly Focus +

+

+ {workflow.canEdit ? 'Create and manage weekly campus focus areas' : 'View this week\'s campus focus and past entries'} +

+
+
+ + {workflow.canEdit && ( + + )} +
+
+ ); +} diff --git a/frontend/src/components/frame/FrameModuleView.tsx b/frontend/src/components/frame/FrameModuleView.tsx new file mode 100644 index 0000000..b71af97 --- /dev/null +++ b/frontend/src/components/frame/FrameModuleView.tsx @@ -0,0 +1,18 @@ +import { FrameDefinitionPanel } from '@/components/frame/FrameDefinitionPanel'; +import { FrameEntryForm } from '@/components/frame/FrameEntryForm'; +import { FrameEntryList } from '@/components/frame/FrameEntryList'; +import { FrameHeader } from '@/components/frame/FrameHeader'; +import { FrameStatusPanel } from '@/components/frame/FrameStatusPanel'; +import type { FrameModuleViewProps } from '@/components/frame/types'; + +export function FrameModuleView({ workflow }: FrameModuleViewProps) { + return ( +
+ + + + + +
+ ); +} diff --git a/frontend/src/components/frame/FrameSectionField.tsx b/frontend/src/components/frame/FrameSectionField.tsx new file mode 100644 index 0000000..987dd82 --- /dev/null +++ b/frontend/src/components/frame/FrameSectionField.tsx @@ -0,0 +1,28 @@ +import { Textarea } from '@/components/ui/textarea'; +import type { FrameSectionLabel } from '@/shared/constants/frame'; + +interface FrameSectionFieldProps { + readonly section: FrameSectionLabel; + readonly value: string; + readonly onChange: (value: string) => void; +} + +export function FrameSectionField({ section, value, onChange }: FrameSectionFieldProps) { + return ( +
+ +