diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..c0f8959 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +node_modules +dist +coverage +.git +.env +*.log diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..41c099a --- /dev/null +++ b/.env.example @@ -0,0 +1,19 @@ +# Slack request verification +SLACK_SIGNING_SECRET= + +# Local, single-workspace development +SLACK_BOT_TOKEN= + +# OAuth distribution (optional for local development) +SLACK_CLIENT_ID= +SLACK_CLIENT_SECRET= +SLACK_STATE_SECRET=replace-with-at-least-32-random-characters +TOKEN_ENCRYPTION_KEY= + +# Runtime +DATABASE_URL=postgresql://teamloop:teamloop@localhost:5432/teamloop?schema=public +APP_BASE_URL=http://localhost:3000 +PORT=3000 +NODE_ENV=development +LOG_LEVEL=info +DEFAULT_TIMEZONE=UTC diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..9a677b1 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @ayushhagarwal diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..4a295a2 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,23 @@ +--- +name: Bug report +about: Report a reproducible TeamLoop problem +title: "[Bug] " +labels: bug +--- + +## What happened? + +## Steps to reproduce + +## Expected behavior + +## Environment + +- TeamLoop commit/version: +- Node version: +- Deployment method: +- Slack surface and command: + +## Safe logs + +Remove tokens, workspace identifiers, channel names, and personal data. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..8269108 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: TeamLoop discussions + url: https://github.com/ayushhagarwal/teamloop/discussions + about: Ask setup questions, share ideas, and show how your team uses TeamLoop. + - name: Security report + url: https://github.com/ayushhagarwal/teamloop/security/advisories/new + about: Privately report a security vulnerability. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..622a468 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,14 @@ +--- +name: Feature request +about: Propose a focused improvement +title: "[Feature] " +labels: enhancement +--- + +## Team coordination problem + +## Proposed Slack experience + +## Why this belongs in the current roadmap + +## Privacy or scope considerations diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..ca9742d --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,24 @@ +version: 2 +updates: + - package-ecosystem: npm + directory: / + schedule: + interval: weekly + day: monday + time: "06:00" + timezone: Asia/Kolkata + open-pull-requests-limit: 5 + labels: + - dependencies + groups: + development-dependencies: + dependency-type: development + production-dependencies: + dependency-type: production + + - package-ecosystem: github-actions + directory: / + schedule: + interval: monthly + labels: + - dependencies diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..71d28c3 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,16 @@ +## What changed? + +## Why? + +## Verification + +- [ ] `npm run format:check` +- [ ] `npm run lint` +- [ ] `npm run typecheck` +- [ ] `npm test` +- [ ] Manual Slack interaction tested where relevant + +## Privacy and scope + +- [ ] No secrets or real workspace data included +- [ ] No V1 scope creep diff --git a/.github/release.yml b/.github/release.yml new file mode 100644 index 0000000..993c263 --- /dev/null +++ b/.github/release.yml @@ -0,0 +1,25 @@ +changelog: + exclude: + labels: + - skip-changelog + categories: + - title: Breaking changes + labels: + - breaking-change + - title: New features + labels: + - enhancement + - feature + - title: Fixes + labels: + - bug + - fix + - title: Documentation + labels: + - documentation + - title: Dependencies + labels: + - dependencies + - title: Other changes + labels: + - "*" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..fb17ae8 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,23 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + quality: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + - run: npm ci + - run: npm run prisma:generate + - run: npm run format:check + - run: npm run lint + - run: npm run typecheck + - run: npm test + - run: npm run build diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..2bb3299 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,70 @@ +name: Release + +on: + workflow_dispatch: + inputs: + version: + description: Semantic version without the v prefix, for example 1.1.0 + required: true + type: string + +permissions: + contents: write + +concurrency: + group: release + cancel-in-progress: false + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Validate release version + env: + VERSION: ${{ inputs.version }} + run: | + if ! echo "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$'; then + echo "Version must be valid semver without a v prefix." + exit 1 + fi + PACKAGE_VERSION="$(node -p "require('./package.json').version")" + if [ "$PACKAGE_VERSION" != "$VERSION" ]; then + echo "package.json version $PACKAGE_VERSION does not match $VERSION." + exit 1 + fi + if git rev-parse "v$VERSION" >/dev/null 2>&1; then + echo "Tag v$VERSION already exists." + exit 1 + fi + + - run: npm ci + - run: npm run prisma:generate + - run: npm run format:check + - run: npm run lint + - run: npm run typecheck + - run: npm test + - run: npm run build + + - name: Create annotated tag + env: + VERSION: ${{ inputs.version }} + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git tag -a "v$VERSION" -m "TeamLoop v$VERSION" + git push origin "v$VERSION" + + - name: Create GitHub release + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ inputs.version }} + run: gh release create "v$VERSION" --title "TeamLoop v$VERSION" --generate-notes diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..73c7b74 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +node_modules/ +dist/ +coverage/ +.env +.env.* +!.env.example +*.log +.DS_Store +prisma/dev.db diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..68f9def --- /dev/null +++ b/.prettierignore @@ -0,0 +1,4 @@ +node_modules +dist +coverage +package-lock.json diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..6bb3bf2 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,6 @@ +{ + "semi": true, + "singleQuote": false, + "trailingComma": "all", + "printWidth": 88 +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..f11c950 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,20 @@ +# Changelog + +All notable TeamLoop changes are documented through GitHub Releases and this +file. The project follows [Semantic Versioning](https://semver.org/). + +## [1.0.0] - 2026-06-21 + +### Added + +- Six Slack activity commands: lunch, dinner, potluck, weekend run, marathon, + and HYROX. +- Interactive event cards with live RSVPs, preferences, summaries, and + organizer controls. +- Potluck contribution tracking and still-needed categories. +- Scheduled and manual reminders with duplicate-send protection. +- PostgreSQL and Prisma persistence, Slack OAuth distribution, and encrypted + workspace token storage. +- Docker Compose, automated tests, CI, and open-source project documentation. + +[1.0.0]: https://github.com/ayushhagarwal/teamloop/releases/tag/v1.0.0 diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..3d344c8 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,20 @@ +# Code of Conduct + +## Our pledge + +We pledge to make participation in TeamLoop welcoming and harassment-free for +everyone, regardless of background, identity, experience, or ability. + +## Expected behavior + +Be respectful, assume good intent, give actionable feedback, and keep technical +disagreement focused on the work. Harassment, discrimination, threats, and +publishing another person’s private information are not acceptable. + +## Enforcement + +Report conduct or safety concerns privately to the repository maintainers. +Maintainers may edit or remove contributions and may temporarily or permanently +restrict participation when behavior harms the community. + +This policy is adapted from the Contributor Covenant, version 2.1. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..a5c1258 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,23 @@ +# Contributing to TeamLoop + +Thanks for helping teams plan things without a 47-message RSVP thread. + +## Development workflow + +1. Read [docs/local-development.md](docs/local-development.md). +2. Create a focused branch from `main`. +3. Add or update tests with behavior changes. +4. Run `npm run format:check`, `npm run lint`, `npm run typecheck`, and `npm test`. +5. Open a pull request using the repository template. + +Keep V1 changes small, privacy-conscious, and Slack-first. New dependencies +should be open source and should not make a paid service mandatory. + +## Commit and pull request guidance + +- Explain the user problem, not only the code change. +- Include manual Slack testing notes for interaction changes. +- Avoid unrelated formatting or refactors. +- Never include workspace tokens, signing secrets, user data, or production logs. + +By participating, you agree to follow the [Code of Conduct](CODE_OF_CONDUCT.md). diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..23c7f17 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,21 @@ +FROM node:20-alpine AS build +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci +COPY prisma ./prisma +COPY prisma.config.ts ./prisma.config.ts +RUN npm run prisma:generate +COPY tsconfig.json vitest.config.ts eslint.config.js .prettierrc.json ./ +COPY src ./src +RUN npm run build + +FROM node:20-alpine AS runtime +WORKDIR /app +ENV NODE_ENV=production +COPY --from=build /app/node_modules ./node_modules +COPY --from=build /app/dist ./dist +COPY --from=build /app/prisma ./prisma +COPY --from=build /app/prisma.config.ts ./prisma.config.ts +COPY package.json package-lock.json ./ +EXPOSE 3000 +CMD ["sh", "-c", "npm run prisma:migrate && npm start"] diff --git a/LICENSE b/LICENSE index 491c920..d182166 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2026 Ayush Kumar Agarwal +Copyright (c) 2026 Ayush Kumar Agarwal and TeamLoop contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md new file mode 100644 index 0000000..04ce528 --- /dev/null +++ b/README.md @@ -0,0 +1,203 @@ +

+ TeamLoop +

+ +# TeamLoop + +Plan team lunches, potlucks, runs, and fitness sessions directly in Slack. + +[![CI](https://github.com/ayushhagarwal/teamloop/actions/workflows/ci.yml/badge.svg)](https://github.com/ayushhagarwal/teamloop/actions/workflows/ci.yml) +[![Release](https://img.shields.io/github/v/release/ayushhagarwal/teamloop)](https://github.com/ayushhagarwal/teamloop/releases) +[![License: MIT](https://img.shields.io/badge/License-MIT-5F9F68.svg)](LICENSE) +[![Open issues](https://img.shields.io/github/issues/ayushhagarwal/teamloop)](https://github.com/ayushhagarwal/teamloop/issues) + +TeamLoop is an open-source Slack bot for planning real-world team activities — +lunches, dinners, potlucks, weekend runs, marathon training, and HYROX sessions. +It creates interactive Slack event cards with RSVPs, preferences, reminders, +organizer controls, and live-updating summaries. + +> V1 is Slack-only, self-hostable, and deliberately small. There is no billing, +> dashboard, AI, calendar integration, or paid service dependency. + +## What it does + +- Creates an activity in under a minute with a slash command and modal. +- Keeps `Going`, `Maybe`, and `Can’t make it` counts current in one message. +- Collects food, pace, race, partner, beginner-group, and HYROX preferences. +- Tracks potluck contributions and categories that are still needed. +- Gives organizers edit, reminder, close-RSVP, cancel, and summary controls. +- Schedules idempotent RSVP-deadline and event-start reminders. +- Enforces organizer actions server-side and stores only coordination data. + +| Command | Activity-specific support | +| ------------- | ------------------------------------------------------------ | +| `/lunch` | Budget and food preference | +| `/dinner` | Budget, plus-one, and food preference | +| `/potluck` | Needed categories and contribution claims | +| `/weekendrun` | Distance, pace, route, and beginner group | +| `/marathon` | Race/training details, distance, target pace, beginner group | +| `/hyrox` | Workout type, partner need, beginner support, and intensity | + +## Preview + +```text +🍱 Team Lunch +Created by @ayush · Lunch · Status: Open + +📅 Friday at 12:30 PM 📍 Green Cafe +⏰ RSVP by Friday 10 AM ✅ 3 · 🤔 2 · ❌ 1 + +🍴 Lunch details +Budget: ₹500 +Preferences: Veg 2 · Vegan 1 + +[✅ Going] [🤔 Maybe] [❌ Can’t make it] [Set food preference] [View summary] +``` + +Add sanitized product images to [`screenshots/`](screenshots/README.md) and a +demo GIF to [`demo/`](demo/README.md). + +## Quick start with Docker + +Requirements: Docker, Docker Compose, a Slack development workspace, and a +public HTTPS tunnel such as ngrok. + +```bash +cp .env.example .env +# Add SLACK_SIGNING_SECRET and SLACK_BOT_TOKEN to .env +docker compose up --build +``` + +The app listens on `http://localhost:3000`; its health endpoint is +`GET /health`. Expose it while developing: + +```bash +ngrok http 3000 +``` + +Set Slack slash commands, interactivity, and event subscriptions to: + +```text +https://YOUR_NGROK_DOMAIN/slack/events +``` + +Follow the full [Slack app setup guide](docs/slack-app-setup.md). + +## Local development + +```bash +npm install +docker compose up -d postgres +cp .env.example .env +npm run prisma:migrate +npm run dev +``` + +Useful commands: + +```bash +npm run prisma:migrate:dev +npm run prisma:seed +npm run format:check +npm run lint +npm run typecheck +npm test +npm run build +``` + +See [local development](docs/local-development.md) and +[self-hosting](docs/self-hosting.md) for complete instructions. + +## Slack installation modes + +For one development workspace, set `SLACK_BOT_TOKEN`. + +For distributed OAuth installs, set `SLACK_CLIENT_ID`, `SLACK_CLIENT_SECRET`, +`SLACK_STATE_SECRET`, `TOKEN_ENCRYPTION_KEY`, and a public `APP_BASE_URL`. +TeamLoop then exposes: + +- `/slack/install` +- `/slack/oauth_redirect` +- `/slack/events` + +Workspace bot tokens are encrypted with AES-256-GCM when +`TOKEN_ENCRYPTION_KEY` is configured. + +## Environment variables + +| Variable | Required | Purpose | +| ---------------------- | ---------- | ------------------------------------------ | +| `DATABASE_URL` | Yes | PostgreSQL connection URL | +| `SLACK_SIGNING_SECRET` | Yes | Verifies requests from Slack | +| `SLACK_BOT_TOKEN` | Local mode | Single-workspace bot token | +| `SLACK_CLIENT_ID` | OAuth mode | Slack app client ID | +| `SLACK_CLIENT_SECRET` | OAuth mode | Slack app client secret | +| `SLACK_STATE_SECRET` | OAuth mode | OAuth state signing secret, 32+ characters | +| `TOKEN_ENCRYPTION_KEY` | OAuth mode | Base64-encoded 32-byte key | +| `APP_BASE_URL` | OAuth mode | Public HTTPS origin | +| `PORT` | No | HTTP port; default `3000` | +| `DEFAULT_TIMEZONE` | No | Reserved for richer V1.1 timezone handling | +| `LOG_LEVEL` | No | `debug`, `info`, `warn`, or `error` | + +V1 modal times are labeled and interpreted as UTC. Slack renders stored UTC +timestamps in each viewer’s local timezone. + +## Architecture + +```text +Slack command/action + → Bolt listener (acknowledges immediately) + → Zod validation + → Prisma service + PostgreSQL + → reusable Block Kit renderer + → chat.postMessage / chat.update + +node-cron + → atomically claims due reminder + → sends one Slack message + → records SENT or FAILED +``` + +The source is split by Slack surface (`commands`, `modals`, `blocks`, +`interactions`) and domain service (`event`, `rsvp`, `reminder`, `potluck`). + +## Privacy + +TeamLoop stores workspace and channel IDs, event details, Slack user IDs, +RSVPs, preferences, potluck items, reminders, and a small audit trail. It does +not store message history, private conversations, emails, or Slack profiles. +See [permissions and data](docs/permissions-and-data.md). + +## Deployment + +The included image runs Prisma migrations before starting the HTTP process. +Use HTTPS, persistent PostgreSQL storage, encrypted backups, and one scheduler +instance (or add distributed locking before scaling schedulers horizontally). +See [self-hosting](docs/self-hosting.md). + +## Roadmap + +- **V1.1:** preference and potluck item editing, event templates, better + timezone support, App Home summary. +- **V1.2:** expense splitting, calendar export/integration, recurring + activities, richer reminder controls. +- **V1.3:** hosted version, install landing page, workspace billing, admin + dashboard. +- **V2:** Microsoft Teams, AI suggestions, restaurant discovery, advanced + analytics, Marketplace polish. + +Roadmap items are not part of this V1 implementation. + +## Contributing and security + +Read [CONTRIBUTING.md](CONTRIBUTING.md), [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md), +[SECURITY.md](SECURITY.md), and [SUPPORT.md](SUPPORT.md) before opening a +contribution, support request, or security report. + +Releases use semantic version tags such as `v1.0.0`. Maintainers can create a +tested tag and GitHub release from the **Release** workflow. See +[the release guide](docs/releasing.md). + +## License + +[MIT](LICENSE) diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..3627c63 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,25 @@ +# Security Policy + +## Reporting a vulnerability + +Please do not open a public issue for a vulnerability involving Slack tokens, +request verification, authorization, data exposure, or injection. Send a +private report to the security contact listed by the repository owner with: + +- affected version or commit; +- reproduction steps; +- likely impact; +- any suggested mitigation. + +Maintainers should acknowledge reports within seven days. Do not include real +workspace tokens or personal data in a report. + +## Supported versions + +Security fixes target the latest release on `main`. + +## Deployment notes + +Use HTTPS, a strong `SLACK_STATE_SECRET`, and a base64-encoded 32-byte +`TOKEN_ENCRYPTION_KEY` for OAuth installs. Rotate any credential that may have +been exposed and remove it from Git history. diff --git a/SUPPORT.md b/SUPPORT.md new file mode 100644 index 0000000..9ec95a2 --- /dev/null +++ b/SUPPORT.md @@ -0,0 +1,22 @@ +# Support + +## Getting help + +- Read the [README](README.md) and [local development guide](docs/local-development.md). +- Search [existing issues](https://github.com/ayushhagarwal/teamloop/issues). +- Open a [GitHub Discussion](https://github.com/ayushhagarwal/teamloop/discussions) + for setup questions and ideas. +- Open a bug issue when behavior is reproducible. + +Please include the TeamLoop version, Node.js version, deployment method, Slack +surface involved, expected behavior, and safe reproduction steps. + +## Security + +Do not report vulnerabilities or expose Slack tokens in a public issue. Follow +[SECURITY.md](SECURITY.md). + +## Scope + +Community support is best-effort. TeamLoop V1 does not include a hosted service, +payments, Microsoft Teams, a web dashboard, or private support tooling. diff --git a/assets/logo-placeholder.svg b/assets/logo-placeholder.svg new file mode 100644 index 0000000..41fe0d7 --- /dev/null +++ b/assets/logo-placeholder.svg @@ -0,0 +1,9 @@ + + TeamLoop logo placeholder + A calm green loop around a small land-colored circle beside the TeamLoop wordmark. + + + + + TeamLoop + diff --git a/demo/README.md b/demo/README.md new file mode 100644 index 0000000..c7c0954 --- /dev/null +++ b/demo/README.md @@ -0,0 +1,4 @@ +# Demo assets + +Place the README demo GIF or short video here. Use a dedicated Slack test +workspace with fictional users and events. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..813b69e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,34 @@ +services: + postgres: + image: postgres:16-alpine + environment: + POSTGRES_DB: teamloop + POSTGRES_USER: teamloop + POSTGRES_PASSWORD: teamloop + ports: + - "5432:5432" + volumes: + - teamloop-postgres:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U teamloop -d teamloop"] + interval: 5s + timeout: 5s + retries: 10 + + app: + build: . + env_file: + - .env + environment: + DATABASE_URL: postgresql://teamloop:teamloop@postgres:5432/teamloop?schema=public + NODE_ENV: production + PORT: 3000 + ports: + - "3000:3000" + depends_on: + postgres: + condition: service_healthy + restart: unless-stopped + +volumes: + teamloop-postgres: diff --git a/docs/local-development.md b/docs/local-development.md new file mode 100644 index 0000000..2532fa7 --- /dev/null +++ b/docs/local-development.md @@ -0,0 +1,100 @@ +# Local development + +## Prerequisites + +- Node.js 20+ +- Docker and Docker Compose +- A Slack development workspace +- ngrok or another HTTPS tunnel + +## Install and configure + +```bash +npm install +cp .env.example .env +docker compose up -d postgres +npm run prisma:generate +npm run prisma:migrate +``` + +Add the signing secret and development workspace bot token to `.env`. + +## Run + +```bash +npm run dev +``` + +In another terminal: + +```bash +ngrok http 3000 +``` + +Point all slash commands and Slack interactivity to +`https://YOUR_DOMAIN/slack/events`. + +## Database changes + +Edit `prisma/schema.prisma`, then create a migration: + +```bash +npm run prisma:migrate:dev -- --name describe_the_change +``` + +Never edit an already-deployed migration. The seed command creates only a demo +workspace record: + +```bash +npm run prisma:seed +``` + +## Quality checks + +```bash +npm run format:check +npm run lint +npm run typecheck +npm test +npm run build +``` + +## Manual Slack checklist + +1. Run `/lunch` and create a future event. +2. Click `Going`. +3. Respond `Maybe` from another user. +4. Set a food preference. +5. Send an organizer reminder. +6. Close RSVPs and confirm a new response is rejected. +7. Cancel the event and confirm the card changes. +8. Run `/dinner` and test a plus-one preference. +9. Run `/potluck`. +10. Claim a potluck item and confirm the user is marked going. +11. Confirm claimed and still-needed categories update. +12. Run `/weekendrun` and set a pace preference. +13. Run `/marathon` and save a target pace note. +14. Run `/hyrox` and save a HYROX preference. +15. Edit an organizer-owned event. +16. Try an organizer control from another user. +17. Open the full summary. +18. Confirm a scheduled reminder sends only once. +19. Inspect logs for useful context without Slack tokens. +20. Confirm the event, RSVP, preference, item, and reminder rows in PostgreSQL. + +## Time handling + +V1 modal labels explicitly use UTC. Values are converted to UTC `Date` values +and PostgreSQL stores them as timestamps. Slack date tokens render in the +viewer’s local timezone. Workspace-aware modal defaults are a V1.1 item. + +## Common problems + +- **`invalid_auth`:** reinstall the app and update `SLACK_BOT_TOKEN`. +- **`dispatch_failed` or timeout:** verify the ngrok URL and that interactions + use `/slack/events`. +- **Card cannot post:** add `chat:write.public` or invite the app to the channel. +- **Expired trigger ID:** make sure listeners acknowledge immediately and avoid + breakpoints before `views.open`. +- **Database unavailable:** wait for `docker compose ps` to show PostgreSQL as + healthy and verify `DATABASE_URL`. diff --git a/docs/marketplace-readiness.md b/docs/marketplace-readiness.md new file mode 100644 index 0000000..5b23e74 --- /dev/null +++ b/docs/marketplace-readiness.md @@ -0,0 +1,47 @@ +# Slack Marketplace readiness + +TeamLoop V1 supports OAuth distribution, but a real submission still needs +operational and listing work. + +## Product and installation + +- [ ] Stable production HTTPS origin and direct install URL. +- [ ] OAuth install, reinstall, and uninstall tested across multiple workspaces. +- [ ] Staging Slack app with the same granular scopes. +- [ ] Friendly installation success and failure pages. +- [ ] At least the currently required number of active workspace installs. + Slack’s current guidelines should be checked immediately before submission; + as of June 21, 2026, the published guideline says apps installed on fewer + than five active workspaces are not accepted. + +## Listing assets + +- [ ] Final app icon and brand-approved screenshots. +- [ ] Short and long descriptions. +- [ ] Demo video showing all six commands and organizer controls. +- [ ] Support email and public support page. +- [ ] Public privacy policy and terms of service. +- [ ] Installation, configuration, and uninstall instructions. + +## Review notes + +- Explain why each of the three scopes is necessary. +- Provide test-workspace installation instructions and fictional test data. +- Demonstrate permission errors for non-organizers. +- Demonstrate token encryption, request-signature verification, data deletion, + duplicate reminder prevention, and failure handling. +- Verify the listing and product behavior match. +- Add at least one additional app collaborator. + +## Maintenance + +- Monitor errors, uninstalls, support requests, and Slack platform changes. +- Keep contact, privacy, support, and listing URLs working. +- Use a staging app and resubmit material behavior or scope changes. +- Revoke tokens and communicate clearly if the app is discontinued. + +Official references: + +- [Marketplace guidelines and requirements](https://docs.slack.dev/slack-marketplace/slack-marketplace-app-guidelines-and-requirements) +- [Distribution and review process](https://docs.slack.dev/slack-marketplace/distributing-your-app-in-the-slack-marketplace) +- [Marketplace review guide](https://docs.slack.dev/slack-marketplace/slack-marketplace-review-guide) diff --git a/docs/permissions-and-data.md b/docs/permissions-and-data.md new file mode 100644 index 0000000..b2ceb1f --- /dev/null +++ b/docs/permissions-and-data.md @@ -0,0 +1,54 @@ +# Permissions and data + +TeamLoop follows a narrow, Slack-first data model. + +## Slack scopes + +| Scope | Why it is needed | +| ------------------- | ----------------------------------------------------------------------------------- | +| `commands` | Receives `/lunch`, `/dinner`, `/potluck`, `/weekendrun`, `/marathon`, and `/hyrox`. | +| `chat:write` | Posts and updates event cards, reminders, and ephemeral summaries. | +| `chat:write.public` | Posts the initial card in a public channel when the app has not been invited. | + +No user token scopes are requested. Private channels must invite the app. +TeamLoop does not request message history, email, profile, direct-message, +file, admin, or broad channel-read scopes. + +## Stored data + +- Slack workspace ID, optional workspace name, bot user ID, installer user ID. +- OAuth bot token, encrypted with AES-256-GCM when an encryption key is set. +- Slack channel ID and optional channel name. +- Event title, type, time, location, notes, state, message timestamp, and + activity-specific metadata. +- Slack user IDs associated with RSVPs, preferences, and potluck items. +- Reminder state and a minimal event audit trail. + +## Data not stored + +- Slack message history or thread contents. +- Private messages. +- Email addresses or full user profiles. +- Files, calendars, payment details, health records, or location history. +- Analytics or behavior outside explicit TeamLoop interactions. + +## Logs + +Logs include event/reminder IDs and error context. Secret-shaped logger fields +are redacted. Operators should still avoid adding raw Slack payloads to logs. + +## Uninstall and deletion + +When `app_uninstalled` is configured, TeamLoop removes the stored workspace +token and cancels pending reminders. Event data remains for the self-hosting +operator to manage. + +Delete one workspace and all related records: + +```sql +DELETE FROM "Workspace" WHERE "slackTeamId" = 'T_WORKSPACE_ID'; +``` + +Foreign-key cascades remove channels, events, RSVPs, preferences, potluck +items, reminders, and audit logs. Operators should define a retention period +and include backups in deletion procedures. diff --git a/docs/releasing.md b/docs/releasing.md new file mode 100644 index 0000000..f3264dd --- /dev/null +++ b/docs/releasing.md @@ -0,0 +1,38 @@ +# Releasing TeamLoop + +TeamLoop uses Semantic Versioning and annotated Git tags: + +- `vMAJOR.MINOR.PATCH` +- patch for compatible fixes; +- minor for compatible functionality; +- major for incompatible changes. + +## Prepare a release + +1. Create a branch from the latest `main`. +2. Update `package.json` and `package-lock.json` to the release version. +3. Move notable changes from the Unreleased section into a dated section in + `CHANGELOG.md`. +4. Open a pull request and wait for CI and review. +5. Rebase-merge the pull request. + +## Publish + +In GitHub Actions, open the **Release** workflow, choose **Run workflow** on +`main`, and enter the version without a leading `v`, for example `1.1.0`. + +The workflow: + +1. verifies that it is running from `main`; +2. checks that `package.json` has the requested version; +3. runs formatting, linting, typechecking, tests, and the production build; +4. creates and pushes annotated tag `v1.1.0`; +5. creates a GitHub Release with generated categorized notes. + +The workflow refuses to overwrite an existing tag. Releases should never be +created from an unmerged feature branch. + +## Hotfixes + +Prepare a normal pull request from the current `main`, update the patch version, +rebase-merge it, then run the same workflow. diff --git a/docs/self-hosting.md b/docs/self-hosting.md new file mode 100644 index 0000000..7565c81 --- /dev/null +++ b/docs/self-hosting.md @@ -0,0 +1,55 @@ +# Self-hosting + +## Docker Compose + +```bash +cp .env.example .env +# Configure Slack credentials and public APP_BASE_URL +docker compose up -d --build +``` + +The app container waits for PostgreSQL health, runs `prisma migrate deploy`, +and starts TeamLoop. Persist the `teamloop-postgres` volume. + +## Production checklist + +- Terminate TLS at a reverse proxy or load balancer. +- Keep `.env` and backups outside the repository. +- Set a 32+ character OAuth state secret. +- Set a base64-encoded 32-byte token encryption key. +- Restrict database network access. +- Monitor `/health`, process exits, Slack API errors, and failed reminders. +- Back up PostgreSQL and test restore procedures. +- Deploy migrations before or with application startup. + +## Scaling + +The HTTP app is stateless apart from PostgreSQL and may have multiple +instances. The reminder claim is atomic, but V1 is best operated with one +scheduler instance. At larger scale, separate the scheduler into a worker and +add a PostgreSQL advisory lock or queue. + +## Backups + +Example logical backup: + +```bash +docker compose exec -T postgres pg_dump -U teamloop teamloop > teamloop.sql +``` + +Protect backups as sensitive because they contain Slack user IDs, event +locations, and encrypted workspace bot tokens. + +## Deployment targets + +Any platform that can run a Node.js container and PostgreSQL works. Configure a +stable HTTPS origin, port 3000, health checks, persistent database storage, and +the environment variables from `.env.example`. No paid platform is required. + +## Upgrade + +1. Back up PostgreSQL. +2. Pull the desired release. +3. Run `docker compose build`. +4. Run `docker compose up -d`. +5. Confirm `/health`, migrations, one slash command, and reminder delivery. diff --git a/docs/slack-app-setup.md b/docs/slack-app-setup.md new file mode 100644 index 0000000..a2dba45 --- /dev/null +++ b/docs/slack-app-setup.md @@ -0,0 +1,105 @@ +# Slack app setup + +This guide configures TeamLoop in HTTP mode. Socket Mode is intentionally not +used because it is unsuitable for public Slack Marketplace distribution. + +## 1. Start TeamLoop and expose HTTPS + +Run the app on port 3000, then create a tunnel: + +```bash +ngrok http 3000 +``` + +Use the resulting HTTPS origin as `APP_BASE_URL`. + +## 2. Create the Slack app + +Create an app at [api.slack.com/apps](https://api.slack.com/apps), either from +scratch or from [`manifest.yml`](../manifest.yml). Replace every +`https://YOUR_DOMAIN` value with the tunnel or deployed origin. + +## 3. Add bot scopes + +Under **OAuth & Permissions**, add: + +- `commands` — receives the six slash commands. +- `chat:write` — posts and updates event cards and reminders. +- `chat:write.public` — allows a slash command to create a card in a public + channel where the bot has not already been invited. + +Private channels must explicitly include the app. TeamLoop does not request +`users:read`, channel read scopes, direct-message scopes, or message history. + +## 4. Add slash commands + +Create each command with request URL +`https://YOUR_DOMAIN/slack/events`: + +- `/lunch` +- `/dinner` +- `/potluck` +- `/weekendrun` +- `/marathon` +- `/hyrox` + +## 5. Enable interactivity + +Under **Interactivity & Shortcuts**, enable interactivity and set the request +URL to: + +```text +https://YOUR_DOMAIN/slack/events +``` + +No shortcuts are required in V1. + +## 6. Optional uninstall event + +Under **Event Subscriptions**, enable events, use the same request URL, and add +the `app_uninstalled` bot event. TeamLoop clears the stored OAuth token and +cancels pending reminders when this event arrives. + +## 7. Install for local development + +Install the app into a test workspace. Copy the bot token and signing secret: + +```dotenv +SLACK_SIGNING_SECRET=... +SLACK_BOT_TOKEN=xoxb-... +``` + +Restart TeamLoop and run `/lunch` in a test channel. + +## 8. Configure OAuth distribution + +Set the redirect URL in Slack: + +```text +https://YOUR_DOMAIN/slack/oauth_redirect +``` + +Configure: + +```dotenv +SLACK_CLIENT_ID=... +SLACK_CLIENT_SECRET=... +SLACK_STATE_SECRET=a-random-secret-with-at-least-32-characters +TOKEN_ENCRYPTION_KEY=BASE64_ENCODED_32_BYTE_KEY +APP_BASE_URL=https://YOUR_DOMAIN +``` + +Generate an encryption key: + +```bash +openssl rand -base64 32 +``` + +Do not set `SLACK_BOT_TOKEN` in distributed mode. Open +`https://YOUR_DOMAIN/slack/install` to start OAuth. + +## Manual smoke test + +Create each event type, submit an RSVP from two users, set a preference, update +the event, send a reminder, close RSVPs, and cancel it. For a potluck, add a +contribution and confirm that it appears in the live card. diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..066753b --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,29 @@ +import eslint from "@eslint/js"; +import tseslint from "typescript-eslint"; + +export default tseslint.config( + { + ignores: ["dist/**", "node_modules/**", "coverage/**", "eslint.config.js"], + }, + eslint.configs.recommended, + ...tseslint.configs.recommendedTypeChecked, + { + languageOptions: { + parserOptions: { + projectService: true, + tsconfigRootDir: import.meta.dirname, + }, + }, + rules: { + "@typescript-eslint/no-misused-promises": ["error", { checksVoidReturn: false }], + "@typescript-eslint/require-await": "off", + }, + }, + { + files: ["src/tests/**/*.ts"], + rules: { + "@typescript-eslint/no-unsafe-assignment": "off", + "@typescript-eslint/unbound-method": "off", + }, + }, +); diff --git a/manifest.yml b/manifest.yml new file mode 100644 index 0000000..b14b499 --- /dev/null +++ b/manifest.yml @@ -0,0 +1,52 @@ +display_information: + name: TeamLoop + description: Plan team lunches, potlucks, runs, and fitness sessions directly in Slack. + background_color: "#5F9F68" +features: + bot_user: + display_name: TeamLoop + always_online: false + slash_commands: + - command: /lunch + description: Plan a team lunch + usage_hint: "" + should_escape: false + - command: /dinner + description: Plan a team dinner + usage_hint: "" + should_escape: false + - command: /potluck + description: Plan a team potluck + usage_hint: "" + should_escape: false + - command: /weekendrun + description: Plan a weekend run + usage_hint: "" + should_escape: false + - command: /marathon + description: Plan marathon training or a race + usage_hint: "" + should_escape: false + - command: /hyrox + description: Plan a HYROX session + usage_hint: "" + should_escape: false +oauth_config: + redirect_urls: + - https://YOUR_DOMAIN/slack/oauth_redirect + scopes: + bot: + - commands + - chat:write + - chat:write.public +settings: + interactivity: + is_enabled: true + request_url: https://YOUR_DOMAIN/slack/events + event_subscriptions: + request_url: https://YOUR_DOMAIN/slack/events + bot_events: + - app_uninstalled + org_deploy_enabled: false + socket_mode_enabled: false + token_rotation_enabled: false diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..e0c8681 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,5106 @@ +{ + "name": "teamloop", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "teamloop", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "@prisma/client": "^6.10.1", + "@slack/bolt": "^4.4.0", + "@slack/oauth": "^3.0.5", + "@slack/types": "^2.21.1", + "@slack/web-api": "^7.17.0", + "dotenv": "^16.5.0", + "node-cron": "^4.1.1", + "zod": "^3.25.67" + }, + "devDependencies": { + "@eslint/js": "^9.29.0", + "@types/node": "^22.15.32", + "@types/node-cron": "^3.0.11", + "eslint": "^9.29.0", + "prettier": "^3.5.3", + "prisma": "^6.10.1", + "tsx": "^4.20.3", + "typescript": "^5.8.3", + "typescript-eslint": "^8.34.1", + "vitest": "^4.1.9" + }, + "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/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "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.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.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/@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/@napi-rs/wasm-runtime": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", + "integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.2" + }, + "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/@prisma/client": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.3.tgz", + "integrity": "sha512-mKq3jQFhjvko5LTJFHGilsuQs+W+T3Gm451NzuTDGQxwCzwXHYnIu2zGkRoW+Exq3Rob7yp2MfzSrdIiZVhrBg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "peerDependencies": { + "prisma": "*", + "typescript": ">=5.1.0" + }, + "peerDependenciesMeta": { + "prisma": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@prisma/config": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.19.3.tgz", + "integrity": "sha512-CBPT44BjlQxEt8kiMEauji2WHTDoVBOKl7UlewXmUgBPnr/oPRZC3psci5chJnYmH0ivEIog2OU9PGWoki3DLQ==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "c12": "3.1.0", + "deepmerge-ts": "7.1.5", + "effect": "3.21.0", + "empathic": "2.0.0" + } + }, + "node_modules/@prisma/debug": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.19.3.tgz", + "integrity": "sha512-ljkJ+SgpXNktLG0Q/n4JGYCkKf0f8oYLyjImS2I8e2q2WCfdRRtWER062ZV/ixaNP2M2VKlWXVJiGzZaUgbKZw==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/engines": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.19.3.tgz", + "integrity": "sha512-RSYxtlYFl5pJ8ZePgMv0lZ9IzVCOdTPOegrs2qcbAEFrBI1G33h6wyC9kjQvo0DnYEhEVY0X4LsuFHXLKQk88g==", + "devOptional": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "6.19.3", + "@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", + "@prisma/fetch-engine": "6.19.3", + "@prisma/get-platform": "6.19.3" + } + }, + "node_modules/@prisma/engines-version": { + "version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7.tgz", + "integrity": "sha512-03bgb1VD5gvuumNf+7fVGBzfpJPjmqV423l/WxsWk2cNQ42JD0/SsFBPhN6z8iAvdHs07/7ei77SKu7aZfq8bA==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/fetch-engine": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.19.3.tgz", + "integrity": "sha512-tKtl/qco9Nt7LU5iKhpultD8O4vMCZcU2CHjNTnRrL1QvSUr5W/GcyFPjNL87GtRrwBc7ubXXD9xy4EvLvt8JA==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "6.19.3", + "@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", + "@prisma/get-platform": "6.19.3" + } + }, + "node_modules/@prisma/get-platform": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.19.3.tgz", + "integrity": "sha512-xFj1VcJ1N3MKooOQAGO0W5tsd0W2QzIvW7DD7c/8H14Zmp4jseeWAITm+w2LLoLrlhoHdPPh0NMZ8mfL6puoHA==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "6.19.3" + } + }, + "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/@slack/bolt": { + "version": "4.7.3", + "resolved": "https://registry.npmjs.org/@slack/bolt/-/bolt-4.7.3.tgz", + "integrity": "sha512-bODs8q/yNDWUPoxmQhFrRqLMA5vhB/PDizYWqb6CkQhLWEUo5JFtfJcmeU4ElGl6qSt++OKjSYNa4MPc77CleQ==", + "license": "MIT", + "dependencies": { + "@slack/logger": "^4.0.1", + "@slack/oauth": "^3.0.5", + "@slack/socket-mode": "^2.0.7", + "@slack/types": "^2.21.1", + "@slack/web-api": "^7.16.0", + "axios": "^1.12.0", + "express": "^5.0.0", + "path-to-regexp": "^8.1.0", + "raw-body": "^3", + "tsscmp": "^1.0.6" + }, + "engines": { + "node": ">=18", + "npm": ">=8.6.0" + }, + "peerDependencies": { + "@types/express": "^5.0.0" + } + }, + "node_modules/@slack/logger": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@slack/logger/-/logger-4.0.1.tgz", + "integrity": "sha512-6cmdPrV/RYfd2U0mDGiMK8S7OJqpCTm7enMLRR3edccsPX8j7zXTLnaEF4fhxxJJTAIOil6+qZrnUPTuaLvwrQ==", + "license": "MIT", + "dependencies": { + "@types/node": ">=18" + }, + "engines": { + "node": ">= 18", + "npm": ">= 8.6.0" + } + }, + "node_modules/@slack/oauth": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@slack/oauth/-/oauth-3.0.5.tgz", + "integrity": "sha512-exqFQySKhNDptWYSWhvRUJ4/+ndu2gayIy7vg/JfmJq3wGtGdHk531P96fAZyBm5c1Le3yaPYqv92rL4COlU3A==", + "license": "MIT", + "dependencies": { + "@slack/logger": "^4.0.1", + "@slack/web-api": "^7.15.0", + "@types/jsonwebtoken": "^9", + "@types/node": ">=18", + "jsonwebtoken": "^9" + }, + "engines": { + "node": ">=18", + "npm": ">=8.6.0" + } + }, + "node_modules/@slack/socket-mode": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@slack/socket-mode/-/socket-mode-2.0.7.tgz", + "integrity": "sha512-qYy07je71WnEHgRwmw12DlAnZLi5HXmdlI2WUzUK2LH/rYXQpP6uEg462S5CwfE8FoCKUdIigHtYnOOfzZH1lQ==", + "license": "MIT", + "dependencies": { + "@slack/logger": "^4.0.1", + "@slack/web-api": "^7.15.0", + "@types/node": ">=18", + "@types/ws": "^8", + "eventemitter3": "^5", + "ws": "^8" + }, + "engines": { + "node": ">= 18", + "npm": ">= 8.6.0" + } + }, + "node_modules/@slack/types": { + "version": "2.21.1", + "resolved": "https://registry.npmjs.org/@slack/types/-/types-2.21.1.tgz", + "integrity": "sha512-I8vmSjNYWsaxuWPx6dz4yeh0h7vRBWbgAMK14LEmblbZ404BtrPbXs6jDPx4cYgGf8msDGF4A9opLZBu21FViQ==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0", + "npm": ">= 6.12.0" + } + }, + "node_modules/@slack/web-api": { + "version": "7.17.0", + "resolved": "https://registry.npmjs.org/@slack/web-api/-/web-api-7.17.0.tgz", + "integrity": "sha512-jejr34a8B4L5AS713wOAx1LAqNkW16HVMDEa6sYBvFDc/llUBl8hXaiI4BwF+Al+Sug19Vn2O7iokTVIhVvZ1Q==", + "license": "MIT", + "dependencies": { + "@slack/logger": "^4.0.1", + "@slack/types": "^2.21.0", + "@types/node": ">=18", + "@types/retry": "0.12.0", + "axios": "^1.16.0", + "eventemitter3": "^5.0.1", + "form-data": "^4.0.4", + "is-electron": "2.2.2", + "is-stream": "^2", + "p-queue": "^6", + "p-retry": "^4", + "retry": "^0.13.1" + }, + "engines": { + "node": ">= 18", + "npm": ">= 8.6.0" + } + }, + "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==", + "devOptional": true, + "license": "MIT" + }, + "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/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "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/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/node": "*" + } + }, + "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/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/express": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", + "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^2" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz", + "integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "license": "MIT", + "peer": true + }, + "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/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.20.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.0.tgz", + "integrity": "sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/node-cron": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/node-cron/-/node-cron-3.0.11.tgz", + "integrity": "sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", + "license": "MIT", + "peer": true + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "license": "MIT", + "peer": true + }, + "node_modules/@types/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*" + } + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.61.1.tgz", + "integrity": "sha512-ZPlVl3PB3et/59Ne0fv/sci6ZXz4T4Hp4nTJ56i/Y0gR89ARb+KphojTq6j+56E5PIezmOIOOWyY+aWQFd+IkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.61.1", + "@typescript-eslint/type-utils": "8.61.1", + "@typescript-eslint/utils": "8.61.1", + "@typescript-eslint/visitor-keys": "8.61.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.61.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.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.61.1.tgz", + "integrity": "sha512-PJ5vePq5/ognBbrIcoC5+SHO5dfpeLPzP9FpLkzWrguoYQEeeSjlJpVwOpo1JRSTEi7dRcwNy4h4dzV70PqHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.61.1", + "@typescript-eslint/types": "8.61.1", + "@typescript-eslint/typescript-estree": "8.61.1", + "@typescript-eslint/visitor-keys": "8.61.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.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.61.1.tgz", + "integrity": "sha512-PrC4JYGmR241lYnfhmKGTXkFqv8+ymbTFgSAY0fVXpY82/QkMw5TZPl+vGzuDDU2QYJk9fIDOBTntF+yDv9LEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.61.1", + "@typescript-eslint/types": "^8.61.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.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.61.1.tgz", + "integrity": "sha512-L2bdIeoQS8FlKAvONAr20w6OcLXeB+qiDKbAooS9A0Ben+iSIkBef0FxqwKWYqt5sa0i4KJtxVyVmhMylKzF5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.61.1", + "@typescript-eslint/visitor-keys": "8.61.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.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.61.1.tgz", + "integrity": "sha512-UN/H4di+OO7EWx2ovME+8t31YO+KVnK0RRKEHR3kOt21/Ay8BOq3M1OMvWs5vNiqcFCYGYoxK3MXPZzmMUE+yg==", + "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.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.61.1.tgz", + "integrity": "sha512-GYRicKmVK0C4fsKgaACaknOUAq9Oa2kwsjnpFhFcS/5p4Ht5IP9OVLbgIgcK4SRk92nVHFluurg1lumD9dBcLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.61.1", + "@typescript-eslint/typescript-estree": "8.61.1", + "@typescript-eslint/utils": "8.61.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.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.61.1.tgz", + "integrity": "sha512-G+CRlPqLv7Bz1IZVs03x5K59F1veqL0EJUROAdGhKsEq8qOiRiZbI+HUojPq5l0fEGOKModD9br6lObhB8zkoA==", + "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.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.61.1.tgz", + "integrity": "sha512-u+oQD3BqYWPc8YV9Zab4vaJElJuwOLPRc10Jm1o/qS+6Qwen14HCWwx0Seo4LnSn2wxea2Ik8DxPt2/FHmuhrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.61.1", + "@typescript-eslint/tsconfig-utils": "8.61.1", + "@typescript-eslint/types": "8.61.1", + "@typescript-eslint/visitor-keys": "8.61.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/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/@typescript-eslint/typescript-estree/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/@typescript-eslint/typescript-estree/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/@typescript-eslint/utils": { + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.61.1.tgz", + "integrity": "sha512-1+P/3Dj6jvtybE1q0HQ6yBt/gq+oKJyLdEv4HdnqasaEXRSYCAsD59mXEVQnM/ULNdQxbX77tdG4jPRjIS6knA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.61.1", + "@typescript-eslint/types": "8.61.1", + "@typescript-eslint/typescript-estree": "8.61.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.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.61.1.tgz", + "integrity": "sha512-6fJ9MHWtK14C1DSkiMlHUSOmrVebL7150xZJBlJiL62jjhIA4JmOq6flwBgDxIdBKKdoiZRel+dfPD5MLfny3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.61.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/@typescript-eslint/visitor-keys/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/@vitest/expect": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", + "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz", + "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.9", + "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/@vitest/pretty-format": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz", + "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz", + "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.9", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz", + "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.9", + "@vitest/utils": "4.1.9", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz", + "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz", + "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.9", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "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/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "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": "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/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/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "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/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/axios": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.0.tgz", + "integrity": "sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw==", + "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": "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/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/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": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.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/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/c12": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz", + "integrity": "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "chokidar": "^4.0.3", + "confbox": "^0.2.2", + "defu": "^6.1.4", + "dotenv": "^16.6.1", + "exsolve": "^1.0.7", + "giget": "^2.0.0", + "jiti": "^2.4.2", + "ohash": "^2.0.11", + "pathe": "^2.0.3", + "perfect-debounce": "^1.0.0", + "pkg-types": "^2.2.0", + "rc9": "^2.1.2" + }, + "peerDependencies": { + "magicast": "^0.3.5" + }, + "peerDependenciesMeta": { + "magicast": { + "optional": true + } + } + }, + "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/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "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/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/citty": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz", + "integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "consola": "^3.2.3" + } + }, + "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/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/confbox": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", + "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, + "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/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/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": "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/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/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/deepmerge-ts": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", + "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==", + "devOptional": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/defu": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", + "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", + "devOptional": true, + "license": "MIT" + }, + "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/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/destr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", + "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", + "devOptional": 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/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "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/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/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/effect": { + "version": "3.21.0", + "resolved": "https://registry.npmjs.org/effect/-/effect-3.21.0.tgz", + "integrity": "sha512-PPN80qRokCd1f015IANNhrwOnLO7GrrMQfk4/lnZRE/8j7UPWrNNjPV0uBrZutI/nHzernbW+J0hdqQysHiSnQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "fast-check": "^3.23.1" + } + }, + "node_modules/empathic": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz", + "integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "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/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-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-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/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "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": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", + "@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", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.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", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "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/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/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/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/exsolve": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", + "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/fast-check": { + "version": "3.23.2", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", + "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==", + "devOptional": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^6.1.0" + }, + "engines": { + "node": ">=8.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/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/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data/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/form-data/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/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/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/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/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/giget": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz", + "integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "citty": "^0.1.6", + "consola": "^3.4.0", + "defu": "^6.1.4", + "node-fetch-native": "^1.6.6", + "nypm": "^0.6.0", + "pathe": "^2.0.3" + }, + "bin": { + "giget": "dist/cli.mjs" + } + }, + "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": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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/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/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/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/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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/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/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-electron": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/is-electron/-/is-electron-2.2.2.tgz", + "integrity": "sha512-FO/Rhvz5tuw4MCWkpMzHFKWD2LsfHzIb7i6MdPYZ/KW7AlxawyLkqdy+jPZP1WubqEADE3O4FUENlJHDfQASRg==", + "license": "MIT" + }, + "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/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-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/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==", + "devOptional": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "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==", + "dev": true, + "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-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/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/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/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.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "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/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/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": "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/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/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/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/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "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/nanoid": { + "version": "3.3.14", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.14.tgz", + "integrity": "sha512-U9kYi5bpVMEI31yC8iw4bJJp0avcHXA0W8/wNfLfnvJYzihQo2ZRPYPvpAAd570HAcCBjCTN7vnr+v4StKl1IQ==", + "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/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/node-cron": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.4.1.tgz", + "integrity": "sha512-1e4Gku1qeKx1ywNnMoA0o5hiZ8iA4klH84TIszTHZAME68xVIjLkiBo5KrtowbcOJn8MQqJG/yWHjC/dFdMWyQ==", + "license": "ISC", + "engines": { + "node": ">=20" + } + }, + "node_modules/node-fetch-native": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", + "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/nypm": { + "version": "0.6.7", + "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.7.tgz", + "integrity": "sha512-s3ds97SD5pd1dULE+tHUk1DrV0cSHOnsfpcdGATJ8JpBo21DoKqN9exTH4/2nhPQNOLomBdTFMicN94S4DrZrQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "citty": "^0.2.2", + "pathe": "^2.0.3", + "tinyexec": "^1.2.4" + }, + "bin": { + "nypm": "dist/cli.mjs" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/nypm/node_modules/citty": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/citty/-/citty-0.2.2.tgz", + "integrity": "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==", + "devOptional": true, + "license": "MIT" + }, + "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/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/ohash": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", + "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", + "devOptional": true, + "license": "MIT" + }, + "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/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-finally": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", + "integrity": "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "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/p-queue": { + "version": "6.6.2", + "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-6.6.2.tgz", + "integrity": "sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==", + "license": "MIT", + "dependencies": { + "eventemitter3": "^4.0.4", + "p-timeout": "^3.2.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-queue/node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, + "node_modules/p-retry": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", + "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "license": "MIT", + "dependencies": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-timeout": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-3.2.0.tgz", + "integrity": "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==", + "license": "MIT", + "dependencies": { + "p-finally": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "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/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/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/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/perfect-debounce": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "devOptional": 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/pkg-types": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz", + "integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.2.4", + "exsolve": "^1.0.8", + "pathe": "^2.0.3" + } + }, + "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/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/prettier": { + "version": "3.8.4", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.4.tgz", + "integrity": "sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prisma": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.3.tgz", + "integrity": "sha512-++ZJ0ijLrDJF6hNB4t4uxg2br3fC4H9Yc9tcbjr2fcNFP3rh/SBNrAgjhsqBU4Ght8JPrVofG/ZkXfnSfnYsFg==", + "devOptional": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/config": "6.19.3", + "@prisma/engines": "6.19.3" + }, + "bin": { + "prisma": "build/index.js" + }, + "engines": { + "node": ">=18.18" + }, + "peerDependencies": { + "typescript": ">=5.1.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "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/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/pure-rand": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "devOptional": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "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/rc9": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz", + "integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "defu": "^6.1.4", + "destr": "^2.0.3" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "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/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/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/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.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "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/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==", + "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/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "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/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/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/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/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/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "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==", + "devOptional": 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/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/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==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/tsscmp": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/tsscmp/-/tsscmp-1.0.6.tgz", + "integrity": "sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==", + "license": "MIT", + "engines": { + "node": ">=0.6.x" + } + }, + "node_modules/tsx": { + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", + "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "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": "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/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/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.61.1.tgz", + "integrity": "sha512-V7PayAfJokV3pEHgN7/v03D1SpujhRfQtYLbLIiBfDDncdg4PAiRBfoS4cnCANK4jmAPncczi59QO3afiXUlNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.61.1", + "@typescript-eslint/parser": "8.61.1", + "@typescript-eslint/typescript-estree": "8.61.1", + "@typescript-eslint/utils": "8.61.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": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "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/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/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/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.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz", + "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.9", + "@vitest/mocker": "4.1.9", + "@vitest/pretty-format": "4.1.9", + "@vitest/runner": "4.1.9", + "@vitest/snapshot": "4.1.9", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "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.9", + "@vitest/browser-preview": "4.1.9", + "@vitest/browser-webdriverio": "4.1.9", + "@vitest/coverage-istanbul": "4.1.9", + "@vitest/coverage-v8": "4.1.9", + "@vitest/ui": "4.1.9", + "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/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/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/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "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": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..a26db48 --- /dev/null +++ b/package.json @@ -0,0 +1,73 @@ +{ + "name": "teamloop", + "version": "1.0.0", + "description": "Open-source Slack bot for planning real-world team activities.", + "private": true, + "license": "MIT", + "homepage": "https://github.com/ayushhagarwal/teamloop#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/ayushhagarwal/teamloop.git" + }, + "bugs": { + "url": "https://github.com/ayushhagarwal/teamloop/issues" + }, + "keywords": [ + "slack", + "slack-bot", + "slack-bolt", + "team-activities", + "event-planning", + "rsvp", + "potluck", + "team-lunch", + "running", + "hyrox", + "typescript", + "postgresql", + "prisma", + "open-source" + ], + "type": "module", + "engines": { + "node": ">=20" + }, + "scripts": { + "build": "tsc -p tsconfig.json", + "dev": "tsx watch src/index.ts", + "start": "node dist/src/index.js", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "format": "prettier --write .", + "format:check": "prettier --check .", + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "vitest run", + "test:watch": "vitest", + "prisma:generate": "prisma generate", + "prisma:migrate": "prisma migrate deploy", + "prisma:migrate:dev": "prisma migrate dev", + "prisma:seed": "tsx prisma/seed.ts" + }, + "dependencies": { + "@prisma/client": "^6.10.1", + "@slack/bolt": "^4.4.0", + "@slack/oauth": "^3.0.5", + "@slack/types": "^2.21.1", + "@slack/web-api": "^7.17.0", + "dotenv": "^16.5.0", + "node-cron": "^4.1.1", + "zod": "^3.25.67" + }, + "devDependencies": { + "@eslint/js": "^9.29.0", + "@types/node": "^22.15.32", + "@types/node-cron": "^3.0.11", + "eslint": "^9.29.0", + "prettier": "^3.5.3", + "prisma": "^6.10.1", + "tsx": "^4.20.3", + "typescript": "^5.8.3", + "typescript-eslint": "^8.34.1", + "vitest": "^4.1.9" + } +} diff --git a/prisma.config.ts b/prisma.config.ts new file mode 100644 index 0000000..75046ac --- /dev/null +++ b/prisma.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "prisma/config"; + +export default defineConfig({ + schema: "prisma/schema.prisma", + migrations: { + path: "prisma/migrations", + seed: "tsx prisma/seed.ts", + }, +}); diff --git a/prisma/migrations/20260621000000_init/migration.sql b/prisma/migrations/20260621000000_init/migration.sql new file mode 100644 index 0000000..2b2954f --- /dev/null +++ b/prisma/migrations/20260621000000_init/migration.sql @@ -0,0 +1,134 @@ +-- CreateEnum +CREATE TYPE "EventType" AS ENUM ('LUNCH', 'DINNER', 'POTLUCK', 'WEEKEND_RUN', 'MARATHON', 'HYROX'); +CREATE TYPE "EventStatus" AS ENUM ('OPEN', 'RSVP_CLOSED', 'CANCELLED', 'COMPLETED'); +CREATE TYPE "RsvpStatus" AS ENUM ('GOING', 'MAYBE', 'CANNOT_MAKE_IT'); +CREATE TYPE "ReminderType" AS ENUM ('RSVP_DEADLINE', 'EVENT_START', 'MANUAL'); +CREATE TYPE "ReminderStatus" AS ENUM ('PENDING', 'PROCESSING', 'SENT', 'FAILED', 'CANCELLED'); + +CREATE TABLE "Workspace" ( + "id" TEXT NOT NULL, + "slackTeamId" TEXT NOT NULL, + "name" TEXT, + "botUserId" TEXT, + "installedBySlackUserId" TEXT, + "accessToken" TEXT, + "uninstalledAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "Workspace_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "Channel" ( + "id" TEXT NOT NULL, + "slackChannelId" TEXT NOT NULL, + "workspaceId" TEXT NOT NULL, + "name" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "Channel_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "Event" ( + "id" TEXT NOT NULL, + "workspaceId" TEXT NOT NULL, + "channelId" TEXT NOT NULL, + "organizerSlackUserId" TEXT NOT NULL, + "type" "EventType" NOT NULL, + "title" TEXT NOT NULL, + "description" TEXT, + "location" TEXT, + "eventStartsAt" TIMESTAMP(3) NOT NULL, + "rsvpDeadlineAt" TIMESTAMP(3), + "status" "EventStatus" NOT NULL DEFAULT 'OPEN', + "slackMessageTs" TEXT, + "slackChannelId" TEXT NOT NULL, + "metadata" JSONB NOT NULL DEFAULT '{}', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "cancelledAt" TIMESTAMP(3), + "closedAt" TIMESTAMP(3), + CONSTRAINT "Event_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "RSVP" ( + "id" TEXT NOT NULL, + "eventId" TEXT NOT NULL, + "slackUserId" TEXT NOT NULL, + "status" "RsvpStatus" NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "RSVP_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "Preference" ( + "id" TEXT NOT NULL, + "eventId" TEXT NOT NULL, + "slackUserId" TEXT NOT NULL, + "key" TEXT NOT NULL, + "value" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "Preference_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "PotluckItem" ( + "id" TEXT NOT NULL, + "eventId" TEXT NOT NULL, + "category" TEXT NOT NULL, + "itemName" TEXT NOT NULL, + "claimedBySlackUserId" TEXT, + "servesCount" INTEGER, + "dietaryType" TEXT, + "notes" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "PotluckItem_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "Reminder" ( + "id" TEXT NOT NULL, + "eventId" TEXT NOT NULL, + "type" "ReminderType" NOT NULL, + "scheduledFor" TIMESTAMP(3) NOT NULL, + "sentAt" TIMESTAMP(3), + "status" "ReminderStatus" NOT NULL DEFAULT 'PENDING', + "attemptCount" INTEGER NOT NULL DEFAULT 0, + "lastError" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "Reminder_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "AuditLog" ( + "id" TEXT NOT NULL, + "workspaceId" TEXT NOT NULL, + "eventId" TEXT, + "actorSlackUserId" TEXT, + "action" TEXT NOT NULL, + "metadata" JSONB NOT NULL DEFAULT '{}', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "AuditLog_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "Workspace_slackTeamId_key" ON "Workspace"("slackTeamId"); +CREATE UNIQUE INDEX "Channel_workspaceId_slackChannelId_key" ON "Channel"("workspaceId", "slackChannelId"); +CREATE INDEX "Event_workspaceId_status_idx" ON "Event"("workspaceId", "status"); +CREATE INDEX "Event_eventStartsAt_idx" ON "Event"("eventStartsAt"); +CREATE UNIQUE INDEX "RSVP_eventId_slackUserId_key" ON "RSVP"("eventId", "slackUserId"); +CREATE INDEX "RSVP_eventId_status_idx" ON "RSVP"("eventId", "status"); +CREATE UNIQUE INDEX "Preference_eventId_slackUserId_key_key" ON "Preference"("eventId", "slackUserId", "key"); +CREATE INDEX "PotluckItem_eventId_category_idx" ON "PotluckItem"("eventId", "category"); +CREATE INDEX "Reminder_status_scheduledFor_idx" ON "Reminder"("status", "scheduledFor"); +CREATE UNIQUE INDEX "Reminder_eventId_type_scheduledFor_key" ON "Reminder"("eventId", "type", "scheduledFor"); +CREATE INDEX "AuditLog_workspaceId_createdAt_idx" ON "AuditLog"("workspaceId", "createdAt"); +CREATE INDEX "AuditLog_eventId_idx" ON "AuditLog"("eventId"); + +ALTER TABLE "Channel" ADD CONSTRAINT "Channel_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "Workspace"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "Event" ADD CONSTRAINT "Event_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "Workspace"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "Event" ADD CONSTRAINT "Event_channelId_fkey" FOREIGN KEY ("channelId") REFERENCES "Channel"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "RSVP" ADD CONSTRAINT "RSVP_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "Preference" ADD CONSTRAINT "Preference_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "PotluckItem" ADD CONSTRAINT "PotluckItem_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "Reminder" ADD CONSTRAINT "Reminder_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "AuditLog" ADD CONSTRAINT "AuditLog_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "Workspace"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "AuditLog" ADD CONSTRAINT "AuditLog_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..256aa97 --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,177 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +enum EventType { + LUNCH + DINNER + POTLUCK + WEEKEND_RUN + MARATHON + HYROX +} + +enum EventStatus { + OPEN + RSVP_CLOSED + CANCELLED + COMPLETED +} + +enum RsvpStatus { + GOING + MAYBE + CANNOT_MAKE_IT +} + +enum ReminderType { + RSVP_DEADLINE + EVENT_START + MANUAL +} + +enum ReminderStatus { + PENDING + PROCESSING + SENT + FAILED + CANCELLED +} + +model Workspace { + id String @id @default(cuid()) + slackTeamId String @unique + name String? + botUserId String? + installedBySlackUserId String? + accessToken String? + uninstalledAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + channels Channel[] + events Event[] + auditLogs AuditLog[] +} + +model Channel { + id String @id @default(cuid()) + slackChannelId String + workspaceId String + name String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + events Event[] + + @@unique([workspaceId, slackChannelId]) +} + +model Event { + id String @id @default(cuid()) + workspaceId String + channelId String + organizerSlackUserId String + type EventType + title String + description String? + location String? + eventStartsAt DateTime + rsvpDeadlineAt DateTime? + status EventStatus @default(OPEN) + slackMessageTs String? + slackChannelId String + metadata Json @default("{}") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + cancelledAt DateTime? + closedAt DateTime? + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + channel Channel @relation(fields: [channelId], references: [id], onDelete: Cascade) + rsvps RSVP[] + preferences Preference[] + potluckItems PotluckItem[] + reminders Reminder[] + auditLogs AuditLog[] + + @@index([workspaceId, status]) + @@index([eventStartsAt]) +} + +model RSVP { + id String @id @default(cuid()) + eventId String + slackUserId String + status RsvpStatus + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + event Event @relation(fields: [eventId], references: [id], onDelete: Cascade) + + @@unique([eventId, slackUserId]) + @@index([eventId, status]) +} + +model Preference { + id String @id @default(cuid()) + eventId String + slackUserId String + key String + value String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + event Event @relation(fields: [eventId], references: [id], onDelete: Cascade) + + @@unique([eventId, slackUserId, key]) +} + +model PotluckItem { + id String @id @default(cuid()) + eventId String + category String + itemName String + claimedBySlackUserId String? + servesCount Int? + dietaryType String? + notes String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + event Event @relation(fields: [eventId], references: [id], onDelete: Cascade) + + @@index([eventId, category]) +} + +model Reminder { + id String @id @default(cuid()) + eventId String + type ReminderType + scheduledFor DateTime + sentAt DateTime? + status ReminderStatus @default(PENDING) + attemptCount Int @default(0) + lastError String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + event Event @relation(fields: [eventId], references: [id], onDelete: Cascade) + + @@index([status, scheduledFor]) + @@unique([eventId, type, scheduledFor]) +} + +model AuditLog { + id String @id @default(cuid()) + workspaceId String + eventId String? + actorSlackUserId String? + action String + metadata Json @default("{}") + createdAt DateTime @default(now()) + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + event Event? @relation(fields: [eventId], references: [id], onDelete: SetNull) + + @@index([workspaceId, createdAt]) + @@index([eventId]) +} diff --git a/prisma/seed.ts b/prisma/seed.ts new file mode 100644 index 0000000..e23721b --- /dev/null +++ b/prisma/seed.ts @@ -0,0 +1,20 @@ +import { prisma } from "../src/db/prisma.js"; + +async function main(): Promise { + await prisma.workspace.upsert({ + where: { slackTeamId: "T_TEAMLOOP_DEMO" }, + update: {}, + create: { + slackTeamId: "T_TEAMLOOP_DEMO", + name: "TeamLoop Demo", + }, + }); +} + +main() + .then(async () => prisma.$disconnect()) + .catch(async (error: unknown) => { + console.error(error); + await prisma.$disconnect(); + process.exitCode = 1; + }); diff --git a/screenshots/README.md b/screenshots/README.md new file mode 100644 index 0000000..ab0d7f0 --- /dev/null +++ b/screenshots/README.md @@ -0,0 +1,4 @@ +# Screenshots + +Add sanitized Slack screenshots here before publishing a release. Never include +real workspace names, private channels, tokens, or participant details. diff --git a/src/app.ts b/src/app.ts new file mode 100644 index 0000000..1b827d1 --- /dev/null +++ b/src/app.ts @@ -0,0 +1,100 @@ +import { App, ExpressReceiver } from "@slack/bolt"; +import type { ScheduledTask } from "node-cron"; +import { registerDinnerCommand } from "./commands/dinner.command.js"; +import { registerHyroxCommand } from "./commands/hyrox.command.js"; +import { registerLunchCommand } from "./commands/lunch.command.js"; +import { registerMarathonCommand } from "./commands/marathon.command.js"; +import { registerPotluckCommand } from "./commands/potluck.command.js"; +import { registerWeekendRunCommand } from "./commands/weekendrun.command.js"; +import type { Env } from "./config/env.js"; +import { Logger } from "./config/logger.js"; +import { SLACK_ENDPOINTS, SLACK_SCOPES } from "./config/slack.js"; +import { prisma } from "./db/prisma.js"; +import { registerEventSubmissionHandlers } from "./interactions/event-submissions.js"; +import { registerOrganizerActions } from "./interactions/organizer.actions.js"; +import { registerPotluckActions } from "./interactions/potluck.actions.js"; +import { registerPreferenceActions } from "./interactions/preference.actions.js"; +import { registerRsvpActions } from "./interactions/rsvp.actions.js"; +import { startReminderJob } from "./jobs/reminder.job.js"; +import { EventService } from "./services/event.service.js"; +import { PrismaInstallationStore } from "./services/oauth-installation.store.js"; +import { PotluckService } from "./services/potluck.service.js"; +import { PreferenceService } from "./services/preference.service.js"; +import { ReminderService } from "./services/reminder.service.js"; +import { RsvpService } from "./services/rsvp.service.js"; +import { SlackMessageService } from "./services/slack-message.service.js"; +import { WorkspaceService } from "./services/workspace.service.js"; + +export interface TeamLoopRuntime { + app: App; + receiver: ExpressReceiver; + logger: Logger; + startJobs: () => ScheduledTask; +} + +export function createTeamLoopApp(env: Env): TeamLoopRuntime { + const logger = new Logger(env.LOG_LEVEL); + const workspaces = new WorkspaceService(prisma, env.TOKEN_ENCRYPTION_KEY); + const receiver = + env.SLACK_CLIENT_ID && env.SLACK_CLIENT_SECRET && env.SLACK_STATE_SECRET + ? new ExpressReceiver({ + signingSecret: env.SLACK_SIGNING_SECRET, + endpoints: SLACK_ENDPOINTS.events, + clientId: env.SLACK_CLIENT_ID, + clientSecret: env.SLACK_CLIENT_SECRET, + stateSecret: env.SLACK_STATE_SECRET, + redirectUri: `${env.APP_BASE_URL}${SLACK_ENDPOINTS.oauthRedirect}`, + scopes: [...SLACK_SCOPES], + installationStore: new PrismaInstallationStore(workspaces), + installerOptions: { + installPath: SLACK_ENDPOINTS.install, + redirectUriPath: SLACK_ENDPOINTS.oauthRedirect, + }, + }) + : new ExpressReceiver({ + signingSecret: env.SLACK_SIGNING_SECRET, + endpoints: SLACK_ENDPOINTS.events, + }); + receiver.app.get("/health", (_request, response) => { + response.status(200).json({ ok: true, service: "teamloop" }); + }); + + const app = new App({ + receiver, + ...(env.SLACK_BOT_TOKEN ? { token: env.SLACK_BOT_TOKEN } : {}), + }); + const events = new EventService(prisma); + const rsvps = new RsvpService(prisma); + const preferences = new PreferenceService(prisma); + const potluck = new PotluckService(prisma); + const reminders = new ReminderService(prisma); + const messages = new SlackMessageService(); + + registerLunchCommand(app); + registerDinnerCommand(app); + registerPotluckCommand(app); + registerWeekendRunCommand(app); + registerMarathonCommand(app); + registerHyroxCommand(app); + registerEventSubmissionHandlers(app, events, reminders, messages); + registerRsvpActions(app, rsvps, events, messages); + registerPreferenceActions(app, events, preferences, messages); + registerPotluckActions(app, events, potluck, messages); + registerOrganizerActions(app, events, reminders, messages); + + app.event("app_uninstalled", async ({ body }) => { + const teamId = "team_id" in body ? body.team_id : undefined; + if (typeof teamId === "string") await workspaces.markUninstalled(teamId); + }); + + app.error(async (error) => { + logger.error("Unhandled Slack listener error", { error: error.message }); + }); + + return { + app, + receiver, + logger, + startJobs: () => startReminderJob(reminders, workspaces, env, logger), + }; +} diff --git a/src/blocks/event-card.blocks.ts b/src/blocks/event-card.blocks.ts new file mode 100644 index 0000000..0d32a26 --- /dev/null +++ b/src/blocks/event-card.blocks.ts @@ -0,0 +1,311 @@ +import { EventStatus, EventType, RsvpStatus, type Preference } from "@prisma/client"; +import type { KnownBlock } from "@slack/types"; +import type { EventMetadata, EventWithRelations } from "../types/event.types.js"; +import { slackDate } from "../utils/date.js"; +import { escapeMrkdwn, titleCase, truncate } from "../utils/text.js"; +import { button, contextBlock, divider, mrkdwnSection } from "./shared.blocks.js"; + +const activity: Record< + EventType, + { emoji: string; label: string; preferenceLabel: string; preferenceAction: string } +> = { + LUNCH: { + emoji: "🍱", + label: "Lunch", + preferenceLabel: "Set food preference", + preferenceAction: "open_preference", + }, + DINNER: { + emoji: "🍽️", + label: "Dinner", + preferenceLabel: "Set food preference", + preferenceAction: "open_preference", + }, + POTLUCK: { + emoji: "🥘", + label: "Potluck", + preferenceLabel: "Bring something", + preferenceAction: "open_potluck_item", + }, + WEEKEND_RUN: { + emoji: "🏃", + label: "Weekend run", + preferenceLabel: "Set pace preference", + preferenceAction: "open_preference", + }, + MARATHON: { + emoji: "🏅", + label: "Marathon", + preferenceLabel: "Set running preference", + preferenceAction: "open_preference", + }, + HYROX: { + emoji: "🔥", + label: "HYROX", + preferenceLabel: "Set HYROX preference", + preferenceAction: "open_preference", + }, +}; + +export function renderEventCard(event: EventWithRelations): KnownBlock[] { + const definition = activity[event.type]; + const metadata = readMetadata(event.metadata); + const counts = countRsvps(event); + const value = JSON.stringify({ eventId: event.id }); + const blocks: KnownBlock[] = [ + { + type: "header", + text: { + type: "plain_text", + text: `${definition.emoji} ${truncate(event.title, 140)}`, + emoji: true, + }, + }, + contextBlock([ + `Created by <@${event.organizerSlackUserId}>`, + `${definition.label} · *Status: ${statusLabel(event.status)}*`, + ]), + { + type: "section", + fields: [ + { type: "mrkdwn", text: `*📅 Date & time*\n${slackDate(event.eventStartsAt)}` }, + { + type: "mrkdwn", + text: `*📍 Location*\n${escapeMrkdwn(event.location ?? "To be confirmed")}`, + }, + { + type: "mrkdwn", + text: `*⏰ RSVP by*\n${ + event.rsvpDeadlineAt ? slackDate(event.rsvpDeadlineAt) : "No deadline" + }`, + }, + { + type: "mrkdwn", + text: `*Who’s joining?*\n✅ ${counts.going} · 🤔 ${counts.maybe} · ❌ ${counts.cannot}`, + }, + ], + }, + ]; + + const detail = activityDetail(event.type, metadata, event.preferences, event); + if (detail) blocks.push(mrkdwnSection(detail)); + if (event.description) { + blocks.push( + mrkdwnSection(`*💬 Notes*\n${escapeMrkdwn(truncate(event.description, 700))}`), + ); + } + + if (event.status === EventStatus.CANCELLED) { + blocks.push(divider(), mrkdwnSection("🚫 *This event has been cancelled.*"), { + type: "actions", + elements: [button("View summary", "view_summary", value)], + }); + return blocks; + } + + if (event.status === EventStatus.RSVP_CLOSED) { + blocks.push(divider(), mrkdwnSection("🔒 *RSVPs are closed for this event.*"), { + type: "actions", + elements: [button("View summary", "view_summary", value)], + }); + } else { + blocks.push({ + type: "actions", + block_id: `rsvp_${event.id}`, + elements: [ + button("✅ Going", "rsvp_going", value, "primary"), + button("🤔 Maybe", "rsvp_maybe", value), + button("❌ Can’t make it", "rsvp_cannot", value), + ...(showsPreferenceAction(event.type, metadata) + ? [ + button( + preferenceActionLabel(event.type, metadata, definition.preferenceLabel), + definition.preferenceAction, + value, + ), + ] + : []), + button("View summary", "view_summary", value), + ], + }); + } + + blocks.push(divider(), contextBlock(["Organizer controls"]), { + type: "actions", + block_id: `organizer_${event.id}`, + elements: [ + button("Edit", "edit_event", value), + button("Send reminder", "send_reminder", value), + ...(event.status === EventStatus.OPEN + ? [button("Close RSVP", "close_rsvp", value)] + : []), + button("Cancel", "cancel_event", value, "danger", { + title: "Cancel event?", + text: "The event card will remain visible and pending reminders will stop.", + confirm: "Cancel event", + deny: "Keep event", + }), + ], + }); + return blocks; +} + +function countRsvps(event: EventWithRelations) { + return { + going: event.rsvps.filter((rsvp) => rsvp.status === RsvpStatus.GOING).length, + maybe: event.rsvps.filter((rsvp) => rsvp.status === RsvpStatus.MAYBE).length, + cannot: event.rsvps.filter((rsvp) => rsvp.status === RsvpStatus.CANNOT_MAKE_IT) + .length, + }; +} + +function statusLabel(status: EventStatus): string { + if (status === EventStatus.RSVP_CLOSED) return "RSVP closed"; + return titleCase(status); +} + +function readMetadata(value: unknown): EventMetadata { + if (value && typeof value === "object" && !Array.isArray(value)) { + return value as EventMetadata; + } + return {}; +} + +function activityDetail( + type: EventType, + metadata: EventMetadata, + preferences: Preference[], + event: EventWithRelations, +): string { + const preferenceSummary = summarizePreferences(preferences); + switch (type) { + case EventType.LUNCH: + return compactLines([ + `*🍴 Lunch details*`, + metadata.budget ? `Budget: ${escapeMrkdwn(String(metadata.budget))}` : "", + preferenceSummary, + ]); + case EventType.DINNER: + return compactLines([ + `*🍴 Dinner details*`, + metadata.budget ? `Budget: ${escapeMrkdwn(String(metadata.budget))}` : "", + `Plus-one: ${metadata.plusOneAllowed ? "Allowed" : "Not enabled"}`, + preferenceSummary, + ]); + case EventType.POTLUCK: { + const claimed = event.potluckItems.slice(0, 5).map((item) => { + const by = item.claimedBySlackUserId + ? ` by <@${item.claimedBySlackUserId}>` + : ""; + const detail = [ + item.dietaryType, + item.servesCount ? `serves ${item.servesCount}` : "", + ] + .filter(Boolean) + .join(" · "); + return `• *${escapeMrkdwn(item.category)}:* ${escapeMrkdwn(item.itemName)}${by}${detail ? ` _(${escapeMrkdwn(detail)})_` : ""}`; + }); + const needed = Array.isArray(metadata.neededCategories) + ? metadata.neededCategories.filter( + (category) => + !event.potluckItems.some((item) => item.category === category), + ) + : []; + return compactLines([ + "*🥘 Potluck plan*", + claimed.length ? `*Claimed*\n${claimed.join("\n")}` : "No items claimed yet.", + needed.length + ? `*Still needed:* ${needed.map(escapeMrkdwn).join(", ")}` + : "All requested categories have a contribution.", + event.potluckItems.length > 5 + ? "_More items are available in the summary._" + : "", + ]); + } + case EventType.WEEKEND_RUN: + return compactLines([ + "*🏃 Run details*", + `Distance: ${display(metadata.distance, "Flexible")} · Pace: ${display(metadata.pace, "Mixed groups")}`, + `Beginner-friendly: ${yesNo(metadata.beginnerFriendly)}`, + metadata.routeLink ? `Route: ${display(metadata.routeLink, "")}` : "", + preferenceSummary, + ]); + case EventType.MARATHON: + return compactLines([ + "*🏅 Marathon plan*", + metadata.raceName ? `Race: ${escapeMrkdwn(String(metadata.raceName))}` : "", + `Distance: ${display(metadata.distance, "Flexible")}${ + metadata.targetPace + ? ` · Target pace: ${display(metadata.targetPace, "")}` + : "" + }`, + `Beginner group: ${yesNo(metadata.beginnerFriendly)}`, + preferenceSummary, + ]); + case EventType.HYROX: + return compactLines([ + "*🔥 HYROX details*", + `Workout: ${display(metadata.workoutType, "Full prep")} · Intensity: ${display(metadata.intensity, "Mixed")}`, + `Partner needed: ${yesNo(metadata.partnerNeeded)} · Beginner-friendly: ${yesNo(metadata.beginnerFriendly)}`, + preferenceSummary, + ]); + } +} + +function summarizePreferences(preferences: Preference[]): string { + if (!preferences.length) return ""; + const counts = new Map(); + for (const preference of preferences) { + const label = + preference.key === "plusOne" + ? `Plus-one ${preference.value}` + : preference.key === "targetPace" + ? `Pace ${preference.value}` + : preference.value; + counts.set(label, (counts.get(label) ?? 0) + 1); + } + const summary = [...counts.entries()] + .slice(0, 4) + .map(([value, count]) => `${escapeMrkdwn(value)} ${count}`) + .join(" · "); + return summary ? `*Preferences:* ${summary}` : ""; +} + +function compactLines(lines: string[]): string { + return lines.filter(Boolean).join("\n"); +} + +function yesNo(value: EventMetadata[string] | undefined): string { + return value === true || value === "Yes" ? "Yes" : "No"; +} + +function display(value: EventMetadata[string] | undefined, fallback: string): string { + if (value === undefined || value === null || value === "") return fallback; + if (Array.isArray(value)) return value.join(", "); + return String(value); +} + +function showsPreferenceAction(type: EventType, metadata: EventMetadata): boolean { + if (type === EventType.LUNCH) return metadata.foodPreferencesEnabled !== false; + if (type === EventType.DINNER) { + return ( + metadata.foodPreferencesEnabled !== false || metadata.plusOneAllowed === true + ); + } + return true; +} + +function preferenceActionLabel( + type: EventType, + metadata: EventMetadata, + fallback: string, +): string { + if ( + type === EventType.DINNER && + metadata.foodPreferencesEnabled === false && + metadata.plusOneAllowed === true + ) { + return "Set plus-one"; + } + return fallback; +} diff --git a/src/blocks/shared.blocks.ts b/src/blocks/shared.blocks.ts new file mode 100644 index 0000000..98b6813 --- /dev/null +++ b/src/blocks/shared.blocks.ts @@ -0,0 +1,50 @@ +import type { KnownBlock } from "@slack/types"; + +export function divider(): KnownBlock { + return { type: "divider" }; +} + +export function mrkdwnSection(text: string): KnownBlock { + return { + type: "section", + text: { type: "mrkdwn", text }, + }; +} + +export function contextBlock(elements: string[]): KnownBlock { + return { + type: "context", + elements: elements.map((text) => ({ type: "mrkdwn", text })), + }; +} + +export function button( + text: string, + actionId: string, + value: string, + style?: "primary" | "danger", + confirm?: { + title: string; + text: string; + confirm: string; + deny: string; + }, +) { + return { + type: "button" as const, + text: { type: "plain_text" as const, text, emoji: true }, + action_id: actionId, + value, + ...(style ? { style } : {}), + ...(confirm + ? { + confirm: { + title: { type: "plain_text" as const, text: confirm.title }, + text: { type: "mrkdwn" as const, text: confirm.text }, + confirm: { type: "plain_text" as const, text: confirm.confirm }, + deny: { type: "plain_text" as const, text: confirm.deny }, + }, + } + : {}), + }; +} diff --git a/src/blocks/summary.blocks.ts b/src/blocks/summary.blocks.ts new file mode 100644 index 0000000..87cf133 --- /dev/null +++ b/src/blocks/summary.blocks.ts @@ -0,0 +1,50 @@ +import { RsvpStatus } from "@prisma/client"; +import type { KnownBlock } from "@slack/types"; +import type { EventWithRelations } from "../types/event.types.js"; +import { escapeMrkdwn } from "../utils/text.js"; +import { divider, mrkdwnSection } from "./shared.blocks.js"; + +export function renderSummary(event: EventWithRelations): KnownBlock[] { + const byStatus = (status: RsvpStatus) => + event.rsvps + .filter((rsvp) => rsvp.status === status) + .map((rsvp) => `<@${rsvp.slackUserId}>`); + const going = byStatus(RsvpStatus.GOING); + const maybe = byStatus(RsvpStatus.MAYBE); + const cannot = byStatus(RsvpStatus.CANNOT_MAKE_IT); + + const preferenceRows = event.preferences.map( + (preference) => + `• <@${preference.slackUserId}> — ${ + preference.key === "primary" + ? escapeMrkdwn(preference.value) + : `${escapeMrkdwn(preference.key)}: ${escapeMrkdwn(preference.value)}` + }`, + ); + const potluckRows = event.potluckItems.map((item) => { + const owner = item.claimedBySlackUserId + ? `<@${item.claimedBySlackUserId}>` + : "Unclaimed"; + return `• *${escapeMrkdwn(item.category)}:* ${escapeMrkdwn(item.itemName)} — ${owner}`; + }); + + return [ + { + type: "header", + text: { type: "plain_text", text: `Event summary · ${event.title}`, emoji: true }, + }, + mrkdwnSection( + [ + `*✅ Going (${going.length})*\n${going.join(", ") || "No responses"}`, + `*🤔 Maybe (${maybe.length})*\n${maybe.join(", ") || "No responses"}`, + `*❌ Can’t make it (${cannot.length})*\n${cannot.join(", ") || "No responses"}`, + ].join("\n\n"), + ), + ...(preferenceRows.length + ? [divider(), mrkdwnSection(`*Preferences*\n${preferenceRows.join("\n")}`)] + : []), + ...(potluckRows.length + ? [divider(), mrkdwnSection(`*Potluck items*\n${potluckRows.join("\n")}`)] + : []), + ]; +} diff --git a/src/commands/dinner.command.ts b/src/commands/dinner.command.ts new file mode 100644 index 0000000..9ddc024 --- /dev/null +++ b/src/commands/dinner.command.ts @@ -0,0 +1,7 @@ +import { EventType } from "@prisma/client"; +import type { App } from "@slack/bolt"; +import { buildDinnerModal } from "../modals/dinner.modal.js"; +import { registerActivityCommand } from "./shared.command.js"; + +export const registerDinnerCommand = (app: App): void => + registerActivityCommand(app, "/dinner", EventType.DINNER, buildDinnerModal); diff --git a/src/commands/hyrox.command.ts b/src/commands/hyrox.command.ts new file mode 100644 index 0000000..14210bc --- /dev/null +++ b/src/commands/hyrox.command.ts @@ -0,0 +1,7 @@ +import { EventType } from "@prisma/client"; +import type { App } from "@slack/bolt"; +import { buildHyroxModal } from "../modals/hyrox.modal.js"; +import { registerActivityCommand } from "./shared.command.js"; + +export const registerHyroxCommand = (app: App): void => + registerActivityCommand(app, "/hyrox", EventType.HYROX, buildHyroxModal); diff --git a/src/commands/lunch.command.ts b/src/commands/lunch.command.ts new file mode 100644 index 0000000..d51c4a0 --- /dev/null +++ b/src/commands/lunch.command.ts @@ -0,0 +1,7 @@ +import { EventType } from "@prisma/client"; +import type { App } from "@slack/bolt"; +import { buildLunchModal } from "../modals/lunch.modal.js"; +import { registerActivityCommand } from "./shared.command.js"; + +export const registerLunchCommand = (app: App): void => + registerActivityCommand(app, "/lunch", EventType.LUNCH, buildLunchModal); diff --git a/src/commands/marathon.command.ts b/src/commands/marathon.command.ts new file mode 100644 index 0000000..98c1b12 --- /dev/null +++ b/src/commands/marathon.command.ts @@ -0,0 +1,7 @@ +import { EventType } from "@prisma/client"; +import type { App } from "@slack/bolt"; +import { buildMarathonModal } from "../modals/marathon.modal.js"; +import { registerActivityCommand } from "./shared.command.js"; + +export const registerMarathonCommand = (app: App): void => + registerActivityCommand(app, "/marathon", EventType.MARATHON, buildMarathonModal); diff --git a/src/commands/potluck.command.ts b/src/commands/potluck.command.ts new file mode 100644 index 0000000..6a345ee --- /dev/null +++ b/src/commands/potluck.command.ts @@ -0,0 +1,7 @@ +import { EventType } from "@prisma/client"; +import type { App } from "@slack/bolt"; +import { buildPotluckModal } from "../modals/potluck.modal.js"; +import { registerActivityCommand } from "./shared.command.js"; + +export const registerPotluckCommand = (app: App): void => + registerActivityCommand(app, "/potluck", EventType.POTLUCK, buildPotluckModal); diff --git a/src/commands/shared.command.ts b/src/commands/shared.command.ts new file mode 100644 index 0000000..0033891 --- /dev/null +++ b/src/commands/shared.command.ts @@ -0,0 +1,29 @@ +import type { App } from "@slack/bolt"; +import type { EventType } from "@prisma/client"; +import type { ModalView } from "@slack/types"; +import type { CommandMetadata } from "../types/slack.types.js"; + +type ModalBuilder = (metadata: CommandMetadata) => ModalView; + +export function registerActivityCommand( + app: App, + command: string, + eventType: EventType, + buildModal: ModalBuilder, +): void { + app.command(command, async ({ ack, body, client }) => { + await ack(); + const metadata: CommandMetadata = { + channelId: body.channel_id, + channelName: body.channel_name, + userId: body.user_id, + teamId: body.team_id, + teamName: body.team_domain, + eventType, + }; + await client.views.open({ + trigger_id: body.trigger_id, + view: buildModal(metadata), + }); + }); +} diff --git a/src/commands/weekendrun.command.ts b/src/commands/weekendrun.command.ts new file mode 100644 index 0000000..77731a9 --- /dev/null +++ b/src/commands/weekendrun.command.ts @@ -0,0 +1,12 @@ +import { EventType } from "@prisma/client"; +import type { App } from "@slack/bolt"; +import { buildWeekendRunModal } from "../modals/weekendrun.modal.js"; +import { registerActivityCommand } from "./shared.command.js"; + +export const registerWeekendRunCommand = (app: App): void => + registerActivityCommand( + app, + "/weekendrun", + EventType.WEEKEND_RUN, + buildWeekendRunModal, + ); diff --git a/src/config/env.ts b/src/config/env.ts new file mode 100644 index 0000000..34e4d0f --- /dev/null +++ b/src/config/env.ts @@ -0,0 +1,84 @@ +import "dotenv/config"; +import { z } from "zod"; + +const optionalSecret = z.preprocess( + (value) => (typeof value === "string" && value.trim() === "" ? undefined : value), + z.string().trim().min(1).optional(), +); + +const envSchema = z + .object({ + NODE_ENV: z.enum(["development", "test", "production"]).default("development"), + PORT: z.coerce.number().int().positive().default(3000), + LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"), + DATABASE_URL: z.string().min(1), + APP_BASE_URL: z.string().url().default("http://localhost:3000"), + DEFAULT_TIMEZONE: z.string().default("UTC"), + SLACK_SIGNING_SECRET: z.string().min(1), + SLACK_BOT_TOKEN: optionalSecret, + SLACK_CLIENT_ID: optionalSecret, + SLACK_CLIENT_SECRET: optionalSecret, + SLACK_STATE_SECRET: optionalSecret, + TOKEN_ENCRYPTION_KEY: optionalSecret, + }) + .superRefine((value, context) => { + if ( + !value.SLACK_BOT_TOKEN && + (!value.SLACK_CLIENT_ID || !value.SLACK_CLIENT_SECRET) + ) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["SLACK_BOT_TOKEN"], + message: + "Set SLACK_BOT_TOKEN for local development or configure SLACK_CLIENT_ID and SLACK_CLIENT_SECRET for OAuth.", + }); + } + + if ( + value.SLACK_CLIENT_ID && + value.SLACK_CLIENT_SECRET && + (!value.SLACK_STATE_SECRET || value.SLACK_STATE_SECRET.length < 32) + ) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["SLACK_STATE_SECRET"], + message: "SLACK_STATE_SECRET must be at least 32 characters for OAuth.", + }); + } + if (value.SLACK_BOT_TOKEN && value.SLACK_CLIENT_ID) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["SLACK_BOT_TOKEN"], + message: "Use either local bot-token mode or OAuth mode, not both.", + }); + } + if (value.SLACK_CLIENT_ID && !isEncryptionKey(value.TOKEN_ENCRYPTION_KEY)) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["TOKEN_ENCRYPTION_KEY"], + message: "OAuth mode requires a base64-encoded 32-byte encryption key.", + }); + } + }); + +export type Env = z.infer; + +export function loadEnv(source: NodeJS.ProcessEnv = process.env): Env { + const result = envSchema.safeParse(source); + if (!result.success) { + const details = result.error.issues + .map((issue) => `${issue.path.join(".")}: ${issue.message}`) + .join("\n"); + throw new Error(`Invalid environment configuration:\n${details}`); + } + return result.data; +} + +function isEncryptionKey(value: string | undefined): boolean { + if (!value) return false; + try { + return Buffer.from(value, "base64").length === 32; + } catch { + return false; + } +} diff --git a/src/config/logger.ts b/src/config/logger.ts new file mode 100644 index 0000000..4b27d7f --- /dev/null +++ b/src/config/logger.ts @@ -0,0 +1,52 @@ +type LogLevel = "debug" | "info" | "warn" | "error"; +type Context = Record; + +const priorities: Record = { + debug: 10, + info: 20, + warn: 30, + error: 40, +}; + +export class Logger { + constructor(private readonly level: LogLevel = "info") {} + + debug(message: string, context: Context = {}): void { + this.write("debug", message, context); + } + + info(message: string, context: Context = {}): void { + this.write("info", message, context); + } + + warn(message: string, context: Context = {}): void { + this.write("warn", message, context); + } + + error(message: string, context: Context = {}): void { + this.write("error", message, context); + } + + private write(level: LogLevel, message: string, context: Context): void { + if (priorities[level] < priorities[this.level]) return; + const payload = JSON.stringify({ + time: new Date().toISOString(), + level, + message, + ...sanitize(context), + }); + if (level === "error") console.error(payload); + else if (level === "warn") console.warn(payload); + else console.log(payload); + } +} + +function sanitize(context: Context): Context { + const blocked = new Set(["token", "accessToken", "clientSecret", "signingSecret"]); + return Object.fromEntries( + Object.entries(context).map(([key, value]) => [ + key, + blocked.has(key) ? "[REDACTED]" : value, + ]), + ); +} diff --git a/src/config/slack.ts b/src/config/slack.ts new file mode 100644 index 0000000..5d1b9c3 --- /dev/null +++ b/src/config/slack.ts @@ -0,0 +1,7 @@ +export const SLACK_SCOPES = ["commands", "chat:write", "chat:write.public"] as const; + +export const SLACK_ENDPOINTS = { + events: "/slack/events", + install: "/slack/install", + oauthRedirect: "/slack/oauth_redirect", +} as const; diff --git a/src/db/prisma.ts b/src/db/prisma.ts new file mode 100644 index 0000000..ce3a843 --- /dev/null +++ b/src/db/prisma.ts @@ -0,0 +1,11 @@ +import { PrismaClient } from "@prisma/client"; + +declare global { + var __teamloopPrisma: PrismaClient | undefined; +} + +export const prisma = globalThis.__teamloopPrisma ?? new PrismaClient(); + +if (process.env.NODE_ENV !== "production") { + globalThis.__teamloopPrisma = prisma; +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..832890e --- /dev/null +++ b/src/index.ts @@ -0,0 +1,27 @@ +import { createTeamLoopApp } from "./app.js"; +import { loadEnv } from "./config/env.js"; +import { prisma } from "./db/prisma.js"; + +const env = loadEnv(); +const runtime = createTeamLoopApp(env); +const reminderJob = runtime.startJobs(); + +await runtime.app.start(env.PORT); +runtime.logger.info("TeamLoop is running", { + port: env.PORT, + oauth: Boolean(env.SLACK_CLIENT_ID), +}); + +async function shutdown(signal: string): Promise { + runtime.logger.info("Shutting down TeamLoop", { signal }); + await reminderJob.stop(); + await runtime.receiver.stop(); + await prisma.$disconnect(); +} + +process.once("SIGINT", () => { + void shutdown("SIGINT").catch(console.error); +}); +process.once("SIGTERM", () => { + void shutdown("SIGTERM").catch(console.error); +}); diff --git a/src/interactions/event-submissions.ts b/src/interactions/event-submissions.ts new file mode 100644 index 0000000..ce1667f --- /dev/null +++ b/src/interactions/event-submissions.ts @@ -0,0 +1,171 @@ +import { EventType } from "@prisma/client"; +import type { App } from "@slack/bolt"; +import { dinnerModalConfig } from "../modals/dinner.modal.js"; +import { hyroxModalConfig } from "../modals/hyrox.modal.js"; +import { lunchModalConfig } from "../modals/lunch.modal.js"; +import { marathonModalConfig } from "../modals/marathon.modal.js"; +import { potluckModalConfig } from "../modals/potluck.modal.js"; +import { + extractActivityValues, + type ActivityModalConfig, +} from "../modals/shared.modal.js"; +import { weekendRunModalConfig } from "../modals/weekendrun.modal.js"; +import { EventService } from "../services/event.service.js"; +import { ReminderService } from "../services/reminder.service.js"; +import { SlackMessageService } from "../services/slack-message.service.js"; +import type { EventMetadata } from "../types/event.types.js"; +import type { CommandMetadata } from "../types/slack.types.js"; +import { + eventInputSchema, + formatZodErrors, + type ValidatedEventInput, +} from "../utils/validation.js"; + +const configs: Array<{ type: EventType; config: ActivityModalConfig }> = [ + { type: EventType.LUNCH, config: lunchModalConfig }, + { type: EventType.DINNER, config: dinnerModalConfig }, + { type: EventType.POTLUCK, config: potluckModalConfig }, + { type: EventType.WEEKEND_RUN, config: weekendRunModalConfig }, + { type: EventType.MARATHON, config: marathonModalConfig }, + { type: EventType.HYROX, config: hyroxModalConfig }, +]; + +export function registerEventSubmissionHandlers( + app: App, + eventService: EventService, + reminderService: ReminderService, + messages: SlackMessageService, +): void { + for (const { type, config } of configs) { + app.view(config.callbackId, async ({ ack, body, view, client, logger }) => { + const raw = extractActivityValues(view.state.values, config.fields); + const parsed = eventInputSchema.safeParse(raw); + const customErrors = activityErrors(type, raw); + if (!parsed.success || Object.keys(customErrors).length) { + await ack({ + response_action: "errors", + errors: { + ...(parsed.success ? {} : formatZodErrors(parsed.error)), + ...customErrors, + }, + }); + return; + } + + await ack(); + try { + const command = JSON.parse(view.private_metadata) as CommandMetadata; + const event = await eventService.create({ + slackTeamId: command.teamId, + ...(command.teamName ? { slackTeamName: command.teamName } : {}), + slackChannelId: command.channelId, + ...(command.channelName ? { slackChannelName: command.channelName } : {}), + organizerSlackUserId: body.user.id, + type, + title: parsed.data.title, + ...(parsed.data.notes ? { description: parsed.data.notes } : {}), + location: parsed.data.location, + eventStartsAt: parsed.data.eventStartsAt, + rsvpDeadlineAt: parsed.data.rsvpDeadlineAt, + metadata: buildMetadata(type, raw), + }); + const posted = await messages.postEvent(client, event); + if (!posted.ts) throw new Error("Slack did not return a message timestamp."); + await eventService.setSlackMessage(event.id, command.channelId, posted.ts); + try { + await reminderService.createDefaults({ + eventId: event.id, + eventStartsAt: event.eventStartsAt, + ...(event.rsvpDeadlineAt ? { rsvpDeadlineAt: event.rsvpDeadlineAt } : {}), + ...(parsed.data.reminderMinutes !== undefined + ? { eventReminderMinutes: parsed.data.reminderMinutes } + : {}), + }); + } catch (error) { + logger.warn("Event created, but default reminders could not be scheduled", { + eventId: event.id, + error, + }); + } + } catch (error) { + logger.error("Failed to create TeamLoop event", error); + const metadata = JSON.parse(view.private_metadata) as CommandMetadata; + await messages.ephemeral( + client, + metadata.channelId, + body.user.id, + "Something went wrong while creating the event. Please try again.", + ); + } + }); + } +} + +function activityErrors( + type: EventType, + raw: Record, +): Record { + if ( + (type === EventType.WEEKEND_RUN || type === EventType.MARATHON) && + raw.distance === "Custom" && + !raw.customDistance?.trim() + ) { + return { customDistance: "Enter a custom distance." }; + } + return {}; +} + +function buildMetadata(type: EventType, raw: Record): EventMetadata { + const value = (key: string) => raw[key]?.trim() ?? ""; + const yes = (key: string) => value(key) === "Yes"; + switch (type) { + case EventType.LUNCH: + return { + budget: value("budget"), + foodPreferencesEnabled: yes("foodPreferencesEnabled"), + }; + case EventType.DINNER: + return { + budget: value("budget"), + plusOneAllowed: yes("plusOneAllowed"), + foodPreferencesEnabled: yes("foodPreferencesEnabled"), + }; + case EventType.POTLUCK: + return { + neededCategories: value("neededCategories") + .split(",") + .map((item) => item.trim()) + .filter(Boolean), + }; + case EventType.WEEKEND_RUN: + return { + distance: + value("distance") === "Custom" ? value("customDistance") : value("distance"), + pace: value("pace"), + routeLink: value("routeLink"), + beginnerFriendly: yes("beginnerFriendly"), + }; + case EventType.MARATHON: + return { + raceName: value("raceName"), + raceDate: value("raceDate"), + distance: + value("distance") === "Custom" ? value("customDistance") : value("distance"), + targetPace: value("targetPace"), + beginnerFriendly: yes("beginnerFriendly"), + }; + case EventType.HYROX: + return { + workoutType: value("workoutType"), + partnerNeeded: yes("partnerNeeded"), + beginnerFriendly: yes("beginnerFriendly"), + intensity: value("intensity"), + }; + } +} + +export function validatedInputForTest( + input: Record, +): ValidatedEventInput { + return eventInputSchema.parse(input); +} diff --git a/src/interactions/fitness.actions.ts b/src/interactions/fitness.actions.ts new file mode 100644 index 0000000..68d9e5d --- /dev/null +++ b/src/interactions/fitness.actions.ts @@ -0,0 +1,9 @@ +import { EventType } from "@prisma/client"; + +export function isFitnessEvent(type: EventType): boolean { + return ( + type === EventType.WEEKEND_RUN || + type === EventType.MARATHON || + type === EventType.HYROX + ); +} diff --git a/src/interactions/helpers.ts b/src/interactions/helpers.ts new file mode 100644 index 0000000..784dbe6 --- /dev/null +++ b/src/interactions/helpers.ts @@ -0,0 +1,52 @@ +import type { SlackViewValues } from "../types/slack.types.js"; + +export function parseActionValue(action: unknown): { eventId: string; value?: string } { + if ( + !action || + typeof action !== "object" || + !("value" in action) || + typeof action.value !== "string" + ) { + throw new Error("Missing action value"); + } + return JSON.parse(action.value) as { eventId: string; value?: string }; +} + +export function modalValue( + values: SlackViewValues, + blockId: string, + actionId = "value", +): string { + const state = values[blockId]?.[actionId]; + if (!state) return ""; + if ("value" in state && typeof state.value === "string") return state.value; + if ( + "selected_option" in state && + state.selected_option && + "value" in state.selected_option + ) { + return state.selected_option.value; + } + if ("selected_date" in state && typeof state.selected_date === "string") { + return state.selected_date; + } + if ("selected_time" in state && typeof state.selected_time === "string") { + return state.selected_time; + } + return ""; +} + +export function channelIdFromBody(body: unknown): string { + if ( + body && + typeof body === "object" && + "channel" in body && + body.channel && + typeof body.channel === "object" && + "id" in body.channel && + typeof body.channel.id === "string" + ) { + return body.channel.id; + } + throw new Error("Missing Slack channel"); +} diff --git a/src/interactions/organizer.actions.ts b/src/interactions/organizer.actions.ts new file mode 100644 index 0000000..cdda94a --- /dev/null +++ b/src/interactions/organizer.actions.ts @@ -0,0 +1,133 @@ +import { ReminderType } from "@prisma/client"; +import type { App } from "@slack/bolt"; +import { renderSummary } from "../blocks/summary.blocks.js"; +import { buildEditEventModal } from "../modals/edit-event.modal.js"; +import { EventService } from "../services/event.service.js"; +import { PermissionService } from "../services/permission.service.js"; +import { ReminderService } from "../services/reminder.service.js"; +import { SlackMessageService } from "../services/slack-message.service.js"; +import { slackTime } from "../utils/date.js"; +import { toUserMessage } from "../utils/errors.js"; +import { eventInputSchema, formatZodErrors } from "../utils/validation.js"; +import { extractActivityValues } from "../modals/shared.modal.js"; +import { parseActionValue } from "./helpers.js"; + +export function registerOrganizerActions( + app: App, + events: EventService, + reminders: ReminderService, + messages: SlackMessageService, +): void { + const permissions = new PermissionService(); + + app.action("view_summary", async ({ ack, action, body, client, respond }) => { + await ack(); + try { + const event = await events.getById(parseActionValue(action).eventId); + await client.chat.postEphemeral({ + channel: event.slackChannelId, + user: body.user.id, + text: `Summary for ${event.title}`, + blocks: renderSummary(event), + }); + } catch (error) { + await respond({ response_type: "ephemeral", text: toUserMessage(error) }); + } + }); + + app.action("edit_event", async ({ ack, action, body, client, respond }) => { + await ack(); + try { + const event = await events.getById(parseActionValue(action).eventId); + permissions.assertOrganizer(event, body.user.id); + if (!("trigger_id" in body) || typeof body.trigger_id !== "string") { + throw new Error("Missing trigger ID"); + } + await client.views.open({ + trigger_id: body.trigger_id, + view: buildEditEventModal(event), + }); + } catch (error) { + await respond({ response_type: "ephemeral", text: toUserMessage(error) }); + } + }); + + app.view("edit_event_submission", async ({ ack, body, view, client }) => { + const raw = extractActivityValues(view.state.values, []); + const parsed = eventInputSchema.safeParse(raw); + if (!parsed.success) { + await ack({ + response_action: "errors", + errors: formatZodErrors(parsed.error), + }); + return; + } + await ack(); + const { eventId } = JSON.parse(view.private_metadata) as { eventId: string }; + const event = await events.update(eventId, body.user.id, { + title: parsed.data.title, + location: parsed.data.location, + description: parsed.data.notes || null, + eventStartsAt: parsed.data.eventStartsAt, + rsvpDeadlineAt: parsed.data.rsvpDeadlineAt, + }); + await messages.updateEvent(client, event); + }); + + app.action("close_rsvp", async ({ ack, action, body, client, respond }) => { + await ack(); + try { + const event = await events.closeRsvp( + parseActionValue(action).eventId, + body.user.id, + ); + await messages.updateEvent(client, event); + await respond({ response_type: "ephemeral", text: "RSVPs are now closed." }); + } catch (error) { + await respond({ response_type: "ephemeral", text: toUserMessage(error) }); + } + }); + + app.action("cancel_event", async ({ ack, action, body, client, respond }) => { + await ack(); + try { + const event = await events.cancel(parseActionValue(action).eventId, body.user.id); + await messages.updateEvent(client, event); + await respond({ + response_type: "ephemeral", + text: "The event has been cancelled.", + }); + } catch (error) { + await respond({ response_type: "ephemeral", text: toUserMessage(error) }); + } + }); + + app.action("send_reminder", async ({ ack, action, body, client, respond }) => { + await ack(); + try { + const event = await events.getById(parseActionValue(action).eventId); + permissions.assertOrganizer(event, body.user.id); + const reminder = await reminders.createManual(event.id); + try { + await client.chat.postMessage({ + channel: event.slackChannelId, + ...(event.slackMessageTs ? { thread_ts: event.slackMessageTs } : {}), + text: `🔔 Reminder from <@${body.user.id}>: Please update your RSVP for *${event.title}*. The event starts ${slackTime(event.eventStartsAt)}.`, + }); + await reminders.markSent(reminder.id); + } catch (error) { + await reminders.markFailed(reminder.id, error); + throw error; + } + await respond({ response_type: "ephemeral", text: "Reminder sent." }); + } catch (error) { + await respond({ response_type: "ephemeral", text: toUserMessage(error) }); + } + }); +} + +export function reminderLabel(type: ReminderType): string { + if (type === ReminderType.RSVP_DEADLINE) return "RSVP deadline"; + if (type === ReminderType.EVENT_START) return "Event start"; + return "Manual"; +} diff --git a/src/interactions/potluck.actions.ts b/src/interactions/potluck.actions.ts new file mode 100644 index 0000000..446732f --- /dev/null +++ b/src/interactions/potluck.actions.ts @@ -0,0 +1,68 @@ +import type { App } from "@slack/bolt"; +import { buildPotluckItemModal } from "../modals/potluck-item.modal.js"; +import { EventService } from "../services/event.service.js"; +import { PotluckService } from "../services/potluck.service.js"; +import { SlackMessageService } from "../services/slack-message.service.js"; +import { toUserMessage } from "../utils/errors.js"; +import { modalValue, parseActionValue } from "./helpers.js"; + +export function registerPotluckActions( + app: App, + events: EventService, + potluck: PotluckService, + messages: SlackMessageService, +): void { + app.action("open_potluck_item", async ({ ack, action, body, client, respond }) => { + await ack(); + try { + const { eventId } = parseActionValue(action); + const event = await events.getById(eventId); + if (!("trigger_id" in body) || typeof body.trigger_id !== "string") { + throw new Error("Missing trigger ID"); + } + await client.views.open({ + trigger_id: body.trigger_id, + view: buildPotluckItemModal(event), + }); + } catch (error) { + await respond({ response_type: "ephemeral", text: toUserMessage(error) }); + } + }); + + app.view("save_potluck_item", async ({ ack, body, view, client }) => { + const values = view.state.values; + const itemName = modalValue(values, "itemName").trim(); + const servesRaw = modalValue(values, "servesCount").trim(); + const servesCount = servesRaw ? Number.parseInt(servesRaw, 10) : undefined; + const errors: Record = {}; + if (!itemName) errors.itemName = "Enter a dish or item name."; + if (servesRaw && (!servesCount || servesCount < 1 || servesCount > 10_000)) { + errors.servesCount = "Enter a positive number."; + } + if (Object.keys(errors).length) { + await ack({ response_action: "errors", errors }); + return; + } + await ack(); + const { eventId } = JSON.parse(view.private_metadata) as { eventId: string }; + await potluck.addItem(eventId, body.user.id, { + category: modalValue(values, "category"), + itemName, + ...(servesCount ? { servesCount } : {}), + ...(modalValue(values, "dietaryType") + ? { dietaryType: modalValue(values, "dietaryType") } + : {}), + ...(modalValue(values, "itemNotes") + ? { notes: modalValue(values, "itemNotes") } + : {}), + }); + const event = await events.getById(eventId); + await messages.updateEvent(client, event); + await messages.ephemeral( + client, + event.slackChannelId, + body.user.id, + "Added your potluck item.", + ); + }); +} diff --git a/src/interactions/preference.actions.ts b/src/interactions/preference.actions.ts new file mode 100644 index 0000000..a16a8c4 --- /dev/null +++ b/src/interactions/preference.actions.ts @@ -0,0 +1,63 @@ +import type { App } from "@slack/bolt"; +import { buildPreferenceModal } from "../modals/preference.modal.js"; +import { EventService } from "../services/event.service.js"; +import { PreferenceService } from "../services/preference.service.js"; +import { SlackMessageService } from "../services/slack-message.service.js"; +import { toUserMessage } from "../utils/errors.js"; +import { modalValue, parseActionValue } from "./helpers.js"; + +export function registerPreferenceActions( + app: App, + events: EventService, + preferences: PreferenceService, + messages: SlackMessageService, +): void { + app.action("open_preference", async ({ ack, action, body, client, respond }) => { + await ack(); + try { + const { eventId } = parseActionValue(action); + const event = await events.getById(eventId); + if (!("trigger_id" in body) || typeof body.trigger_id !== "string") { + throw new Error("Missing trigger ID"); + } + await client.views.open({ + trigger_id: body.trigger_id, + view: buildPreferenceModal(event), + }); + } catch (error) { + await respond({ response_type: "ephemeral", text: toUserMessage(error) }); + } + }); + + app.view("save_preference", async ({ ack, body, view, client }) => { + const values = view.state.values; + const preference = modalValue(values, "preference"); + const metadata = JSON.parse(view.private_metadata) as { + eventId: string; + primaryRequired?: boolean; + }; + if (metadata.primaryRequired !== false && !preference) { + await ack({ + response_action: "errors", + errors: { preference: "Choose a preference." }, + }); + return; + } + await ack(); + const { eventId } = metadata; + if (preference) { + await preferences.upsert(eventId, body.user.id, "primary", preference); + } + const plusOne = modalValue(values, "plusOne"); + if (plusOne) { + await preferences.upsert(eventId, body.user.id, "plusOne", plusOne); + } + const targetPace = modalValue(values, "targetPaceNote"); + if (targetPace) { + await preferences.upsert(eventId, body.user.id, "targetPace", targetPace); + } + const event = await events.getById(eventId); + await messages.updateEvent(client, event); + await messages.ephemeral(client, event.slackChannelId, body.user.id, "Saved."); + }); +} diff --git a/src/interactions/rsvp.actions.ts b/src/interactions/rsvp.actions.ts new file mode 100644 index 0000000..1951cb8 --- /dev/null +++ b/src/interactions/rsvp.actions.ts @@ -0,0 +1,47 @@ +import { RsvpStatus } from "@prisma/client"; +import type { App } from "@slack/bolt"; +import { EventService } from "../services/event.service.js"; +import { RsvpService } from "../services/rsvp.service.js"; +import { SlackMessageService } from "../services/slack-message.service.js"; +import { toUserMessage } from "../utils/errors.js"; +import { parseActionValue } from "./helpers.js"; + +const actions = [ + { + actionId: "rsvp_going", + status: RsvpStatus.GOING, + confirmation: "You’re marked as going.", + }, + { + actionId: "rsvp_maybe", + status: RsvpStatus.MAYBE, + confirmation: "You’re marked as maybe.", + }, + { + actionId: "rsvp_cannot", + status: RsvpStatus.CANNOT_MAKE_IT, + confirmation: "You’re marked as unable to make it.", + }, +] as const; + +export function registerRsvpActions( + app: App, + rsvpService: RsvpService, + eventService: EventService, + messages: SlackMessageService, +): void { + for (const definition of actions) { + app.action(definition.actionId, async ({ ack, action, body, client, respond }) => { + await ack(); + try { + const { eventId } = parseActionValue(action); + await rsvpService.upsert(eventId, body.user.id, definition.status); + const event = await eventService.getById(eventId); + await messages.updateEvent(client, event); + await respond({ response_type: "ephemeral", text: definition.confirmation }); + } catch (error) { + await respond({ response_type: "ephemeral", text: toUserMessage(error) }); + } + }); + } +} diff --git a/src/jobs/reminder.job.ts b/src/jobs/reminder.job.ts new file mode 100644 index 0000000..7878bb6 --- /dev/null +++ b/src/jobs/reminder.job.ts @@ -0,0 +1,63 @@ +import { ReminderType } from "@prisma/client"; +import { WebClient } from "@slack/web-api"; +import cron, { type ScheduledTask } from "node-cron"; +import type { Env } from "../config/env.js"; +import type { Logger } from "../config/logger.js"; +import { ReminderService } from "../services/reminder.service.js"; +import { WorkspaceService } from "../services/workspace.service.js"; +import { slackTime } from "../utils/date.js"; + +export function startReminderJob( + reminders: ReminderService, + workspaces: WorkspaceService, + env: Env, + logger: Logger, +): ScheduledTask { + return cron.schedule( + "* * * * *", + async () => { + const due = await reminders.due(); + for (const reminder of due) { + if (!(await reminders.claim(reminder.id))) continue; + try { + const token = + env.SLACK_BOT_TOKEN ?? + (await workspaces.getBotTokenByWorkspaceId(reminder.event.workspaceId)); + if (!token) throw new Error("No Slack bot token for reminder workspace."); + const client = new WebClient(token); + await client.chat.postMessage({ + channel: reminder.event.slackChannelId, + ...(reminder.event.slackMessageTs + ? { thread_ts: reminder.event.slackMessageTs } + : {}), + text: reminderText(reminder.type, reminder.event), + }); + await reminders.markSent(reminder.id); + } catch (error) { + logger.error("Reminder delivery failed", { + reminderId: reminder.id, + eventId: reminder.eventId, + error: error instanceof Error ? error.message : String(error), + }); + await reminders.markFailed(reminder.id, error); + } + } + }, + { noOverlap: true }, + ); +} + +function reminderText( + type: ReminderType, + event: { title: string; location: string | null; eventStartsAt: Date }, +): string { + if (type === ReminderType.RSVP_DEADLINE) { + return `⏰ Reminder: RSVP for *${event.title}* soon.`; + } + if (type === ReminderType.EVENT_START) { + return `📍 *${event.title}* starts ${slackTime(event.eventStartsAt)}${ + event.location ? ` at ${event.location}` : "" + }. See you there!`; + } + return `🔔 Reminder: Please update your RSVP for *${event.title}*.`; +} diff --git a/src/modals/dinner.modal.ts b/src/modals/dinner.modal.ts new file mode 100644 index 0000000..dafa5da --- /dev/null +++ b/src/modals/dinner.modal.ts @@ -0,0 +1,32 @@ +import { EventType } from "@prisma/client"; +import type { CommandMetadata } from "../types/slack.types.js"; +import { + buildActivityModal, + callbackIdFor, + type ActivityModalConfig, +} from "./shared.modal.js"; + +export const dinnerModalConfig: ActivityModalConfig = { + callbackId: callbackIdFor(EventType.DINNER), + title: "Create team dinner", + defaultTitle: "Team Dinner", + locationLabel: "Venue", + fields: [ + { blockId: "budget", label: "Budget per person", kind: "text", optional: true }, + { + blockId: "plusOneAllowed", + label: "Plus-one allowed?", + kind: "boolean", + initialValue: "No", + }, + { + blockId: "foodPreferencesEnabled", + label: "Food preference options enabled?", + kind: "boolean", + initialValue: "Yes", + }, + ], +}; + +export const buildDinnerModal = (metadata: CommandMetadata) => + buildActivityModal(dinnerModalConfig, metadata); diff --git a/src/modals/edit-event.modal.ts b/src/modals/edit-event.modal.ts new file mode 100644 index 0000000..b700105 --- /dev/null +++ b/src/modals/edit-event.modal.ts @@ -0,0 +1,83 @@ +import type { ModalView } from "@slack/types"; +import type { EventWithRelations } from "../types/event.types.js"; + +export function buildEditEventModal(event: EventWithRelations): ModalView { + return { + type: "modal", + callback_id: "edit_event_submission", + private_metadata: JSON.stringify({ eventId: event.id }), + title: { type: "plain_text", text: "Edit event" }, + submit: { type: "plain_text", text: "Save changes" }, + close: { type: "plain_text", text: "Cancel" }, + blocks: [ + textInput("title", "Activity title", event.title), + { + type: "input", + block_id: "date", + label: { type: "plain_text", text: "Date" }, + element: { + type: "datepicker", + action_id: "value", + initial_date: event.eventStartsAt.toISOString().slice(0, 10), + }, + }, + { + type: "input", + block_id: "time", + label: { type: "plain_text", text: "Time (UTC)" }, + element: { + type: "timepicker", + action_id: "value", + initial_time: event.eventStartsAt.toISOString().slice(11, 16), + }, + }, + textInput("location", "Location / meeting point", event.location ?? ""), + { + type: "input", + block_id: "rsvpDeadlineDate", + label: { type: "plain_text", text: "RSVP deadline date" }, + element: { + type: "datepicker", + action_id: "value", + initial_date: (event.rsvpDeadlineAt ?? event.eventStartsAt) + .toISOString() + .slice(0, 10), + }, + }, + { + type: "input", + block_id: "rsvpDeadlineTime", + label: { type: "plain_text", text: "RSVP deadline time (UTC)" }, + element: { + type: "timepicker", + action_id: "value", + initial_time: (event.rsvpDeadlineAt ?? event.eventStartsAt) + .toISOString() + .slice(11, 16), + }, + }, + textInput("notes", "Notes", event.description ?? "", true, true), + ], + }; +} + +function textInput( + blockId: string, + label: string, + initialValue: string, + optional = false, + multiline = false, +) { + return { + type: "input" as const, + block_id: blockId, + optional, + label: { type: "plain_text" as const, text: label }, + element: { + type: "plain_text_input" as const, + action_id: "value", + initial_value: initialValue, + multiline, + }, + }; +} diff --git a/src/modals/hyrox.modal.ts b/src/modals/hyrox.modal.ts new file mode 100644 index 0000000..75386a2 --- /dev/null +++ b/src/modals/hyrox.modal.ts @@ -0,0 +1,45 @@ +import { EventType } from "@prisma/client"; +import type { CommandMetadata } from "../types/slack.types.js"; +import { + buildActivityModal, + callbackIdFor, + type ActivityModalConfig, +} from "./shared.modal.js"; + +export const hyroxModalConfig: ActivityModalConfig = { + callbackId: callbackIdFor(EventType.HYROX), + title: "Create HYROX session", + defaultTitle: "HYROX Session", + locationLabel: "Gym / location", + fields: [ + { + blockId: "workoutType", + label: "Workout type", + kind: "select", + options: ["Simulation", "Running", "Strength", "Full prep", "Custom"], + initialValue: "Full prep", + }, + { + blockId: "partnerNeeded", + label: "Partner needed?", + kind: "boolean", + initialValue: "No", + }, + { + blockId: "beginnerFriendly", + label: "Beginner friendly?", + kind: "boolean", + initialValue: "Yes", + }, + { + blockId: "intensity", + label: "Intensity", + kind: "select", + options: ["Casual", "Competitive", "Mixed"], + initialValue: "Mixed", + }, + ], +}; + +export const buildHyroxModal = (metadata: CommandMetadata) => + buildActivityModal(hyroxModalConfig, metadata); diff --git a/src/modals/lunch.modal.ts b/src/modals/lunch.modal.ts new file mode 100644 index 0000000..09a0851 --- /dev/null +++ b/src/modals/lunch.modal.ts @@ -0,0 +1,26 @@ +import { EventType } from "@prisma/client"; +import type { CommandMetadata } from "../types/slack.types.js"; +import { + buildActivityModal, + callbackIdFor, + type ActivityModalConfig, +} from "./shared.modal.js"; + +export const lunchModalConfig: ActivityModalConfig = { + callbackId: callbackIdFor(EventType.LUNCH), + title: "Create team lunch", + defaultTitle: "Team Lunch", + locationLabel: "Restaurant / location / order link", + fields: [ + { blockId: "budget", label: "Budget per person", kind: "text", optional: true }, + { + blockId: "foodPreferencesEnabled", + label: "Food preference options enabled?", + kind: "boolean", + initialValue: "Yes", + }, + ], +}; + +export const buildLunchModal = (metadata: CommandMetadata) => + buildActivityModal(lunchModalConfig, metadata); diff --git a/src/modals/marathon.modal.ts b/src/modals/marathon.modal.ts new file mode 100644 index 0000000..05ef73d --- /dev/null +++ b/src/modals/marathon.modal.ts @@ -0,0 +1,46 @@ +import { EventType } from "@prisma/client"; +import type { CommandMetadata } from "../types/slack.types.js"; +import { + buildActivityModal, + callbackIdFor, + type ActivityModalConfig, +} from "./shared.modal.js"; + +export const marathonModalConfig: ActivityModalConfig = { + callbackId: callbackIdFor(EventType.MARATHON), + title: "Create marathon plan", + defaultTitle: "Marathon Training", + locationLabel: "Meeting point", + fields: [ + { blockId: "raceName", label: "Race name", kind: "text", optional: true }, + { + blockId: "raceDate", + label: "Race date (YYYY-MM-DD)", + kind: "text", + optional: true, + }, + { + blockId: "distance", + label: "Distance", + kind: "select", + options: ["5K", "10K", "21K", "42K", "Custom"], + initialValue: "10K", + }, + { + blockId: "customDistance", + label: "Custom distance", + kind: "text", + optional: true, + }, + { blockId: "targetPace", label: "Target pace", kind: "text", optional: true }, + { + blockId: "beginnerFriendly", + label: "Beginner group available?", + kind: "boolean", + initialValue: "Yes", + }, + ], +}; + +export const buildMarathonModal = (metadata: CommandMetadata) => + buildActivityModal(marathonModalConfig, metadata); diff --git a/src/modals/potluck-item.modal.ts b/src/modals/potluck-item.modal.ts new file mode 100644 index 0000000..ce455c1 --- /dev/null +++ b/src/modals/potluck-item.modal.ts @@ -0,0 +1,59 @@ +import type { ModalView } from "@slack/types"; +import type { EventWithRelations } from "../types/event.types.js"; + +export function buildPotluckItemModal(event: EventWithRelations): ModalView { + const metadata = event.metadata as { neededCategories?: string[] }; + const categories = metadata.neededCategories?.length + ? metadata.neededCategories + : ["Main dish", "Snacks", "Dessert", "Drinks", "Other"]; + return { + type: "modal", + callback_id: "save_potluck_item", + private_metadata: JSON.stringify({ eventId: event.id }), + title: { type: "plain_text", text: "Bring something" }, + submit: { type: "plain_text", text: "Add item" }, + close: { type: "plain_text", text: "Cancel" }, + blocks: [ + { + type: "input", + block_id: "category", + label: { type: "plain_text", text: "Category" }, + element: { + type: "static_select", + action_id: "value", + options: categories.slice(0, 100).map((category) => ({ + text: { type: "plain_text", text: category.slice(0, 75) }, + value: category.slice(0, 75), + })), + }, + }, + textInput("itemName", "Dish / item name"), + textInput("servesCount", "Serves how many", true), + { + type: "input", + block_id: "dietaryType", + optional: true, + label: { type: "plain_text", text: "Dietary type" }, + element: { + type: "static_select", + action_id: "value", + options: ["Veg", "Non-veg", "Vegan", "Other"].map((value) => ({ + text: { type: "plain_text", text: value }, + value, + })), + }, + }, + textInput("itemNotes", "Notes", true), + ], + }; +} + +function textInput(blockId: string, label: string, optional = false) { + return { + type: "input" as const, + block_id: blockId, + optional, + label: { type: "plain_text" as const, text: label }, + element: { type: "plain_text_input" as const, action_id: "value" }, + }; +} diff --git a/src/modals/potluck.modal.ts b/src/modals/potluck.modal.ts new file mode 100644 index 0000000..3bc4b15 --- /dev/null +++ b/src/modals/potluck.modal.ts @@ -0,0 +1,26 @@ +import { EventType } from "@prisma/client"; +import type { CommandMetadata } from "../types/slack.types.js"; +import { + buildActivityModal, + callbackIdFor, + type ActivityModalConfig, +} from "./shared.modal.js"; + +export const potluckModalConfig: ActivityModalConfig = { + callbackId: callbackIdFor(EventType.POTLUCK), + title: "Create team potluck", + defaultTitle: "Team Potluck", + locationLabel: "Venue", + fields: [ + { + blockId: "neededCategories", + label: "Needed categories", + kind: "text", + initialValue: "Main dish, Snacks, Dessert, Drinks", + placeholder: "Comma-separated categories", + }, + ], +}; + +export const buildPotluckModal = (metadata: CommandMetadata) => + buildActivityModal(potluckModalConfig, metadata); diff --git a/src/modals/preference.modal.ts b/src/modals/preference.modal.ts new file mode 100644 index 0000000..894505a --- /dev/null +++ b/src/modals/preference.modal.ts @@ -0,0 +1,93 @@ +import { EventType } from "@prisma/client"; +import type { ModalView } from "@slack/types"; +import type { EventWithRelations } from "../types/event.types.js"; + +const options: Record = { + LUNCH: ["Veg", "Non-veg", "Vegan", "No preference"], + DINNER: ["Veg", "Non-veg", "Vegan", "No preference"], + POTLUCK: [], + WEEKEND_RUN: ["Easy pace", "Moderate pace", "Fast pace", "Beginner group"], + MARATHON: ["Joining training", "Running race", "Beginner group"], + HYROX: ["Need partner", "Beginner", "Competitive", "Casual"], +}; + +export function buildPreferenceModal(event: EventWithRelations): ModalView { + const metadata = event.metadata as { + plusOneAllowed?: boolean; + foodPreferencesEnabled?: boolean; + }; + const primaryRequired = !( + event.type === EventType.DINNER && metadata.foodPreferencesEnabled === false + ); + return { + type: "modal", + callback_id: "save_preference", + private_metadata: JSON.stringify({ eventId: event.id, primaryRequired }), + title: { type: "plain_text", text: "Set preference" }, + submit: { type: "plain_text", text: "Save" }, + close: { type: "plain_text", text: "Cancel" }, + blocks: [ + ...(primaryRequired + ? [ + { + type: "input" as const, + block_id: "preference", + label: { type: "plain_text" as const, text: "Preference" }, + element: { + type: "static_select" as const, + action_id: "value", + placeholder: { + type: "plain_text" as const, + text: "Choose a preference", + }, + options: options[event.type].map((value) => ({ + text: { type: "plain_text" as const, text: value }, + value, + })), + }, + }, + ] + : []), + ...(event.type === EventType.DINNER && metadata.plusOneAllowed + ? [ + { + type: "input" as const, + block_id: "plusOne", + label: { type: "plain_text" as const, text: "Bringing a plus-one?" }, + element: { + type: "static_select" as const, + action_id: "value", + initial_option: selectOption("No"), + options: [selectOption("Yes"), selectOption("No")], + }, + }, + ] + : []), + ...(event.type === EventType.MARATHON + ? [ + { + type: "input" as const, + block_id: "targetPaceNote", + optional: true, + label: { type: "plain_text" as const, text: "Target pace note" }, + element: { + type: "plain_text_input" as const, + action_id: "value", + placeholder: { + type: "plain_text" as const, + text: "Optional, e.g. 5:45/km", + }, + }, + }, + ] + : []), + ], + }; +} + +function selectOption(value: string) { + return { + text: { type: "plain_text" as const, text: value }, + value, + }; +} diff --git a/src/modals/shared.modal.ts b/src/modals/shared.modal.ts new file mode 100644 index 0000000..0d0f903 --- /dev/null +++ b/src/modals/shared.modal.ts @@ -0,0 +1,221 @@ +import type { EventType } from "@prisma/client"; +import type { InputBlockElement, ModalView, KnownBlock } from "@slack/types"; +import type { CommandMetadata, SlackViewValues } from "../types/slack.types.js"; + +export interface ModalField { + blockId: string; + actionId?: string; + label: string; + kind: "text" | "select" | "boolean"; + optional?: boolean; + multiline?: boolean; + placeholder?: string; + initialValue?: string; + options?: string[]; +} + +export interface ActivityModalConfig { + callbackId: string; + title: string; + defaultTitle: string; + locationLabel: string; + fields: ModalField[]; +} + +export function buildActivityModal( + config: ActivityModalConfig, + metadata: CommandMetadata, +): ModalView { + const tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000) + .toISOString() + .slice(0, 10); + const blocks: KnownBlock[] = [ + input("title", "Activity title", { + type: "plain_text_input", + action_id: "value", + initial_value: config.defaultTitle, + max_length: 150, + }), + input("date", "Date", { + type: "datepicker", + action_id: "value", + initial_date: tomorrow, + placeholder: { type: "plain_text", text: "Select a date" }, + }), + input("time", "Time (UTC)", { + type: "timepicker", + action_id: "value", + initial_time: "12:30", + placeholder: { type: "plain_text", text: "Select a time" }, + }), + input("location", config.locationLabel, { + type: "plain_text_input", + action_id: "value", + max_length: 300, + placeholder: { type: "plain_text", text: "Where should everyone meet?" }, + }), + ...config.fields.map(renderField), + input("rsvpDeadlineDate", "RSVP deadline date", { + type: "datepicker", + action_id: "value", + initial_date: tomorrow, + placeholder: { type: "plain_text", text: "Select a date" }, + }), + input("rsvpDeadlineTime", "RSVP deadline time (UTC)", { + type: "timepicker", + action_id: "value", + initial_time: "10:00", + placeholder: { type: "plain_text", text: "Select a time" }, + }), + input( + "reminderMinutes", + "Event reminder", + { + type: "static_select", + action_id: "value", + initial_option: option("60 minutes before", "60"), + options: [ + option("No event reminder", "0"), + option("30 minutes before", "30"), + option("60 minutes before", "60"), + option("1 day before", "1440"), + ], + }, + true, + ), + input( + "notes", + "Notes", + { + type: "plain_text_input", + action_id: "value", + multiline: true, + max_length: 1500, + placeholder: { + type: "plain_text", + text: "Anything else the team should know?", + }, + }, + true, + ), + ]; + + return { + type: "modal", + callback_id: config.callbackId, + private_metadata: JSON.stringify(metadata), + title: { type: "plain_text", text: config.title.slice(0, 24) }, + submit: { type: "plain_text", text: "Create event" }, + close: { type: "plain_text", text: "Cancel" }, + notify_on_close: false, + blocks, + }; +} + +export function extractActivityValues( + values: SlackViewValues, + extraFields: ModalField[], +): Record { + const result: Record = { + title: readValue(values, "title"), + date: readValue(values, "date"), + time: readValue(values, "time"), + location: readValue(values, "location"), + rsvpDeadlineDate: readValue(values, "rsvpDeadlineDate"), + rsvpDeadlineTime: readValue(values, "rsvpDeadlineTime"), + reminderMinutes: readValue(values, "reminderMinutes"), + notes: readValue(values, "notes"), + }; + for (const field of extraFields) { + result[field.blockId] = readValue(values, field.blockId, field.actionId ?? "value"); + } + return result; +} + +export function callbackIdFor(type: EventType): string { + return `create_${type.toLowerCase()}_event`; +} + +function renderField(field: ModalField): KnownBlock { + if (field.kind === "select" || field.kind === "boolean") { + const options = field.kind === "boolean" ? ["Yes", "No"] : (field.options ?? []); + const initialValue = field.initialValue ?? options[0] ?? ""; + return input( + field.blockId, + field.label, + { + type: "static_select", + action_id: field.actionId ?? "value", + placeholder: { + type: "plain_text", + text: field.placeholder ?? "Choose an option", + }, + options: options.map((value) => option(value, value)), + ...(initialValue ? { initial_option: option(initialValue, initialValue) } : {}), + }, + field.optional, + ); + } + + return input( + field.blockId, + field.label, + { + type: "plain_text_input", + action_id: field.actionId ?? "value", + ...(field.initialValue ? { initial_value: field.initialValue } : {}), + ...(field.multiline ? { multiline: true } : {}), + placeholder: { + type: "plain_text", + text: field.placeholder ?? `Enter ${field.label.toLowerCase()}`, + }, + }, + field.optional, + ); +} + +function input( + blockId: string, + label: string, + element: InputBlockElement, + optional = false, +): KnownBlock { + return { + type: "input", + block_id: blockId, + optional, + label: { type: "plain_text", text: label, emoji: true }, + element, + }; +} + +function option(text: string, value: string) { + return { + text: { type: "plain_text" as const, text, emoji: true }, + value, + }; +} + +function readValue( + values: SlackViewValues, + blockId: string, + actionId = "value", +): string { + const state = values[blockId]?.[actionId]; + if (!state) return ""; + if ("value" in state && typeof state.value === "string") return state.value; + if ("selected_date" in state && typeof state.selected_date === "string") { + return state.selected_date; + } + if ("selected_time" in state && typeof state.selected_time === "string") { + return state.selected_time; + } + if ( + "selected_option" in state && + state.selected_option && + "value" in state.selected_option + ) { + return state.selected_option.value; + } + return ""; +} diff --git a/src/modals/weekendrun.modal.ts b/src/modals/weekendrun.modal.ts new file mode 100644 index 0000000..de14b3d --- /dev/null +++ b/src/modals/weekendrun.modal.ts @@ -0,0 +1,46 @@ +import { EventType } from "@prisma/client"; +import type { CommandMetadata } from "../types/slack.types.js"; +import { + buildActivityModal, + callbackIdFor, + type ActivityModalConfig, +} from "./shared.modal.js"; + +export const weekendRunModalConfig: ActivityModalConfig = { + callbackId: callbackIdFor(EventType.WEEKEND_RUN), + title: "Create weekend run", + defaultTitle: "Weekend Run", + locationLabel: "Meeting point", + fields: [ + { + blockId: "distance", + label: "Distance", + kind: "select", + options: ["3K", "5K", "10K", "Custom"], + initialValue: "5K", + }, + { + blockId: "customDistance", + label: "Custom distance", + kind: "text", + optional: true, + }, + { + blockId: "pace", + label: "Pace", + kind: "select", + options: ["Easy", "Moderate", "Fast", "Mixed groups"], + initialValue: "Mixed groups", + }, + { blockId: "routeLink", label: "Route link", kind: "text", optional: true }, + { + blockId: "beginnerFriendly", + label: "Beginner friendly?", + kind: "boolean", + initialValue: "Yes", + }, + ], +}; + +export const buildWeekendRunModal = (metadata: CommandMetadata) => + buildActivityModal(weekendRunModalConfig, metadata); diff --git a/src/services/event.service.ts b/src/services/event.service.ts new file mode 100644 index 0000000..cff081d --- /dev/null +++ b/src/services/event.service.ts @@ -0,0 +1,222 @@ +import { + EventStatus, + ReminderStatus, + type Event, + type PrismaClient, +} from "@prisma/client"; +import type { + CreateEventInput, + EventWithRelations, + UpdateEventInput, +} from "../types/event.types.js"; +import { metadataToJson } from "../types/event.types.js"; +import { NotFoundError } from "../utils/errors.js"; +import { PermissionService } from "./permission.service.js"; + +export class EventService { + constructor( + private readonly db: PrismaClient, + private readonly permissions = new PermissionService(), + ) {} + + async create(input: CreateEventInput): Promise { + return this.db.$transaction(async (tx) => { + const workspace = await tx.workspace.upsert({ + where: { slackTeamId: input.slackTeamId }, + update: { + ...(input.slackTeamName ? { name: input.slackTeamName } : {}), + uninstalledAt: null, + }, + create: { + slackTeamId: input.slackTeamId, + ...(input.slackTeamName ? { name: input.slackTeamName } : {}), + }, + }); + + const channel = await tx.channel.upsert({ + where: { + workspaceId_slackChannelId: { + workspaceId: workspace.id, + slackChannelId: input.slackChannelId, + }, + }, + update: input.slackChannelName ? { name: input.slackChannelName } : {}, + create: { + workspaceId: workspace.id, + slackChannelId: input.slackChannelId, + ...(input.slackChannelName ? { name: input.slackChannelName } : {}), + }, + }); + + const event = await tx.event.create({ + data: { + workspaceId: workspace.id, + channelId: channel.id, + organizerSlackUserId: input.organizerSlackUserId, + type: input.type, + title: input.title, + ...(input.description ? { description: input.description } : {}), + location: input.location, + eventStartsAt: input.eventStartsAt, + ...(input.rsvpDeadlineAt ? { rsvpDeadlineAt: input.rsvpDeadlineAt } : {}), + slackChannelId: input.slackChannelId, + metadata: metadataToJson(input.metadata ?? {}), + }, + }); + + const neededCategories = + input.type === "POTLUCK" && Array.isArray(input.metadata?.neededCategories) + ? input.metadata.neededCategories + : []; + if (neededCategories.length) { + await tx.potluckItem.createMany({ + data: neededCategories.map((category) => ({ + eventId: event.id, + category, + itemName: "Needed", + })), + }); + } + + await tx.auditLog.create({ + data: { + workspaceId: workspace.id, + eventId: event.id, + actorSlackUserId: input.organizerSlackUserId, + action: "event.created", + metadata: { type: input.type }, + }, + }); + + return tx.event.findUniqueOrThrow({ + where: { id: event.id }, + include: this.relations(), + }); + }); + } + + async getById(eventId: string): Promise { + const event = await this.db.event.findUnique({ + where: { id: eventId }, + include: this.relations(), + }); + if (!event) throw new NotFoundError(); + return event; + } + + async update( + eventId: string, + actorSlackUserId: string, + input: UpdateEventInput, + ): Promise { + const existing = await this.getById(eventId); + this.permissions.assertOrganizer(existing, actorSlackUserId); + + return this.db.$transaction(async (tx) => { + await tx.event.update({ + where: { id: eventId }, + data: { + ...input, + ...(input.metadata ? { metadata: metadataToJson(input.metadata) } : {}), + }, + }); + await tx.auditLog.create({ + data: { + workspaceId: existing.workspaceId, + eventId, + actorSlackUserId, + action: "event.updated", + }, + }); + return tx.event.findUniqueOrThrow({ + where: { id: eventId }, + include: this.relations(), + }); + }); + } + + async setSlackMessage(eventId: string, channel: string, ts: string): Promise { + return this.db.event.update({ + where: { id: eventId }, + data: { slackChannelId: channel, slackMessageTs: ts }, + }); + } + + async closeRsvp( + eventId: string, + actorSlackUserId: string, + ): Promise { + const existing = await this.getById(eventId); + this.permissions.assertOrganizer(existing, actorSlackUserId); + + return this.transition(existing, actorSlackUserId, EventStatus.RSVP_CLOSED, { + closedAt: new Date(), + action: "event.rsvp_closed", + }); + } + + async cancel(eventId: string, actorSlackUserId: string): Promise { + const existing = await this.getById(eventId); + this.permissions.assertOrganizer(existing, actorSlackUserId); + + return this.db.$transaction(async (tx) => { + await tx.event.update({ + where: { id: eventId }, + data: { status: EventStatus.CANCELLED, cancelledAt: new Date() }, + }); + await tx.reminder.updateMany({ + where: { eventId, status: ReminderStatus.PENDING }, + data: { status: ReminderStatus.CANCELLED }, + }); + await tx.auditLog.create({ + data: { + workspaceId: existing.workspaceId, + eventId, + actorSlackUserId, + action: "event.cancelled", + }, + }); + return tx.event.findUniqueOrThrow({ + where: { id: eventId }, + include: this.relations(), + }); + }); + } + + private async transition( + existing: EventWithRelations, + actorSlackUserId: string, + status: EventStatus, + detail: { closedAt?: Date; action: string }, + ): Promise { + return this.db.$transaction(async (tx) => { + await tx.event.update({ + where: { id: existing.id }, + data: { status, ...(detail.closedAt ? { closedAt: detail.closedAt } : {}) }, + }); + await tx.auditLog.create({ + data: { + workspaceId: existing.workspaceId, + eventId: existing.id, + actorSlackUserId, + action: detail.action, + }, + }); + return tx.event.findUniqueOrThrow({ + where: { id: existing.id }, + include: this.relations(), + }); + }); + } + + private relations() { + return { + rsvps: true, + preferences: true, + potluckItems: { + where: { claimedBySlackUserId: { not: null } }, + orderBy: [{ category: "asc" as const }, { createdAt: "asc" as const }], + }, + }; + } +} diff --git a/src/services/oauth-installation.store.ts b/src/services/oauth-installation.store.ts new file mode 100644 index 0000000..1567933 --- /dev/null +++ b/src/services/oauth-installation.store.ts @@ -0,0 +1,58 @@ +import type { Installation, InstallationQuery, InstallationStore } from "@slack/oauth"; +import { SLACK_SCOPES } from "../config/slack.js"; +import { WorkspaceService } from "./workspace.service.js"; + +export class PrismaInstallationStore implements InstallationStore { + constructor(private readonly workspaces: WorkspaceService) {} + + async storeInstallation( + installation: Installation, + ): Promise { + if (!installation.team?.id || !installation.bot?.token) { + throw new Error("TeamLoop V1 supports workspace bot installations only."); + } + await this.workspaces.saveInstallation({ + teamId: installation.team.id, + ...(installation.team.name ? { teamName: installation.team.name } : {}), + botToken: installation.bot.token, + botUserId: installation.bot.userId, + installedBy: installation.user.id, + }); + } + + async fetchInstallation( + query: InstallationQuery, + ): Promise> { + if (!query.teamId) { + throw new Error("Enterprise organization installs are not supported in V1."); + } + const stored = await this.workspaces.getInstallation(query.teamId); + if (!stored) throw new Error(`No TeamLoop installation found for ${query.teamId}.`); + + return { + authVersion: "v2", + isEnterpriseInstall: false, + team: { + id: stored.teamId, + ...(stored.teamName ? { name: stored.teamName } : {}), + }, + enterprise: undefined, + user: { + id: stored.installedBy ?? "UNKNOWN", + token: undefined, + scopes: undefined, + }, + bot: { + token: stored.botToken, + id: stored.botUserId ?? "UNKNOWN", + userId: stored.botUserId ?? "UNKNOWN", + scopes: [...SLACK_SCOPES], + }, + tokenType: "bot", + }; + } + + async deleteInstallation(query: InstallationQuery): Promise { + if (query.teamId) await this.workspaces.markUninstalled(query.teamId); + } +} diff --git a/src/services/permission.service.ts b/src/services/permission.service.ts new file mode 100644 index 0000000..c2e503a --- /dev/null +++ b/src/services/permission.service.ts @@ -0,0 +1,13 @@ +import type { Event } from "@prisma/client"; +import { PermissionError } from "../utils/errors.js"; + +export class PermissionService { + assertOrganizer( + event: Pick, + slackUserId: string, + ): void { + if (event.organizerSlackUserId !== slackUserId) { + throw new PermissionError(); + } + } +} diff --git a/src/services/potluck.service.ts b/src/services/potluck.service.ts new file mode 100644 index 0000000..62571f0 --- /dev/null +++ b/src/services/potluck.service.ts @@ -0,0 +1,47 @@ +import { RsvpStatus, type PotluckItem, type PrismaClient } from "@prisma/client"; +import { NotFoundError } from "../utils/errors.js"; + +export interface PotluckItemInput { + category: string; + itemName: string; + servesCount?: number; + dietaryType?: string; + notes?: string; +} + +export class PotluckService { + constructor(private readonly db: PrismaClient) {} + + async addItem( + eventId: string, + slackUserId: string, + input: PotluckItemInput, + ): Promise { + const event = await this.db.event.findUnique({ + where: { id: eventId }, + select: { id: true, type: true }, + }); + if (!event) throw new NotFoundError(); + if (event.type !== "POTLUCK") throw new Error("Not a potluck event"); + + return this.db.$transaction(async (tx) => { + const item = await tx.potluckItem.create({ + data: { + eventId, + claimedBySlackUserId: slackUserId, + category: input.category, + itemName: input.itemName, + ...(input.servesCount ? { servesCount: input.servesCount } : {}), + ...(input.dietaryType ? { dietaryType: input.dietaryType } : {}), + ...(input.notes ? { notes: input.notes } : {}), + }, + }); + await tx.rSVP.upsert({ + where: { eventId_slackUserId: { eventId, slackUserId } }, + update: {}, + create: { eventId, slackUserId, status: RsvpStatus.GOING }, + }); + return item; + }); + } +} diff --git a/src/services/preference.service.ts b/src/services/preference.service.ts new file mode 100644 index 0000000..effbc37 --- /dev/null +++ b/src/services/preference.service.ts @@ -0,0 +1,27 @@ +import type { PrismaClient, Preference } from "@prisma/client"; +import { NotFoundError } from "../utils/errors.js"; + +export class PreferenceService { + constructor(private readonly db: PrismaClient) {} + + async upsert( + eventId: string, + slackUserId: string, + key: string, + value: string, + ): Promise { + const event = await this.db.event.findUnique({ + where: { id: eventId }, + select: { id: true }, + }); + if (!event) throw new NotFoundError(); + + return this.db.preference.upsert({ + where: { + eventId_slackUserId_key: { eventId, slackUserId, key }, + }, + update: { value }, + create: { eventId, slackUserId, key, value }, + }); + } +} diff --git a/src/services/reminder.service.ts b/src/services/reminder.service.ts new file mode 100644 index 0000000..cbd9734 --- /dev/null +++ b/src/services/reminder.service.ts @@ -0,0 +1,102 @@ +import { + EventStatus, + ReminderStatus, + ReminderType, + type PrismaClient, + type Reminder, +} from "@prisma/client"; +import { minutesBefore } from "../utils/date.js"; + +export class ReminderService { + constructor(private readonly db: PrismaClient) {} + + async createDefaults(input: { + eventId: string; + eventStartsAt: Date; + rsvpDeadlineAt?: Date; + eventReminderMinutes?: number; + }): Promise { + const reminders: Array<{ + eventId: string; + type: ReminderType; + scheduledFor: Date; + }> = []; + + if (input.rsvpDeadlineAt) { + reminders.push({ + eventId: input.eventId, + type: ReminderType.RSVP_DEADLINE, + scheduledFor: minutesBefore(input.rsvpDeadlineAt, 60), + }); + } + if (input.eventReminderMinutes && input.eventReminderMinutes > 0) { + reminders.push({ + eventId: input.eventId, + type: ReminderType.EVENT_START, + scheduledFor: minutesBefore(input.eventStartsAt, input.eventReminderMinutes), + }); + } + + for (const reminder of reminders.filter((item) => item.scheduledFor > new Date())) { + await this.db.reminder.upsert({ + where: { + eventId_type_scheduledFor: reminder, + }, + update: {}, + create: reminder, + }); + } + } + + async createManual(eventId: string): Promise { + return this.db.reminder.create({ + data: { + eventId, + type: ReminderType.MANUAL, + scheduledFor: new Date(), + }, + }); + } + + async due(now = new Date(), limit = 50) { + return this.db.reminder.findMany({ + where: { + status: ReminderStatus.PENDING, + scheduledFor: { lte: now }, + event: { status: { not: EventStatus.CANCELLED } }, + }, + include: { event: true }, + orderBy: { scheduledFor: "asc" }, + take: limit, + }); + } + + async claim(reminderId: string): Promise { + const result = await this.db.reminder.updateMany({ + where: { id: reminderId, status: ReminderStatus.PENDING }, + data: { + status: ReminderStatus.PROCESSING, + attemptCount: { increment: 1 }, + }, + }); + return result.count === 1; + } + + async markSent(reminderId: string): Promise { + await this.db.reminder.update({ + where: { id: reminderId }, + data: { status: ReminderStatus.SENT, sentAt: new Date(), lastError: null }, + }); + } + + async markFailed(reminderId: string, error: unknown): Promise { + const message = error instanceof Error ? error.message : "Unknown reminder error"; + await this.db.reminder.update({ + where: { id: reminderId }, + data: { + status: ReminderStatus.FAILED, + lastError: message.slice(0, 500), + }, + }); + } +} diff --git a/src/services/rsvp.service.ts b/src/services/rsvp.service.ts new file mode 100644 index 0000000..beb01ea --- /dev/null +++ b/src/services/rsvp.service.ts @@ -0,0 +1,53 @@ +import { EventStatus, RsvpStatus, type PrismaClient, type RSVP } from "@prisma/client"; +import { EventStateError, NotFoundError } from "../utils/errors.js"; + +export interface RsvpCounts { + going: number; + maybe: number; + cannotMakeIt: number; +} + +export class RsvpService { + constructor(private readonly db: PrismaClient) {} + + async upsert( + eventId: string, + slackUserId: string, + status: RsvpStatus, + ): Promise { + const event = await this.db.event.findUnique({ + where: { id: eventId }, + select: { status: true }, + }); + if (!event) throw new NotFoundError(); + if (event.status === EventStatus.CANCELLED) { + throw new EventStateError("This event has been cancelled.", "EVENT_CANCELLED"); + } + if (event.status === EventStatus.RSVP_CLOSED) { + throw new EventStateError("RSVPs are closed for this event.", "RSVP_CLOSED"); + } + + return this.db.rSVP.upsert({ + where: { eventId_slackUserId: { eventId, slackUserId } }, + update: { status }, + create: { eventId, slackUserId, status }, + }); + } + + async counts(eventId: string): Promise { + const rows = await this.db.rSVP.groupBy({ + by: ["status"], + where: { eventId }, + _count: { _all: true }, + }); + const counts: RsvpCounts = { going: 0, maybe: 0, cannotMakeIt: 0 }; + for (const row of rows) { + if (row.status === RsvpStatus.GOING) counts.going = row._count._all; + if (row.status === RsvpStatus.MAYBE) counts.maybe = row._count._all; + if (row.status === RsvpStatus.CANNOT_MAKE_IT) { + counts.cannotMakeIt = row._count._all; + } + } + return counts; + } +} diff --git a/src/services/slack-message.service.ts b/src/services/slack-message.service.ts new file mode 100644 index 0000000..8d805a7 --- /dev/null +++ b/src/services/slack-message.service.ts @@ -0,0 +1,34 @@ +import type { WebClient } from "@slack/web-api"; +import { renderEventCard } from "../blocks/event-card.blocks.js"; +import type { EventWithRelations } from "../types/event.types.js"; + +export class SlackMessageService { + async postEvent(client: WebClient, event: EventWithRelations) { + return client.chat.postMessage({ + channel: event.slackChannelId, + text: `${event.title} — TeamLoop event`, + blocks: renderEventCard(event), + unfurl_links: false, + unfurl_media: false, + }); + } + + async updateEvent(client: WebClient, event: EventWithRelations): Promise { + if (!event.slackMessageTs) return; + await client.chat.update({ + channel: event.slackChannelId, + ts: event.slackMessageTs, + text: `${event.title} — TeamLoop event`, + blocks: renderEventCard(event), + }); + } + + async ephemeral( + client: WebClient, + channel: string, + user: string, + text: string, + ): Promise { + await client.chat.postEphemeral({ channel, user, text }); + } +} diff --git a/src/services/workspace.service.ts b/src/services/workspace.service.ts new file mode 100644 index 0000000..4e80bf0 --- /dev/null +++ b/src/services/workspace.service.ts @@ -0,0 +1,146 @@ +import { ReminderStatus, type PrismaClient } from "@prisma/client"; +import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto"; + +export interface InstallationRecord { + teamId: string; + teamName?: string; + botUserId?: string; + installedBy?: string; + botToken: string; +} + +export interface StoredInstallation { + teamId: string; + teamName?: string; + botUserId?: string; + installedBy?: string; + botToken: string; +} + +export class WorkspaceService { + constructor( + private readonly db: PrismaClient, + private readonly encryptionKey?: string, + ) {} + + async saveInstallation(input: InstallationRecord): Promise { + await this.db.workspace.upsert({ + where: { slackTeamId: input.teamId }, + update: { + ...(input.teamName ? { name: input.teamName } : {}), + ...(input.botUserId ? { botUserId: input.botUserId } : {}), + ...(input.installedBy ? { installedBySlackUserId: input.installedBy } : {}), + accessToken: this.encrypt(input.botToken), + uninstalledAt: null, + }, + create: { + slackTeamId: input.teamId, + ...(input.teamName ? { name: input.teamName } : {}), + ...(input.botUserId ? { botUserId: input.botUserId } : {}), + ...(input.installedBy ? { installedBySlackUserId: input.installedBy } : {}), + accessToken: this.encrypt(input.botToken), + }, + }); + } + + async getBotToken(teamId: string): Promise { + const workspace = await this.db.workspace.findUnique({ + where: { slackTeamId: teamId }, + select: { accessToken: true, uninstalledAt: true }, + }); + if (!workspace?.accessToken || workspace.uninstalledAt) return null; + return this.decrypt(workspace.accessToken); + } + + async getBotTokenByWorkspaceId(workspaceId: string): Promise { + const workspace = await this.db.workspace.findUnique({ + where: { id: workspaceId }, + select: { accessToken: true, uninstalledAt: true }, + }); + if (!workspace?.accessToken || workspace.uninstalledAt) return null; + return this.decrypt(workspace.accessToken); + } + + async getInstallation(teamId: string): Promise { + const workspace = await this.db.workspace.findUnique({ + where: { slackTeamId: teamId }, + select: { + slackTeamId: true, + name: true, + botUserId: true, + installedBySlackUserId: true, + accessToken: true, + uninstalledAt: true, + }, + }); + if (!workspace?.accessToken || workspace.uninstalledAt) return null; + return { + teamId: workspace.slackTeamId, + ...(workspace.name ? { teamName: workspace.name } : {}), + ...(workspace.botUserId ? { botUserId: workspace.botUserId } : {}), + ...(workspace.installedBySlackUserId + ? { installedBy: workspace.installedBySlackUserId } + : {}), + botToken: this.decrypt(workspace.accessToken), + }; + } + + async markUninstalled(teamId: string): Promise { + const workspace = await this.db.workspace.findUnique({ + where: { slackTeamId: teamId }, + select: { id: true }, + }); + if (!workspace) return; + await this.db.$transaction([ + this.db.workspace.update({ + where: { id: workspace.id }, + data: { uninstalledAt: new Date(), accessToken: null }, + }), + this.db.reminder.updateMany({ + where: { + event: { workspaceId: workspace.id }, + status: ReminderStatus.PENDING, + }, + data: { status: ReminderStatus.CANCELLED }, + }), + ]); + } + + private encrypt(value: string): string { + if (!this.encryptionKey) return value; + const key = Buffer.from(this.encryptionKey, "base64"); + if (key.length !== 32) { + throw new Error("TOKEN_ENCRYPTION_KEY must be a base64-encoded 32-byte key."); + } + const iv = randomBytes(12); + const cipher = createCipheriv("aes-256-gcm", key, iv); + const encrypted = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]); + return [ + "v1", + iv.toString("base64"), + cipher.getAuthTag().toString("base64"), + encrypted.toString("base64"), + ].join("."); + } + + private decrypt(value: string): string { + if (!value.startsWith("v1.")) return value; + if (!this.encryptionKey) { + throw new Error("TOKEN_ENCRYPTION_KEY is required to decrypt OAuth tokens."); + } + const [, ivValue, tagValue, encryptedValue] = value.split("."); + if (!ivValue || !tagValue || !encryptedValue) + throw new Error("Invalid token payload."); + const key = Buffer.from(this.encryptionKey, "base64"); + const decipher = createDecipheriv( + "aes-256-gcm", + key, + Buffer.from(ivValue, "base64"), + ); + decipher.setAuthTag(Buffer.from(tagValue, "base64")); + return Buffer.concat([ + decipher.update(Buffer.from(encryptedValue, "base64")), + decipher.final(), + ]).toString("utf8"); + } +} diff --git a/src/tests/blocks.test.ts b/src/tests/blocks.test.ts new file mode 100644 index 0000000..1732b95 --- /dev/null +++ b/src/tests/blocks.test.ts @@ -0,0 +1,63 @@ +import { EventStatus, EventType } from "@prisma/client"; +import { describe, expect, it } from "vitest"; +import { renderEventCard } from "../blocks/event-card.blocks.js"; +import { eventFixture } from "./fixtures.js"; + +const text = (blocks: ReturnType) => JSON.stringify(blocks); + +describe("event card blocks", () => { + it("renders a lunch card", () => { + expect(text(renderEventCard(eventFixture()))).toContain("Lunch details"); + expect(text(renderEventCard(eventFixture()))).toContain("$15"); + }); + + it("renders a potluck card with claimed and needed items", () => { + const event = eventFixture({ + type: EventType.POTLUCK, + metadata: { neededCategories: ["Main dish", "Dessert"] }, + potluckItems: [ + { + id: "item_1", + eventId: "event_1", + category: "Dessert", + itemName: "Brownies", + claimedBySlackUserId: "U1", + servesCount: 8, + dietaryType: "Veg", + notes: null, + createdAt: new Date(), + updatedAt: new Date(), + }, + ], + }); + const output = text(renderEventCard(event)); + expect(output).toContain("Brownies"); + expect(output).toContain("Main dish"); + }); + + it("renders a weekend run card", () => { + const output = text( + renderEventCard( + eventFixture({ + type: EventType.WEEKEND_RUN, + metadata: { + distance: "10K", + pace: "Easy", + beginnerFriendly: true, + }, + }), + ), + ); + expect(output).toContain("10K"); + expect(output).toContain("Beginner-friendly"); + }); + + it("renders cancelled and closed states", () => { + expect( + text(renderEventCard(eventFixture({ status: EventStatus.CANCELLED }))), + ).toContain("cancelled"); + expect( + text(renderEventCard(eventFixture({ status: EventStatus.RSVP_CLOSED }))), + ).toContain("RSVPs are closed"); + }); +}); diff --git a/src/tests/env.test.ts b/src/tests/env.test.ts new file mode 100644 index 0000000..277bfed --- /dev/null +++ b/src/tests/env.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { loadEnv } from "../config/env.js"; + +const base = { + DATABASE_URL: "postgresql://teamloop:teamloop@localhost:5432/teamloop", + SLACK_SIGNING_SECRET: "signing-secret", +}; + +describe("environment configuration", () => { + it("accepts blank optional OAuth fields in local bot-token mode", () => { + const env = loadEnv({ + ...base, + SLACK_BOT_TOKEN: "xoxb-test", + SLACK_CLIENT_ID: "", + SLACK_CLIENT_SECRET: "", + SLACK_STATE_SECRET: "", + TOKEN_ENCRYPTION_KEY: "", + }); + expect(env.SLACK_CLIENT_ID).toBeUndefined(); + }); + + it("requires token encryption for OAuth mode", () => { + expect(() => + loadEnv({ + ...base, + SLACK_CLIENT_ID: "client-id", + SLACK_CLIENT_SECRET: "client-secret", + SLACK_STATE_SECRET: "a".repeat(32), + }), + ).toThrow("TOKEN_ENCRYPTION_KEY"); + }); +}); diff --git a/src/tests/event.service.test.ts b/src/tests/event.service.test.ts new file mode 100644 index 0000000..39f42e0 --- /dev/null +++ b/src/tests/event.service.test.ts @@ -0,0 +1,84 @@ +import { EventStatus, EventType, type PrismaClient } from "@prisma/client"; +import { describe, expect, it, vi } from "vitest"; +import { EventService } from "../services/event.service.js"; +import { eventFixture } from "./fixtures.js"; + +function database() { + const tx = { + workspace: { + upsert: vi.fn().mockResolvedValue({ id: "workspace_1" }), + }, + channel: { + upsert: vi.fn().mockResolvedValue({ id: "channel_1" }), + }, + event: { + create: vi.fn().mockResolvedValue({ id: "event_1" }), + update: vi.fn().mockResolvedValue({}), + findUniqueOrThrow: vi.fn().mockResolvedValue(eventFixture()), + }, + potluckItem: { createMany: vi.fn().mockResolvedValue({ count: 0 }) }, + auditLog: { create: vi.fn().mockResolvedValue({}) }, + reminder: { updateMany: vi.fn().mockResolvedValue({ count: 1 }) }, + }; + const db = { + ...tx, + event: { + ...tx.event, + findUnique: vi.fn().mockResolvedValue(eventFixture()), + }, + $transaction: vi.fn(async (callback: (client: typeof tx) => unknown) => + callback(tx), + ), + } as unknown as PrismaClient; + return { db, tx }; +} + +describe("EventService", () => { + it("creates an event and audit record", async () => { + const { db, tx } = database(); + const event = await new EventService(db).create({ + slackTeamId: "T1", + slackChannelId: "C1", + organizerSlackUserId: "U_ORGANIZER", + type: EventType.LUNCH, + title: "Lunch", + location: "Cafe", + eventStartsAt: new Date("2030-01-01T12:00:00Z"), + }); + expect(event.id).toBe("event_1"); + expect(tx.event.create).toHaveBeenCalledOnce(); + expect(tx.auditLog.create).toHaveBeenCalledOnce(); + }); + + it("updates an organizer-owned event", async () => { + const { db, tx } = database(); + await new EventService(db).update("event_1", "U_ORGANIZER", { + title: "Updated lunch", + }); + expect(tx.event.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ title: "Updated lunch" }), + }), + ); + }); + + it("rejects invalid organizer actions", async () => { + const { db } = database(); + await expect( + new EventService(db).cancel("event_1", "U_SOMEONE_ELSE"), + ).rejects.toThrow("Only the organizer"); + }); + + it("closes RSVP and cancels events without deleting them", async () => { + const { db, tx } = database(); + const service = new EventService(db); + await service.closeRsvp("event_1", "U_ORGANIZER"); + expect(tx.event.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ status: EventStatus.RSVP_CLOSED }), + }), + ); + await service.cancel("event_1", "U_ORGANIZER"); + expect(tx.reminder.updateMany).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/tests/fixtures.ts b/src/tests/fixtures.ts new file mode 100644 index 0000000..6e3a8c4 --- /dev/null +++ b/src/tests/fixtures.ts @@ -0,0 +1,31 @@ +import { EventStatus, EventType } from "@prisma/client"; +import type { EventWithRelations } from "../types/event.types.js"; + +export function eventFixture( + overrides: Partial = {}, +): EventWithRelations { + const base: EventWithRelations = { + id: "event_1", + workspaceId: "workspace_1", + channelId: "channel_1", + organizerSlackUserId: "U_ORGANIZER", + type: EventType.LUNCH, + title: "Team Lunch", + description: "Meet in the lobby.", + location: "Green Cafe", + eventStartsAt: new Date("2030-06-22T12:30:00.000Z"), + rsvpDeadlineAt: new Date("2030-06-22T10:00:00.000Z"), + status: EventStatus.OPEN, + slackMessageTs: "123.456", + slackChannelId: "C_TEAM", + metadata: { budget: "$15", foodPreferencesEnabled: true }, + createdAt: new Date("2030-06-20T00:00:00.000Z"), + updatedAt: new Date("2030-06-20T00:00:00.000Z"), + cancelledAt: null, + closedAt: null, + rsvps: [], + preferences: [], + potluckItems: [], + }; + return { ...base, ...overrides }; +} diff --git a/src/tests/reminder.service.test.ts b/src/tests/reminder.service.test.ts new file mode 100644 index 0000000..a93105a --- /dev/null +++ b/src/tests/reminder.service.test.ts @@ -0,0 +1,56 @@ +import { ReminderStatus, ReminderType, type PrismaClient } from "@prisma/client"; +import { describe, expect, it, vi } from "vitest"; +import { ReminderService } from "../services/reminder.service.js"; + +function database() { + return { + reminder: { + upsert: vi.fn().mockResolvedValue({}), + create: vi.fn().mockResolvedValue({ id: "manual_1" }), + findMany: vi.fn().mockResolvedValue([{ id: "due_1" }]), + updateMany: vi.fn().mockResolvedValue({ count: 1 }), + update: vi.fn().mockResolvedValue({}), + }, + } as unknown as PrismaClient; +} + +describe("ReminderService", () => { + it("creates default reminders idempotently", async () => { + const db = database(); + await new ReminderService(db).createDefaults({ + eventId: "event_1", + eventStartsAt: new Date(Date.now() + 48 * 60 * 60 * 1000), + rsvpDeadlineAt: new Date(Date.now() + 24 * 60 * 60 * 1000), + eventReminderMinutes: 60, + }); + expect(db.reminder.upsert).toHaveBeenCalledTimes(2); + }); + + it("fetches and atomically claims due reminders", async () => { + const db = database(); + const service = new ReminderService(db); + await expect(service.due()).resolves.toEqual([{ id: "due_1" }]); + await expect(service.claim("due_1")).resolves.toBe(true); + expect(db.reminder.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: "due_1", status: ReminderStatus.PENDING }, + }), + ); + }); + + it("marks reminders sent or failed", async () => { + const db = database(); + const service = new ReminderService(db); + await service.markSent("due_1"); + await service.markFailed("due_2", new Error("boom")); + expect(db.reminder.update).toHaveBeenCalledTimes(2); + }); + + it("creates a manual reminder", async () => { + const db = database(); + await new ReminderService(db).createManual("event_1"); + expect(db.reminder.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ type: ReminderType.MANUAL }), + }); + }); +}); diff --git a/src/tests/rsvp.service.test.ts b/src/tests/rsvp.service.test.ts new file mode 100644 index 0000000..03ed774 --- /dev/null +++ b/src/tests/rsvp.service.test.ts @@ -0,0 +1,48 @@ +import { EventStatus, RsvpStatus, type PrismaClient } from "@prisma/client"; +import { describe, expect, it, vi } from "vitest"; +import { RsvpService } from "../services/rsvp.service.js"; + +function database(status: EventStatus) { + return { + event: { findUnique: vi.fn().mockResolvedValue({ status }) }, + rSVP: { + upsert: vi.fn().mockResolvedValue({ id: "rsvp_1" }), + groupBy: vi.fn().mockResolvedValue([ + { status: RsvpStatus.GOING, _count: { _all: 2 } }, + { status: RsvpStatus.MAYBE, _count: { _all: 1 } }, + ]), + }, + } as unknown as PrismaClient; +} + +describe("RsvpService", () => { + it("creates or updates one RSVP per user", async () => { + const db = database(EventStatus.OPEN); + const service = new RsvpService(db); + await service.upsert("event_1", "U1", RsvpStatus.GOING); + expect(db.rSVP.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + where: { eventId_slackUserId: { eventId: "event_1", slackUserId: "U1" } }, + }), + ); + }); + + it.each([ + [EventStatus.RSVP_CLOSED, "RSVPs are closed"], + [EventStatus.CANCELLED, "cancelled"], + ])("prevents RSVP when event is %s", async (status, message) => { + await expect( + new RsvpService(database(status)).upsert("event_1", "U1", RsvpStatus.GOING), + ).rejects.toThrow(message); + }); + + it("counts RSVP statuses", async () => { + await expect( + new RsvpService(database(EventStatus.OPEN)).counts("event_1"), + ).resolves.toEqual({ + going: 2, + maybe: 1, + cannotMakeIt: 0, + }); + }); +}); diff --git a/src/tests/validation.test.ts b/src/tests/validation.test.ts new file mode 100644 index 0000000..2989b3e --- /dev/null +++ b/src/tests/validation.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it, vi } from "vitest"; +import { eventInputSchema } from "../utils/validation.js"; + +describe("event validation", () => { + it("accepts a future event with an earlier RSVP deadline", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2030-01-01T00:00:00.000Z")); + const result = eventInputSchema.safeParse({ + title: "Team Lunch", + date: "2030-01-02", + time: "12:00", + location: "Cafe", + rsvpDeadlineDate: "2030-01-02", + rsvpDeadlineTime: "10:00", + }); + expect(result.success).toBe(true); + vi.useRealTimers(); + }); + + it("rejects a past event", () => { + const result = eventInputSchema.safeParse({ + title: "Team Lunch", + date: "2020-01-02", + time: "12:00", + location: "Cafe", + rsvpDeadlineDate: "2020-01-02", + rsvpDeadlineTime: "10:00", + }); + expect(result.success).toBe(false); + }); + + it("rejects an RSVP deadline after the event", () => { + const result = eventInputSchema.safeParse({ + title: "Team Lunch", + date: "2035-01-02", + time: "12:00", + location: "Cafe", + rsvpDeadlineDate: "2035-01-02", + rsvpDeadlineTime: "13:00", + }); + expect(result.success).toBe(false); + }); +}); diff --git a/src/types/event.types.ts b/src/types/event.types.ts new file mode 100644 index 0000000..c540627 --- /dev/null +++ b/src/types/event.types.ts @@ -0,0 +1,38 @@ +import type { Event, EventType, PotluckItem, Preference, RSVP } from "@prisma/client"; +import type { Prisma } from "@prisma/client"; + +export type EventMetadata = Record; + +export interface CreateEventInput { + slackTeamId: string; + slackTeamName?: string; + slackChannelId: string; + slackChannelName?: string; + organizerSlackUserId: string; + type: EventType; + title: string; + description?: string; + location: string; + eventStartsAt: Date; + rsvpDeadlineAt?: Date; + metadata?: EventMetadata; +} + +export interface UpdateEventInput { + title?: string; + description?: string | null; + location?: string; + eventStartsAt?: Date; + rsvpDeadlineAt?: Date | null; + metadata?: EventMetadata; +} + +export type EventWithRelations = Event & { + rsvps: RSVP[]; + preferences: Preference[]; + potluckItems: PotluckItem[]; +}; + +export function metadataToJson(metadata: EventMetadata): Prisma.InputJsonValue { + return metadata; +} diff --git a/src/types/slack.types.ts b/src/types/slack.types.ts new file mode 100644 index 0000000..3546331 --- /dev/null +++ b/src/types/slack.types.ts @@ -0,0 +1,17 @@ +import type { ViewStateValue } from "@slack/bolt"; + +export interface CommandMetadata { + channelId: string; + channelName?: string; + userId: string; + teamId: string; + teamName?: string; + eventType: string; +} + +export type SlackViewValues = Record>; + +export interface EventActionValue { + eventId: string; + value?: string; +} diff --git a/src/utils/date.ts b/src/utils/date.ts new file mode 100644 index 0000000..ae813a5 --- /dev/null +++ b/src/utils/date.ts @@ -0,0 +1,19 @@ +const SLACK_DATE_FORMAT = "{date_long_pretty} at {time}"; + +export function toUtcDate(date: string, time: string): Date { + return new Date(`${date}T${time}:00.000Z`); +} + +export function slackDate(date: Date): string { + const unix = Math.floor(date.getTime() / 1000); + return ``; +} + +export function slackTime(date: Date): string { + const unix = Math.floor(date.getTime() / 1000); + return ``; +} + +export function minutesBefore(date: Date, minutes: number): Date { + return new Date(date.getTime() - minutes * 60_000); +} diff --git a/src/utils/errors.ts b/src/utils/errors.ts new file mode 100644 index 0000000..91eaab6 --- /dev/null +++ b/src/utils/errors.ts @@ -0,0 +1,32 @@ +export class TeamLoopError extends Error { + constructor( + message: string, + public readonly code: string, + ) { + super(message); + this.name = "TeamLoopError"; + } +} + +export class NotFoundError extends TeamLoopError { + constructor(message = "That event no longer exists.") { + super(message, "NOT_FOUND"); + } +} + +export class PermissionError extends TeamLoopError { + constructor(message = "Only the organizer can do that.") { + super(message, "FORBIDDEN"); + } +} + +export class EventStateError extends TeamLoopError { + constructor(message: string, code = "INVALID_EVENT_STATE") { + super(message, code); + } +} + +export function toUserMessage(error: unknown): string { + if (error instanceof TeamLoopError) return error.message; + return "Something went wrong. Please try again."; +} diff --git a/src/utils/text.ts b/src/utils/text.ts new file mode 100644 index 0000000..8d26ae1 --- /dev/null +++ b/src/utils/text.ts @@ -0,0 +1,16 @@ +export function escapeMrkdwn(value: string): string { + return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">"); +} + +export function truncate(value: string, maxLength: number): string { + if (value.length <= maxLength) return value; + return `${value.slice(0, Math.max(0, maxLength - 1)).trimEnd()}…`; +} + +export function titleCase(value: string): string { + return value + .toLowerCase() + .split("_") + .map((word) => `${word.charAt(0).toUpperCase()}${word.slice(1)}`) + .join(" "); +} diff --git a/src/utils/validation.ts b/src/utils/validation.ts new file mode 100644 index 0000000..433dc1a --- /dev/null +++ b/src/utils/validation.ts @@ -0,0 +1,46 @@ +import { z } from "zod"; +import { toUtcDate } from "./date.js"; + +export const eventInputSchema = z + .object({ + title: z.string().trim().min(1, "Activity title is required.").max(150), + date: z.string().date(), + time: z.string().regex(/^\d{2}:\d{2}$/), + location: z.string().trim().min(1, "Location is required.").max(300), + rsvpDeadlineDate: z.string().date(), + rsvpDeadlineTime: z.string().regex(/^\d{2}:\d{2}$/), + notes: z.string().trim().max(1_500).optional(), + reminderMinutes: z.coerce.number().int().min(0).max(10_080).optional(), + }) + .transform((value) => ({ + ...value, + eventStartsAt: toUtcDate(value.date, value.time), + rsvpDeadlineAt: toUtcDate(value.rsvpDeadlineDate, value.rsvpDeadlineTime), + })) + .superRefine((value, context) => { + if (value.eventStartsAt <= new Date()) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["date"], + message: "Please choose a future date and time.", + }); + } + if (value.rsvpDeadlineAt >= value.eventStartsAt) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["rsvpDeadlineDate"], + message: "RSVP deadline must be before the event starts.", + }); + } + }); + +export type ValidatedEventInput = z.infer; + +export function formatZodErrors(error: z.ZodError): Record { + const result: Record = {}; + for (const issue of error.issues) { + const key = issue.path[0]; + if (typeof key === "string" && !result[key]) result[key] = issue.message; + } + return result; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..bc2bc2a --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "outDir": "dist", + "rootDir": ".", + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "types": ["node", "vitest/globals"] + }, + "include": ["src/**/*.ts", "prisma/**/*.ts", "prisma.config.ts", "vitest.config.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..83dc7a4 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + include: ["src/tests/**/*.test.ts"], + coverage: { + reporter: ["text", "html"], + }, + }, +});