diff --git a/.env.example b/.env.example index 9713e5e..e50105a 100644 --- a/.env.example +++ b/.env.example @@ -16,6 +16,7 @@ ARCHIVE_CATEGORY_ID=archive_category_id_here MODERATORS_ROLE_IDS=role_id_1,role_id_2,role_id_3 REPEL_ROLE_ID=your_repel_role_id_here ONBOARDING_ROLE_ID=onboarding_role_id_here +TAG_ACCESS_ROLE_ID=tag_access_role_id_here # Optional Role IDs # ROLE_A_ID=optional_role_a_id @@ -29,4 +30,7 @@ GUIDES_TRACKER_PATH=guides-tracker.json ADVENT_OF_CODE_TRACKER_PATH=advent-of-code-tracker.json +DATABASE_URL=file:./data/webdev.db + + diff --git a/.env.production b/.env.production index d3660a6..2d1f624 100644 --- a/.env.production +++ b/.env.production @@ -19,6 +19,7 @@ ARCHIVE_CATEGORY_ID=837507969859977258 REPEL_ROLE_ID=1002411741776461844 MODERATORS_ROLE_IDS=849481536654803004 REGULAR_ROLE_ID=1383170370634514544 +TAG_ACCESS_ROLE_ID=1502128862224711805 # Other GUIDES_TRACKER_PATH=/app/data/guides-tracker.json diff --git a/.env.test b/.env.test index 0e25d88..08f34cd 100644 --- a/.env.test +++ b/.env.test @@ -25,7 +25,10 @@ ARCHIVE_CATEGORY_ID=your-archived-category-id REPEL_ROLE_ID=your-repel-role-id MODERATORS_ROLE_IDS=your-moderator-role-id REGULAR_ROLE_ID=your-regular-role-id +TAG_ACCESS_ROLE_ID=your-tag-access-role-id # Other GUIDES_TRACKER_PATH=guides-tracker.json -ADVENT_OF_CODE_TRACKER_PATH=test-advent-tracker.json \ No newline at end of file +ADVENT_OF_CODE_TRACKER_PATH=test-advent-tracker.json + +DATABASE_URL=test.db \ No newline at end of file diff --git a/.github/workflows/deploy-commands.yml b/.github/workflows/deploy-commands.yml index 3ee914e..6b8a41b 100644 --- a/.github/workflows/deploy-commands.yml +++ b/.github/workflows/deploy-commands.yml @@ -32,6 +32,6 @@ jobs: # Run deploy script inside the already running Docker container # .env file should already exist from main deployment echo "Deploying Discord commands..." - docker compose --profile prod exec bot-prod node dist/scripts/deploy-commands.js + docker compose exec bot node dist/scripts/deploy-commands.js echo "Discord commands deployment completed!" diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 469c9ae..8838890 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,58 +1,126 @@ -name: Build, Test and Deploy Discord Bot to VPS - +name: Deploy on: push: - branches: ['main'] - workflow_dispatch: # Allow manual deployment + branches: [main] + workflow_dispatch: + inputs: + ref: + description: 'Git ref (commit SHA or tag) to deploy. Leave blank for latest main.' + required: false + default: '' + +concurrency: + group: deploy-${{ github.ref }} + cancel-in-progress: false + +env: + MAX_BACKUPS: 4 + DIR: /home/${{ secrets.VPS_USER }}/webdev-bot-deploy jobs: - deploy: + lint: + name: Lint & Format Check runs-on: ubuntu-latest - steps: - - name: Checkout code + - name: Checkout uses: actions/checkout@v4 - - name: Read Node version - run: | - NODE_VERSION=$(cat .nvmrc | sed 's/v//') - echo "NODE_VERSION=$NODE_VERSION" >> $GITHUB_ENV + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Prisma generate (sanity check schema) + run: pnpm run prisma:generate + env: + DATABASE_URL: file:/tmp/ci.db - - name: Deploy to VPS - uses: appleboy/ssh-action@v1.0.3 + - name: Lint + run: pnpm lint + + - name: Format + run: pnpm fmt:check + + - name: Typecheck + run: pnpm typecheck + + deploy: + name: Deploy to VPS + runs-on: ubuntu-latest + needs: [lint] + permissions: + contents: read + steps: + - name: Deploy via SSH + uses: appleboy/ssh-action@v1 with: host: ${{ secrets.VPS_HOST }} username: ${{ secrets.VPS_USER }} key: ${{ secrets.VPS_SSH_KEY }} + envs: REPO_URL,REF script: | - cd /home/${{ secrets.VPS_USER }}/webdev-bot-deploy + set -eu + mkdir -p ${{ env.DIR }} + cd ${{ env.DIR }} - # Stash any local changes - git stash push -m "Auto-deploy $(date)" 2>/dev/null || true + if [ -d .git ]; then + git stash push -m "Auto-deploy $(date)" 2>/dev/null || true + git fetch origin + git checkout "$REF" + git reset --hard "origin/$REF" 2>/dev/null || git reset --hard "$REF" + else + git clone "$REPO_URL" . + git checkout "$REF" + fi - # Pull latest changes - git checkout main - git pull origin main + mkdir -p ${{ env.DIR }}/backups - # Read NODE_VERSION from .nvmrc - export NODE_VERSION=$(cat .nvmrc | sed 's/v//') + export NODE_VERSION=$(cat .nvmrc | tr -d 'v') echo "Using Node version: $NODE_VERSION" - # Create .env file with secrets - # Public config comes from .env.production (committed to repo) - # NODE_ENV=production is set in docker-compose.yml - cat > .env << EOF + cat > .env <<'EOF' DISCORD_TOKEN=${{ secrets.DISCORD_TOKEN }} CLIENT_ID=${{ secrets.CLIENT_ID }} EOF + chmod 600 .env + + # Backup the SQLite DB before touching anything + if docker volume inspect webdev-bot_guides-data >/dev/null 2>&1; then + docker run --rm \ + -v webdev-bot_guides-data:/data \ + -v ${{ env.DIR }}/backups:/backup \ + alpine sh -c "if [ -f /data/db.sqlite ]; then cp /data/db.sqlite /backup/db-\$(date +%Y%m%d%H%M%S).sqlite; fi" + ls -1t ${{ env.DIR }}/backups/*.sqlite 2>/dev/null \ + | tail -n +$((env.MAX_BACKUPS+1)) \ + | xargs -r rm -- + fi + + docker compose config >/dev/null + docker compose build bot + + if ! docker compose run --rm migrate; then + echo "Migration failed — leaving existing bot container untouched." + exit 1 + fi - # Stop any existing containers - docker compose down || true + docker compose up -d --remove-orphans bot - # Build and start production container with profile - # NODE_ENV=production is explicitly set in docker-compose.yml bot-prod service - docker compose --profile prod up -d --build + sleep 8 + if ! docker compose ps bot --status running | grep -q bot; then + echo "Bot container is not running after deploy!" + docker compose logs --tail=50 bot + exit 1 + fi - # Check status - echo "Deployment completed. Container status:" docker compose ps + docker image prune -f + env: + REPO_URL: https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}.git + REF: ${{ github.event.inputs.ref != '' && github.event.inputs.ref || 'main' }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7a717f8..6bdbde9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -31,6 +31,11 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Prisma generate (sanity check schema) + run: pnpm run prisma:generate + env: + DATABASE_URL: file:/tmp/ci.db + - name: Lint run: pnpm lint diff --git a/.gitignore b/.gitignore index 9de0913..ecd9d52 100644 --- a/.gitignore +++ b/.gitignore @@ -30,4 +30,12 @@ docker-compose.yml # terraform **/.terraform/ *.tfstate -*.tfstate.* \ No newline at end of file +*.tfstate.* + +# prisma +src/generated + +# Database files +*.db +*.sqlite +*.sqlite3 \ No newline at end of file diff --git a/DOCKER.md b/DOCKER.md index 432b0f5..0724d9b 100644 --- a/DOCKER.md +++ b/DOCKER.md @@ -36,7 +36,7 @@ pnpm run docker:dev Or using docker-compose directly: ```bash -docker compose --profile dev up +docker compose -f docker-compose.dev.yml up ``` In development mode: @@ -48,9 +48,9 @@ In development mode: To stop the development container: ```bash -pnpm run docker:stop +pnpm run docker:dev:stop # or -docker compose --profile dev down +docker compose -f docker-compose.dev.yml down ``` ### Production Mode @@ -64,7 +64,7 @@ pnpm run docker:prod Or using docker-compose directly: ```bash -docker compose --profile prod up -d +docker compose up -d ``` In production mode: @@ -76,7 +76,7 @@ In production mode: To stop the production container: ```bash -docker compose --profile prod down +docker compose down ``` ## Manual Docker Commands @@ -128,12 +128,12 @@ docker run -it \ 1. Check if environment variables are set correctly: ```bash - docker compose --profile dev run --rm bot-dev env | grep DISCORD + docker compose -f docker-compose.dev.yml run --rm bot-dev env | grep DISCORD ``` 2. View container logs: ```bash - docker compose --profile dev logs bot-dev + docker compose -f docker-compose.dev.yml logs bot-dev # or docker logs webdev-bot-dev ``` @@ -159,7 +159,7 @@ If code changes aren't being detected in development mode: 2. Restart the container: ```bash - docker compose --profile dev restart + docker compose -f docker-compose.dev.yml restart ``` ### Image Size Concerns @@ -189,8 +189,8 @@ docker compose build --no-cache ## Best Practices 1. **Never commit `.env`** - Keep your secrets secure -2. **Use production profile for deployment** - Smaller, more secure images -3. **Keep development profile for local testing** - Faster iteration with hot reload +2. **Use production compose for deployment** - Smaller, more secure images +3. **Keep development compose for local testing** - Faster iteration with hot reload 4. **Node version is managed in `.nvmrc`** - Update `.nvmrc` to change Node version for Docker 5. **Regularly update the base image** - Rebuild images after updating `.nvmrc` 6. **Monitor container resources** - Use `docker stats` to check resource usage @@ -218,11 +218,11 @@ docker compose down -v View container logs in real-time: ```bash -docker compose --profile dev logs -f bot-dev +docker compose -f docker-compose.dev.yml logs -f bot-dev ``` Execute commands inside the container: ```bash -docker compose --profile dev exec bot-dev sh +docker compose -f docker-compose.dev.yml exec bot-dev sh ``` diff --git a/Dockerfile b/Dockerfile index c7b375d..2c11a21 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,72 +1,94 @@ -# Base stage - Setup Node.js and pnpm -# Node version is read from .nvmrc at build time via NODE_VERSION build arg ARG NODE_VERSION=22.20.0 FROM node:${NODE_VERSION}-alpine AS base -# Install pnpm -RUN corepack enable && corepack prepare pnpm@10.17.1 --activate +ENV PNPM_HOME="/pnpm" +ENV PATH="$PNPM_HOME:$PATH" +ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 + +RUN corepack enable WORKDIR /app -# Dependencies stage - Install production dependencies only +# --- Production dependencies only --- FROM base AS deps +RUN apk add --no-cache python3 make g++ + +ENV PRISMA_SKIP_POSTINSTALL_GENERATE=1 + COPY package.json pnpm-lock.yaml ./ -RUN pnpm install --frozen-lockfile --production --ignore-scripts +RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ + pnpm install --frozen-lockfile --production -# Dev dependencies stage - Install all dependencies +# --- Full dependencies --- FROM base AS deps-dev +RUN apk add --no-cache python3 make g++ + +ENV PRISMA_SKIP_POSTINSTALL_GENERATE=1 + COPY package.json pnpm-lock.yaml ./ -RUN pnpm install --frozen-lockfile --ignore-scripts +RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ + pnpm install --frozen-lockfile -# Build stage - Compile TypeScript +# --- Build --- FROM deps-dev AS build +# These files are required by prisma.config.ts. +# Keeping them separate allows Prisma generate to be cached +# when only application source code changes. +COPY prisma ./prisma +COPY prisma.config.ts ./ +COPY src/loadEnvFile.ts ./src/loadEnvFile.ts + +ENV DATABASE_URL="file:/tmp/build.db" + +RUN pnpm exec prisma generate + COPY . . RUN pnpm run build -# Production stage - Minimal runtime image +# --- Production runtime --- FROM base AS production ENV NODE_ENV=production -# Copy production dependencies from deps stage -COPY --from=deps /app/node_modules ./node_modules +# Production dependencies +COPY --from=deps --chown=node:node \ + /app/node_modules ./node_modules + +COPY --from=build --chown=node:node \ + /app/src/generated/prisma ./src/generated/prisma -# Copy built application COPY --from=build /app/dist ./dist COPY package.json ./ - -# Copy environment config file (public, non-secret) COPY .env.production ./ -# Copy static assets (guides, tips, etc.) from the build stage COPY --from=build /app/assets ./assets +COPY --from=build /app/prisma ./prisma +COPY --from=build /app/prisma.config.ts ./prisma.config.ts +COPY --from=build /app/src/loadEnvFile.ts ./src/loadEnvFile.ts -# Create data directory and set permissions for node user RUN mkdir -p /app/data && chown -R node:node /app/data -# Run as non-root user for security USER node CMD ["node", "dist/index.js"] -# Development stage - Full dev environment with hot reload +# --- Development --- FROM deps-dev AS development ENV NODE_ENV=development COPY . . -# Create data directory and set permissions for node user +RUN pnpm exec prisma generate + RUN mkdir -p /app/data && chown -R node:node /app/data -# Run as non-root user for security USER node -CMD ["pnpm", "run", "dev"] - +CMD ["pnpm", "run", "dev"] \ No newline at end of file diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..c91944b --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,29 @@ +services: + bot-dev: + build: + context: . + target: development + args: + NODE_VERSION: ${NODE_VERSION} + container_name: webdev-bot-dev + restart: unless-stopped + env_file: + - .env + environment: + - NODE_ENV=development + - GUIDES_TRACKER_PATH=/app/data/guides-tracker.json + - DATABASE_URL=file:/app/data/db.sqlite + volumes: + - ./src:/app/src:ro + - ./scripts:/app/scripts:ro + - ./prisma:/app/prisma:ro + - ./prisma.config.ts:/app/prisma.config.ts:ro + - ./tsconfig.json:/app/tsconfig.json:ro + - ./package.json:/app/package.json:ro + - ./pnpm-lock.yaml:/app/pnpm-lock.yaml:ro + - ./.env.production:/app/.env.production:ro + - guides-data:/app/data + +volumes: + guides-data: + driver: local diff --git a/docker-compose.yml b/docker-compose.yml index 4e3e50d..ed2142f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,55 +1,37 @@ services: - # Production service - optimized runtime - bot-prod: + bot: + image: webdev-bot:latest build: context: . target: production args: NODE_VERSION: ${NODE_VERSION} - container_name: webdev-bot-prod + container_name: webdev-bot restart: unless-stopped + depends_on: + migrate: + condition: service_completed_successfully env_file: - .env environment: - NODE_ENV=production + - DATABASE_URL=file:/app/data/db.sqlite volumes: - # Mount environment config file - ./.env.production:/app/.env.production:ro - # Persist tracker data - guides-data:/app/data - profiles: - - prod - # Development service - hot reload enabled - bot-dev: - build: - context: . - target: development - args: - NODE_VERSION: ${NODE_VERSION} - container_name: webdev-bot-dev - restart: unless-stopped + migrate: + image: webdev-bot:latest + environment: + - DATABASE_URL=file:/app/data/db.sqlite env_file: - .env - environment: - - NODE_ENV=development - - GUIDES_TRACKER_PATH=/app/data/guides-tracker.json + command: ['pnpm', 'run', 'migrate:deploy'] + restart: 'no' volumes: - # Mount source code for hot reload - - ./src:/app/src:ro - # Mount config files - - ./tsconfig.json:/app/tsconfig.json:ro - - ./biome.json:/app/biome.json:ro - # Mount package files (in case dependencies change) - - ./package.json:/app/package.json:ro - - ./pnpm-lock.yaml:/app/pnpm-lock.yaml:ro - # Mount environment config files - - ./.env.production:/app/.env.production:ro - # Persist guides tracker data - guides-data:/app/data - profiles: - - dev + user: 'node' volumes: guides-data: - driver: local \ No newline at end of file + driver: local diff --git a/package.json b/package.json index ae49473..dae966f 100644 --- a/package.json +++ b/package.json @@ -11,9 +11,10 @@ "start": "node dist/index.js", "dev": "tsx watch src/index.ts", "deploy": "tsx src/scripts/deploy-commands.ts", - "docker:build": "NODE_VERSION=$(cat .nvmrc | tr -d 'v') docker compose build", - "docker:dev": "NODE_VERSION=$(cat .nvmrc | tr -d 'v') docker compose --profile dev up", - "docker:prod": "NODE_VERSION=$(cat .nvmrc | tr -d 'v') docker compose --profile prod up -d", + "docker:build": "NODE_VERSION=$(cat .nvmrc | tr -d 'v') docker compose -f docker-compose.dev.yml build", + "docker:dev": "NODE_VERSION=$(cat .nvmrc | tr -d 'v') docker compose -f docker-compose.dev.yml up -d", + "docker:dev:stop": "docker compose -f docker-compose.dev.yml down", + "docker:prod": "NODE_VERSION=$(cat .nvmrc | tr -d 'v') docker compose build bot && docker compose run --rm migrate && docker compose up -d bot", "docker:stop": "docker compose down", "lint": "oxlint", "lint:fix": "oxlint --fix", @@ -22,19 +23,28 @@ "typecheck": "tsc --noEmit", "test": "tsx --test '**/*.test.ts'", "test:ci": "NODE_ENV=test node --test \"dist/**/*.test.js\"", - "prepare": "husky", + "prepare": "husky || true", "pre-commit": "lint-staged", "sync-guides": "tsx src/scripts/sync-guides.ts", - "sync-guides:init": "tsx src/scripts/sync-guides.ts --initialize" + "sync-guides:init": "tsx src/scripts/sync-guides.ts --initialize", + "prisma:generate": "prisma generate", + "migrate:dev": "prisma migrate dev", + "migrate:deploy": "prisma migrate deploy" }, "dependencies": { + "@prisma/adapter-better-sqlite3": "^7.8.0", + "@prisma/client": "^7.8.0", "discord.js": "^14.26.4", + "dotenv": "^17.4.2", + "lru-cache": "^11.5.2", "node-cron": "^4.6.0", + "prisma": "^7.8.0", "typescript": "^6.0.3", "web-features": "^3.32.0" }, "devDependencies": { - "@types/node": "^24.13.2", + "@types/better-sqlite3": "^7.6.13", + "@types/node": "^24.13.3", "@types/node-cron": "^3.0.11", "husky": "^9.1.7", "lint-staged": "^17.0.8", @@ -49,5 +59,10 @@ "*.{js,ts}": "pnpm run lint", "*.{js,ts,json}": "oxfmt --no-error-on-unmatched-pattern" }, - "packageManager": "pnpm@10.17.1" + "packageManager": "pnpm@10.17.1", + "pnpm": { + "onlyBuiltDependencies": [ + "better-sqlite3" + ] + } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 106fda5..1d598ca 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,12 +8,27 @@ importers: .: dependencies: + '@prisma/adapter-better-sqlite3': + specifier: ^7.8.0 + version: 7.9.1 + '@prisma/client': + specifier: ^7.8.0 + version: 7.9.1(prisma@7.9.1(@types/react@19.2.18)(better-sqlite3@12.11.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3))(typescript@6.0.3) discord.js: specifier: ^14.26.4 version: 14.26.4 + dotenv: + specifier: ^17.4.2 + version: 17.4.2 + lru-cache: + specifier: ^11.5.2 + version: 11.5.2 node-cron: specifier: ^4.6.0 version: 4.6.0 + prisma: + specifier: ^7.8.0 + version: 7.9.1(@types/react@19.2.18)(better-sqlite3@12.11.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3) typescript: specifier: ^6.0.3 version: 6.0.3 @@ -21,9 +36,12 @@ importers: specifier: ^3.32.0 version: 3.32.0 devDependencies: + '@types/better-sqlite3': + specifier: ^7.6.13 + version: 7.6.13 '@types/node': - specifier: ^24.13.2 - version: 24.13.2 + specifier: ^24.13.3 + version: 24.13.3 '@types/node-cron': specifier: ^3.0.11 version: 3.0.11 @@ -47,7 +65,7 @@ importers: version: 1.9.0 tsup: specifier: ^8.5.1 - version: 8.5.1(tsx@4.23.0)(typescript@6.0.3)(yaml@2.9.0) + version: 8.5.1(jiti@2.7.0)(tsx@4.23.0)(typescript@6.0.3)(yaml@2.9.0) tsx: specifier: ^4.23.0 version: 4.23.0 @@ -82,6 +100,20 @@ packages: resolution: {integrity: sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw==} engines: {node: '>=16.11.0'} + '@electric-sql/pglite-socket@0.1.3': + resolution: {integrity: sha512-LAciWM0M1dCL8hlsxu2venbVZcdxema0BtDfpWYVqr+Y468UADw0pFWidhKw1M8sfJ8rdLT71tjMmnirf/IZRQ==} + hasBin: true + peerDependencies: + '@electric-sql/pglite': 0.4.3 + + '@electric-sql/pglite-tools@0.3.3': + resolution: {integrity: sha512-AlzLJTRJ8+UFgK8CmxIpyIpJ0+YaFw02IiOSdYrqxwPXdSyeIShz8aa9Tq+tYFXdPwcaMp/Fc80mQZ1dkOQ/wg==} + peerDependencies: + '@electric-sql/pglite': 0.4.3 + + '@electric-sql/pglite@0.4.3': + resolution: {integrity: sha512-ichuWTgtd4mOM1G4SpyGJa5trT03lWbMypDV0fUXUCXg5hiHqVAz/bZyV68NqmkLB7WcYmj1RMJVSp8HV/v/ZQ==} + '@esbuild/aix-ppc64@0.27.7': resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} engines: {node: '>=18'} @@ -685,6 +717,143 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} + '@prisma/adapter-better-sqlite3@7.9.1': + resolution: {integrity: sha512-EoAk+crcwbqjdhwjVehj1TaMx7X/zsR49cxjeukFs/g5frdJYYM3k8Q3xNQp032nvWb1L+RMBytn/4b/EYrNoA==} + + '@prisma/client-runtime-utils@7.9.1': + resolution: {integrity: sha512-mVIBGYdO5CFmK0HvjxrtfIyQQcPdb88pSCeVQriVQPVZyDovIWblpHfOgcS8QO187j3QF0ePArH8qPhp0AU2vg==} + + '@prisma/client@7.9.1': + resolution: {integrity: sha512-+xgrh2EhJVF79wC0yX5G4PI1Rdcm7Qn/nekNQ+t/O153wtNggruHal+fXHSa0QE+Tp/Cw5wvxeCEhZZ59xGm8Q==} + engines: {node: ^20.19 || ^22.12 || >=24.0} + peerDependencies: + prisma: '*' + typescript: '>=5.4.0' + peerDependenciesMeta: + prisma: + optional: true + typescript: + optional: true + + '@prisma/config@7.9.1': + resolution: {integrity: sha512-4znKhxTmXmuPye9Z6pbIyYb5VZlkZ05qG1L6Dr4g+7oTwc6V50Bs9XirFBDdjWt+H/AabMn9aUnxBcvj8z05aA==} + + '@prisma/debug@7.2.0': + resolution: {integrity: sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw==} + + '@prisma/debug@7.9.1': + resolution: {integrity: sha512-/cpVZ4itxtcgB8GHBvZtcmuEjq+lWsLrRJxFMbwZrT1RIdtuKmUm7PPGo/wzfbYpBrk+9WmmBE8CHJw2rybKDQ==} + + '@prisma/dev@0.24.17': + resolution: {integrity: sha512-UvdZzmpFwknnfreh6Jije84ekkYGPYEJhXG1tFzCsCfQyzJifrOo38eZc0qajzvaC6OLUOrN9ML5XfCnEZL9DA==} + + '@prisma/driver-adapter-utils@7.9.1': + resolution: {integrity: sha512-vmHehG7nn/heW32DXXpp13DxxAxVVe6n250oEt3dOL2E/4bt3olktKZN0mzSuxMMronyMSkbeW2uCOn3F4g8RQ==} + + '@prisma/engines-version@7.9.0-1.e922089b7d7502aff4249d5da3420f6fa55fc6ad': + resolution: {integrity: sha512-2BsPPFksz3CQUXG6af3rVCtJKg6+JJGJTtfgu2fU8DdXhOfkBjulCq8mwybCd6ge0/jhZq2kOtLAbmUDMyI1nA==} + + '@prisma/engines@7.9.1': + resolution: {integrity: sha512-UprXSMNXx2NF5ow4pqaQtE8OuBz6K78B0wc0tn2L28G5r933iWp1DR9Do2qWrsNvvFIP3x6mpEWnQtckMO0Uhg==} + + '@prisma/fetch-engine@7.9.1': + resolution: {integrity: sha512-9DwxrNTeT25Orbu9CWh0CZvVlyY1lmscpbaeLZcOnuR7zcuFrt91YSmmOfIm7zJ08YOZ6mVzURKwLoMwEBcK8w==} + + '@prisma/get-platform@7.2.0': + resolution: {integrity: sha512-k1V0l0Td1732EHpAfi2eySTezyllok9dXb6UQanajkJQzPUGi3vO2z7jdkz67SypFTdmbnyGYxvEvYZdZsMAVA==} + + '@prisma/get-platform@7.9.1': + resolution: {integrity: sha512-PK8R60YZRQvYxBrGG9i7l2/rFyzy+2MuI1dKtmtrCqPH8YpiJx/MfiC7LRzX5786rZDEv7BngcjfIJW4/9ADuw==} + + '@prisma/query-plan-executor@7.2.0': + resolution: {integrity: sha512-EOZmNzcV8uJ0mae3DhTsiHgoNCuu1J9mULQpGCh62zN3PxPTd+qI9tJvk5jOst8WHKQNwJWR3b39t0XvfBB0WQ==} + + '@prisma/streams-local@0.1.11': + resolution: {integrity: sha512-0TcebL559MByKqTJ+SsrFIEg228iw8UCVRFckzgfRSiJqczhs+MuAgWOF9lnOIV/IVqvu+KMnFTH0eDeTQMpUg==} + engines: {bun: '>=1.2.0', node: '>=22.0.0'} + + '@prisma/studio-core@0.33.0': + resolution: {integrity: sha512-V2fX/nKEymNTrHXwfP26PGjoLStO35Ogu+ex7CFJbLrMYEcZxxZpiSNOs7px23Hk5mzLWvM5RsqG6Ka+rha+wg==} + engines: {node: ^20.19 || ^22.12 || >=24.0, pnpm: '8'} + peerDependencies: + '@types/react': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + + '@radix-ui/primitive@1.1.3': + resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} + + '@radix-ui/react-compose-refs@1.1.2': + resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-primitive@2.1.3': + resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slot@1.2.3': + resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-toggle@1.1.10': + resolution: {integrity: sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-use-controllable-state@1.2.2': + resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-effect-event@0.0.2': + resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-layout-effect@1.1.1': + resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@rollup/rollup-android-arm-eabi@4.52.5': resolution: {integrity: sha512-8c1vW4ocv3UOMp9K+gToY5zL2XiiVw3k7f1ksf4yO1FlDFQ1C2u72iACFnSOceJFsWskc2WZNqeRhFRPzv+wtQ==} cpu: [arm] @@ -811,18 +980,101 @@ packages: resolution: {integrity: sha512-xzvBr1Q1c4lCe7i6sRnrofxeO1QTP/LKQ6A6qy0iB4x5yfiSfARMEQEghojzTNALDTcv8En04qYNIco9/K9eZQ==} engines: {node: '>=v14.0.0', npm: '>=7.0.0'} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@types/better-sqlite3@7.6.13': + resolution: {integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==} + + '@types/d3-array@3.0.3': + resolution: {integrity: sha512-Reoy+pKnvsksN0lQUlcH6dOGjRZ/3WRwXR//m+/8lt1BXeI4xyaUZoqULNjyXXRuh0Mj4LNpkCvhUpQlY3X5xQ==} + + '@types/d3-color@3.1.0': + resolution: {integrity: sha512-HKuicPHJuvPgCD+np6Se9MQvS6OCbJmOjGvylzMJRlDwUXjKTTXs6Pwgk79O09Vj/ho3u1ofXnhFOaEWWPrlwA==} + + '@types/d3-delaunay@6.0.1': + resolution: {integrity: sha512-tLxQ2sfT0p6sxdG75c6f/ekqxjyYR0+LwPrsO1mbC9YDBzPJhs2HbJJRrn8Ez1DBoHRo2yx7YEATI+8V1nGMnQ==} + + '@types/d3-format@3.0.1': + resolution: {integrity: sha512-5KY70ifCCzorkLuIkDe0Z9YTf9RR2CjBX1iaJG+rgM/cPP+sO+q9YdQ9WdhQcgPj1EQiJ2/0+yUkkziTG6Lubg==} + + '@types/d3-geo@3.1.0': + resolution: {integrity: sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==} + + '@types/d3-interpolate@3.0.1': + resolution: {integrity: sha512-jx5leotSeac3jr0RePOH1KdR9rISG91QIE4Q2PYTu4OymLTZfA3SrnURSLzKH48HmXVUru50b8nje4E79oQSQw==} + + '@types/d3-path@3.1.1': + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + + '@types/d3-scale@4.0.2': + resolution: {integrity: sha512-Yk4htunhPAwN0XGlIwArRomOjdoBFXC3+kCxK2Ubg7I9shQlVSJy/pG/Ht5ASN+gdMIalpk8TJ5xV74jFsetLA==} + + '@types/d3-shape@3.1.7': + resolution: {integrity: sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==} + + '@types/d3-time-format@2.1.0': + resolution: {integrity: sha512-/myT3I7EwlukNOX2xVdMzb8FRgNzRMpsZddwst9Ld/VFe6LyJyRp0s32l/V9XoUzk+Gqu56F/oGk6507+8BxrA==} + + '@types/d3-time@3.0.0': + resolution: {integrity: sha512-sZLCdHvBUcNby1cB6Fd3ZBrABbjz3v1Vm90nysCQ6Vt7vd6e/h9Lt7SiJUoEX0l4Dzc7P5llKyhqSi1ycSf1Hg==} + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/geojson@7946.0.16': + resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} + + '@types/lodash@4.17.25': + resolution: {integrity: sha512-+K1NIO8I+F9/wNulfVvu23QYd0Pe9/OCqRrim4NoYIf1VoEDL90Ve4ClzpyqBLc7NpGGWRvYNCKZ1BE/Jpf8dQ==} + '@types/node-cron@3.0.11': resolution: {integrity: sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg==} - '@types/node@24.13.2': - resolution: {integrity: sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==} + '@types/node@24.13.3': + resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==} + + '@types/react@19.2.18': + resolution: {integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==} '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@visx/curve@4.0.1-alpha.0': + resolution: {integrity: sha512-jRu61Uz274pV1zyioXmboyrLutYbnKsgjj4njSGCnhdXj5GkZvZbg+ThDb6oOzoAnJOBRLz4rzPlWvNJOzuVMg==} + + '@visx/event@4.0.1-alpha.0': + resolution: {integrity: sha512-EQqCMSv/s8NbFjo+hz3FKsvvYfP+2QslsFJ/24/O5l/W+7UC6J6aAvO0ujVwrTwdYbuQ+vhxKi1xdPdKR/qj1g==} + + '@visx/grid@4.0.1-alpha.0': + resolution: {integrity: sha512-rycutGmTHO+znNdPumheWMglm7YfpffvRwUkVy5zy4WoORIuKTMkDxwnOzHG2xMxU3EE/YCd37xFV5AxA30yeg==} + peerDependencies: + react: ^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0 + + '@visx/group@4.0.1-alpha.0': + resolution: {integrity: sha512-V19l7iQ7jccBv8kao/EByuI6o4xtxzzLV9nqVI1hRvmdzTVsuLpqlwzYCZUXJaTVvUWf8s4D2SQFjGkj/Nw+0w==} + peerDependencies: + react: ^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0 + + '@visx/point@4.0.1-alpha.0': + resolution: {integrity: sha512-ijTfr/Nx09f03vIj9nyTr3z4Xth4Y75427UaogJh6dnIRLMEFHQOwNu791sbfiNj0a+ZXuaE32h0vKrFe4/8Qg==} + + '@visx/responsive@4.0.1-alpha.0': + resolution: {integrity: sha512-o+1zGywQZY0+yOx3Iw87wc4bbPJRr/HnIukTwfOz4UVyj9pB1OQNVHB7OORO1+LBHJceWpB31co/ZV9KHncKrA==} + peerDependencies: + react: ^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0 + + '@visx/scale@4.0.1-alpha.0': + resolution: {integrity: sha512-nzjeE87vFSAXGWFiiNfBpNLAf0Q8Qmf6syvKLjqNi4kGZkdhbUll3E/59YsgWXmjM8+llPLWzGsP+JPvo5eq1A==} + + '@visx/shape@4.0.1-alpha.0': + resolution: {integrity: sha512-62QeiVNmPlterQGwhkEDcbq7M0MqY0lBsK5QKXtM9ZoPZWkuGV3aykA3+Xu20B2FAvyJq4LqJzBc7Sxr+EAdbA==} + peerDependencies: + react: ^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0 + + '@visx/vendor@4.0.0-alpha.0': + resolution: {integrity: sha512-6I+MuqXBcv9jnlcVowHoHKSdk9gXTWkHLKyqBwRWg7LY6A3Ei8SHfubpqGV5rBUSppxMq2RszPJUS6w+H0YgmQ==} + '@vladfrangu/async_event_emitter@2.4.7': resolution: {integrity: sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g==} engines: {node: '>=v14.0.0', npm: '>=7.0.0'} @@ -832,6 +1084,9 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + ansi-escapes@7.3.0: resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==} engines: {node: '>=18'} @@ -863,13 +1118,33 @@ packages: resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} engines: {node: '>=8'} + aws-ssl-profiles@1.1.2: + resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==} + engines: {node: '>= 6.0.0'} + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + better-result@2.10.0: + resolution: {integrity: sha512-oQhh0y1qo2/ZKdAAEvHZAqKKiHOFU5k/bW96fE2ScgQOVkJRiHwB+nOS1SgFsYqRlxMDWvefXi9Q3px7QvgNDw==} + + better-sqlite3@12.11.1: + resolution: {integrity: sha512-dq9AtApgg5PGFtBzPFSBl3HZQjHok5gaQCM6zh2Yk0aSmDCs1CbnVI8/HgASQkNKsWFpseIO9beg5xxpYhbIfA==} + engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x || 26.x} + binary-extensions@2.3.0: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} + bindings@1.5.0: + resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} + + bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + brace-expansion@2.0.2: resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} @@ -877,12 +1152,23 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + bundle-require@5.1.0: resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} peerDependencies: esbuild: '>=0.18' + c12@3.3.4: + resolution: {integrity: sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA==} + peerDependencies: + magicast: '*' + peerDependenciesMeta: + magicast: + optional: true + cac@6.7.14: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} @@ -895,6 +1181,16 @@ packages: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} + chokidar@5.0.0: + resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} + engines: {node: '>= 20.19.0'} + + chownr@1.1.4: + resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + + classnames@2.5.1: + resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==} + cli-cursor@5.0.0: resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} engines: {node: '>=18'} @@ -921,6 +1217,9 @@ packages: confbox@0.1.8: resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + confbox@0.2.4: + resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} + consola@3.4.2: resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} engines: {node: ^14.18.0 || >=16.10.0} @@ -929,6 +1228,57 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + d3-array@3.2.1: + resolution: {integrity: sha512-gUY/qeHq/yNqqoCKNq4vtpFLdoCdvyNpWoC/KNjhGbhDuQpAM9sIQQKkXSNpXa9h5KySs/gzm7R88WkUutgwWQ==} + engines: {node: '>=12'} + + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-delaunay@6.0.2: + resolution: {integrity: sha512-IMLNldruDQScrcfT+MWnazhHbDJhcRJyOEBAJfwQnHle1RPh6WDuLvxNArUju2VSMSUuKlY5BGHRJ2cYyoFLQQ==} + engines: {node: '>=12'} + + d3-format@3.1.0: + resolution: {integrity: sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==} + engines: {node: '>=12'} + + d3-geo@3.1.0: + resolution: {integrity: sha512-JEo5HxXDdDYXCaWdwLRt79y7giK8SbhZJbFWXqbRTolCHFI5jRqteLzCsq51NKbUoX0PjBVSohxrx+NoOUujYA==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -938,6 +1288,35 @@ packages: supports-color: optional: true + decompress-response@6.0.0: + resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} + engines: {node: '>=10'} + + deep-extend@0.6.0: + resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} + engines: {node: '>=4.0.0'} + + deepmerge-ts@7.1.5: + resolution: {integrity: sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==} + engines: {node: '>=16.0.0'} + + defu@6.1.7: + resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + + delaunator@5.1.0: + resolution: {integrity: sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==} + + denque@2.1.0: + resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} + engines: {node: '>=0.10'} + + destr@2.0.5: + resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + dir-glob@3.0.1: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} @@ -949,9 +1328,19 @@ packages: resolution: {integrity: sha512-4oBp8tc6Kf8IDBwAHhbsMaAqx1b5fob9SNasZT7V6yyyUydoO5i5fGuX7TmvRtR+q/WgKRnRViRoAWnG7fNyvA==} engines: {node: '>=18'} + dotenv@17.4.2: + resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} + engines: {node: '>=12'} + eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + effect@3.20.0: + resolution: {integrity: sha512-qMLfDJscrNG8p/aw+IkT9W7fgj50Z4wG5bLBy0Txsxz8iUHjDIkOgO3SV0WZfnQbNG2VJYb0b+rDLMrhM4+Krw==} + + elkjs@0.11.1: + resolution: {integrity: sha512-zxxR9k+rx5ktMwT/FwyLdPCrq7xN6e4VGGHH8hA01vVYKjTFik7nHOxBnAYtrgYUB1RpAiLvA1/U2YraWxyKKg==} + emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} @@ -961,6 +1350,17 @@ packages: emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + empathic@2.0.0: + resolution: {integrity: sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==} + engines: {node: '>=14'} + + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + + env-paths@3.0.0: + resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + environment@1.1.0: resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} engines: {node: '>=18'} @@ -978,6 +1378,20 @@ packages: eventemitter3@5.0.4: resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + expand-template@2.0.3: + resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} + engines: {node: '>=6'} + + exsolve@1.1.1: + resolution: {integrity: sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g==} + + fast-check@3.23.2: + resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==} + engines: {node: '>=8.0.0'} + + fast-decode-uri-component@1.0.1: + resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -985,6 +1399,12 @@ packages: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} + fast-querystring@1.1.2: + resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==} + + fast-uri@3.1.5: + resolution: {integrity: sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==} + fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} @@ -997,10 +1417,17 @@ packages: picomatch: optional: true + file-uri-to-path@1.0.0: + resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} + fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} + find-my-way@9.7.0: + resolution: {integrity: sha512-f2JHn75x2JlwUwLenZypgczR7YWMb/uO9BvUXtus+JMgkbIkLADd38cI4EiV+OQqrGo1Zlq6V8wnqMJ8e62wUQ==} + engines: {node: '>=20'} + fix-dts-default-cjs-exports@1.0.1: resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==} @@ -1008,18 +1435,34 @@ packages: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} + fs-constants@1.0.0: + resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + generate-function@2.3.1: + resolution: {integrity: sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==} + get-east-asian-width@1.6.0: resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==} engines: {node: '>=18'} + get-port-please@3.2.0: + resolution: {integrity: sha512-I9QVvBw5U/hw3RmWpYKRumUeaDgxTPd401x364rLmWBJcOQ753eov1eTgzDqRG9bqFIfDc7gfzcQEWrUri3o1A==} + get-tsconfig@4.12.0: resolution: {integrity: sha512-LScr2aNr2FbjAjZh2C6X6BxRx1/x+aTDExct/xyq2XKbYOiG5c0aK7pMsSuyc0brz3ibr/lbQiHD9jzt4lccJw==} + giget@3.3.1: + resolution: {integrity: sha512-r+mvuDjrjMpsdw46Kmeydb8bdHm7wOKw8wNBtTndkjbPjgAp5oUJUxRE76wZFknxIPokfWvep2qSXK37aXE6zg==} + hasBin: true + + github-from-package@0.0.0: + resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -1032,15 +1475,41 @@ packages: resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} engines: {node: '>=10'} + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + grammex@3.1.13: + resolution: {integrity: sha512-LnPnhOBLEJEVKS8WFDVaA397L9Kq55Q9oSITJiVLHVdhAclfUkWzQv74KhvZHKL2Q09Pb1XdsrOsZ4LfTFFTEg==} + + graphmatch@1.1.1: + resolution: {integrity: sha512-5ykVn/EXM1hF0XCaWh05VbYvEiOL2lY1kBxZtaYsyvjp7cmWOU1XsAdfQBwClraEofXDT197lFbXOEVMHpvQOg==} + husky@9.1.7: resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==} engines: {node: '>=18'} hasBin: true + iconv-lite@0.7.3: + resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} + engines: {node: '>=0.10.0'} + + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + is-binary-path@2.1.0: resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} engines: {node: '>=8'} @@ -1065,16 +1534,26 @@ packages: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} + is-property@1.0.2: + resolution: {integrity: sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} jackspeak@3.4.3: resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + joycon@3.1.1: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + lilconfig@3.1.3: resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} engines: {node: '>=14'} @@ -1105,9 +1584,20 @@ packages: resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==} engines: {node: '>=18'} + long@5.3.2: + resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@11.5.2: + resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} + engines: {node: 20 || >=22} + + lru.min@1.1.4: + resolution: {integrity: sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==} + engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'} + magic-bytes.js@1.13.0: resolution: {integrity: sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg==} @@ -1126,14 +1616,24 @@ packages: resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} engines: {node: '>=18'} + mimic-response@3.1.0: + resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} + engines: {node: '>=10'} + minimatch@9.0.5: resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} engines: {node: '>=16 || 14 >=14.17'} + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + minipass@7.1.2: resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} engines: {node: '>=16 || 14 >=14.17'} + mkdirp-classic@0.5.3: + resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + mlly@1.8.0: resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==} @@ -1144,9 +1644,24 @@ packages: resolution: {integrity: sha512-BzQguy9W9NJgoVn2mRWzbFrFWWztGCcng2QI9+41frfk+Athwgx3qhqhvStz7ExeUUu7Kzw427sNzHpEZNINog==} engines: {node: '>=16.0.0'} + mysql2@3.15.3: + resolution: {integrity: sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==} + engines: {node: '>= 8.0'} + mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + named-placeholders@1.1.6: + resolution: {integrity: sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==} + engines: {node: '>=8.0.0'} + + napi-build-utils@2.0.0: + resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} + + node-abi@3.94.0: + resolution: {integrity: sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==} + engines: {node: '>=10'} + node-cron@4.6.0: resolution: {integrity: sha512-Si/bzYiKRHOB8/a99T2+SDGN582ONDMSTlJr5oCkT6GtnqPjZ2s10eoQRYkW9ZHwjVxONL+W8Fb+qR0AHMQsdg==} engines: {node: '>=20'} @@ -1159,6 +1674,12 @@ packages: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} + ohash@2.0.12: + resolution: {integrity: sha512-65S/5gk9YSsaRjcyf7Nfa6h/d3E8/1gslpXfI4W7Dxn/oap8IKRuNT5VXkLQ1YFKIEg4apRY4Pj6aiwFzrDdmw==} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + onetime@7.0.0: resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} engines: {node: '>=18'} @@ -1211,6 +1732,9 @@ packages: pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + perfect-debounce@2.1.0: + resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -1229,6 +1753,9 @@ packages: pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + pkg-types@2.3.1: + resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==} + plimit-lit@1.6.1: resolution: {integrity: sha512-B7+VDyb8Tl6oMJT9oSO2CW8XC/T4UcJGrwOVoNGwOQsQYhlpfajmrMj5xeejqaASq3V/EqThyOeATEOMuSEXiA==} engines: {node: '>=12'} @@ -1251,6 +1778,38 @@ packages: yaml: optional: true + postgres@3.4.7: + resolution: {integrity: sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==} + engines: {node: '>=12'} + + prebuild-install@7.1.3: + resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} + engines: {node: '>=10'} + deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available. + hasBin: true + + prisma@7.9.1: + resolution: {integrity: sha512-aPqePoZIqwlAchbgbFDO/wHqGB+7H1nj9gaM+OsL9h77S5S3TnLd9BgD3LnoeDikULo7cl2HSUrEyQ55Z7DYbg==} + engines: {node: ^20.19 || ^22.12 || >=24.0} + hasBin: true + peerDependencies: + better-sqlite3: '>=9.0.0' + typescript: '>=5.4.0' + peerDependenciesMeta: + better-sqlite3: + optional: true + typescript: + optional: true + + proper-lockfile@4.1.2: + resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} + + pump@3.0.4: + resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + + pure-rand@6.1.0: + resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} + queue-lit@1.5.2: resolution: {integrity: sha512-tLc36IOPeMAubu8BkW8YDBV+WyIgKlYU7zUNs0J5Vk9skSZ4JfGlPOqplP0aHdfv7HL0B2Pg6nwiq60Qc6M2Hw==} engines: {node: '>=12'} @@ -1258,6 +1817,26 @@ packages: queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + rc9@3.0.1: + resolution: {integrity: sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ==} + + rc@1.2.8: + resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} + hasBin: true + + react-dom@19.2.8: + resolution: {integrity: sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==} + peerDependencies: + react: ^19.2.8 + + react@19.2.8: + resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==} + engines: {node: '>=0.10.0'} + + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + readdirp@3.6.0: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} @@ -1266,6 +1845,17 @@ packages: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} + readdirp@5.1.1: + resolution: {integrity: sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==} + engines: {node: '>= 20.19.0'} + + remeda@2.33.4: + resolution: {integrity: sha512-ygHswjlc/opg2VrtiYvUOPLjxjtdKvjGz1/plDhkG66hjNjFr1xmfrs2ClNFo/E6TyUFiwYNh53bKV26oBoMGQ==} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + resolve-from@5.0.0: resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} engines: {node: '>=8'} @@ -1277,6 +1867,14 @@ packages: resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} engines: {node: '>=18'} + ret@0.5.0: + resolution: {integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==} + engines: {node: '>=10'} + + retry@0.12.0: + resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} + engines: {node: '>= 4'} + reusify@1.1.0: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} @@ -1284,6 +1882,9 @@ packages: rfdc@1.4.1: resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + robust-predicates@3.0.3: + resolution: {integrity: sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==} + rollup@4.52.5: resolution: {integrity: sha512-3GuObel8h7Kqdjt0gxkEzaifHTqLVW56Y/bjN7PSQtkKr0w3V/QYSdt6QWYtd7A1xUtYQigtdUfgj1RvWVtorw==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -1292,6 +1893,27 @@ packages: run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safe-regex2@5.1.1: + resolution: {integrity: sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==} + hasBin: true + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + seq-queue@0.0.5: + resolution: {integrity: sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -1300,10 +1922,19 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + signal-exit@4.1.0: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + simple-concat@1.0.1: + resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} + + simple-get@4.0.1: + resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} + slash@3.0.0: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} @@ -1320,6 +1951,13 @@ packages: resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} engines: {node: '>= 12'} + sqlstring@2.3.3: + resolution: {integrity: sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==} + engines: {node: '>= 0.6'} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + string-argv@0.3.2: resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==} engines: {node: '>=0.6.19'} @@ -1340,6 +1978,9 @@ packages: resolution: {integrity: sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==} engines: {node: '>=20'} + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + strip-ansi@6.0.1: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} @@ -1348,11 +1989,22 @@ packages: resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} engines: {node: '>=12'} + strip-json-comments@2.0.1: + resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} + engines: {node: '>=0.10.0'} + sucrase@3.35.0: resolution: {integrity: sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==} engines: {node: '>=16 || 14 >=14.17'} hasBin: true + tar-fs@2.1.5: + resolution: {integrity: sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==} + + tar-stream@2.2.0: + resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} + engines: {node: '>=6'} + thenify-all@1.6.0: resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} engines: {node: '>=0.8'} @@ -1421,6 +2073,9 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + tunnel-agent@0.6.0: + resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + typescript@6.0.3: resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} engines: {node: '>=14.17'} @@ -1436,6 +2091,17 @@ packages: resolution: {integrity: sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==} engines: {node: '>=18.17'} + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + valibot@1.4.2: + resolution: {integrity: sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg==} + peerDependencies: + typescript: '>=5' + peerDependenciesMeta: + typescript: + optional: true + web-features@3.32.0: resolution: {integrity: sha512-PQBbTofqV8FtMP65oT9tLPjbN4FSB2dRdNxLM0A9j4bNifVpFhEP/ATXSMMJAqPPWb/pgUOh6B+98yzfNEVbNw==} @@ -1460,6 +2126,9 @@ packages: resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} engines: {node: '>=18'} + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + ws@8.18.3: resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==} engines: {node: '>=10.0.0'} @@ -1477,6 +2146,9 @@ packages: engines: {node: '>= 14.6'} hasBin: true + zeptomatch@2.1.0: + resolution: {integrity: sha512-KiGErG2J0G82LSpniV0CtIzjlJ10E04j02VOudJsPyPwNZgGnRKQy7I1R7GMyg/QswnE4l7ohSGrQbQbjXPPDA==} + snapshots: '@discordjs/builders@1.14.1': @@ -1528,7 +2200,17 @@ snapshots: - bufferutil - utf-8-validate - '@esbuild/aix-ppc64@0.27.7': + '@electric-sql/pglite-socket@0.1.3(@electric-sql/pglite@0.4.3)': + dependencies: + '@electric-sql/pglite': 0.4.3 + + '@electric-sql/pglite-tools@0.3.3(@electric-sql/pglite@0.4.3)': + dependencies: + '@electric-sql/pglite': 0.4.3 + + '@electric-sql/pglite@0.4.3': {} + + '@esbuild/aix-ppc64@0.27.7': optional: true '@esbuild/aix-ppc64@0.28.1': @@ -1854,6 +2536,162 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true + '@prisma/adapter-better-sqlite3@7.9.1': + dependencies: + '@prisma/driver-adapter-utils': 7.9.1 + better-sqlite3: 12.11.1 + + '@prisma/client-runtime-utils@7.9.1': {} + + '@prisma/client@7.9.1(prisma@7.9.1(@types/react@19.2.18)(better-sqlite3@12.11.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3))(typescript@6.0.3)': + dependencies: + '@prisma/client-runtime-utils': 7.9.1 + optionalDependencies: + prisma: 7.9.1(@types/react@19.2.18)(better-sqlite3@12.11.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3) + typescript: 6.0.3 + + '@prisma/config@7.9.1': + dependencies: + c12: 3.3.4 + deepmerge-ts: 7.1.5 + effect: 3.20.0 + empathic: 2.0.0 + transitivePeerDependencies: + - magicast + + '@prisma/debug@7.2.0': {} + + '@prisma/debug@7.9.1': {} + + '@prisma/dev@0.24.17(typescript@6.0.3)': + dependencies: + '@electric-sql/pglite': 0.4.3 + '@electric-sql/pglite-socket': 0.1.3(@electric-sql/pglite@0.4.3) + '@electric-sql/pglite-tools': 0.3.3(@electric-sql/pglite@0.4.3) + '@prisma/get-platform': 7.2.0 + '@prisma/query-plan-executor': 7.2.0 + '@prisma/streams-local': 0.1.11 + find-my-way: 9.7.0 + foreground-child: 3.3.1 + get-port-please: 3.2.0 + pathe: 2.0.3 + proper-lockfile: 4.1.2 + remeda: 2.33.4 + std-env: 3.10.0 + valibot: 1.4.2(typescript@6.0.3) + zeptomatch: 2.1.0 + transitivePeerDependencies: + - typescript + + '@prisma/driver-adapter-utils@7.9.1': + dependencies: + '@prisma/debug': 7.9.1 + + '@prisma/engines-version@7.9.0-1.e922089b7d7502aff4249d5da3420f6fa55fc6ad': {} + + '@prisma/engines@7.9.1': + dependencies: + '@prisma/debug': 7.9.1 + '@prisma/engines-version': 7.9.0-1.e922089b7d7502aff4249d5da3420f6fa55fc6ad + '@prisma/fetch-engine': 7.9.1 + '@prisma/get-platform': 7.9.1 + + '@prisma/fetch-engine@7.9.1': + dependencies: + '@prisma/debug': 7.9.1 + '@prisma/engines-version': 7.9.0-1.e922089b7d7502aff4249d5da3420f6fa55fc6ad + '@prisma/get-platform': 7.9.1 + + '@prisma/get-platform@7.2.0': + dependencies: + '@prisma/debug': 7.2.0 + + '@prisma/get-platform@7.9.1': + dependencies: + '@prisma/debug': 7.9.1 + + '@prisma/query-plan-executor@7.2.0': {} + + '@prisma/streams-local@0.1.11': + dependencies: + ajv: 8.20.0 + better-result: 2.10.0 + env-paths: 3.0.0 + proper-lockfile: 4.1.2 + + '@prisma/studio-core@0.33.0(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/react-toggle': 1.1.10(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@types/react': 19.2.18 + '@visx/curve': 4.0.1-alpha.0 + '@visx/event': 4.0.1-alpha.0 + '@visx/grid': 4.0.1-alpha.0(react@19.2.8) + '@visx/group': 4.0.1-alpha.0(react@19.2.8) + '@visx/responsive': 4.0.1-alpha.0(react@19.2.8) + '@visx/scale': 4.0.1-alpha.0 + '@visx/shape': 4.0.1-alpha.0(react@19.2.8) + d3-array: 3.2.4 + d3-shape: 3.2.0 + elkjs: 0.11.1 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + transitivePeerDependencies: + - '@types/react-dom' + + '@radix-ui/primitive@1.1.3': {} + + '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.18)(react@19.2.8)': + dependencies: + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.18 + + '@radix-ui/react-primitive@2.1.3(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.18 + + '@radix-ui/react-slot@1.2.3(@types/react@19.2.18)(react@19.2.8)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.18 + + '@radix-ui/react-toggle@1.1.10(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-primitive': 2.1.3(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.18 + + '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.18)(react@19.2.8)': + dependencies: + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.18 + + '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.18)(react@19.2.8)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.18 + + '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.18)(react@19.2.8)': + dependencies: + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.18 + '@rollup/rollup-android-arm-eabi@4.52.5': optional: true @@ -1931,22 +2769,150 @@ snapshots: '@sapphire/snowflake@3.5.5': {} + '@standard-schema/spec@1.1.0': {} + + '@types/better-sqlite3@7.6.13': + dependencies: + '@types/node': 24.13.3 + + '@types/d3-array@3.0.3': {} + + '@types/d3-color@3.1.0': {} + + '@types/d3-delaunay@6.0.1': {} + + '@types/d3-format@3.0.1': {} + + '@types/d3-geo@3.1.0': + dependencies: + '@types/geojson': 7946.0.16 + + '@types/d3-interpolate@3.0.1': + dependencies: + '@types/d3-color': 3.1.0 + + '@types/d3-path@3.1.1': {} + + '@types/d3-scale@4.0.2': + dependencies: + '@types/d3-time': 3.0.0 + + '@types/d3-shape@3.1.7': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time-format@2.1.0': {} + + '@types/d3-time@3.0.0': {} + '@types/estree@1.0.8': {} + '@types/geojson@7946.0.16': {} + + '@types/lodash@4.17.25': {} + '@types/node-cron@3.0.11': {} - '@types/node@24.13.2': + '@types/node@24.13.3': dependencies: undici-types: 7.18.2 + '@types/react@19.2.18': + dependencies: + csstype: 3.2.3 + '@types/ws@8.18.1': dependencies: - '@types/node': 24.13.2 + '@types/node': 24.13.3 + + '@visx/curve@4.0.1-alpha.0': + dependencies: + '@visx/vendor': 4.0.0-alpha.0 + + '@visx/event@4.0.1-alpha.0': + dependencies: + '@types/react': 19.2.18 + '@visx/point': 4.0.1-alpha.0 + + '@visx/grid@4.0.1-alpha.0(react@19.2.8)': + dependencies: + '@types/react': 19.2.18 + '@visx/curve': 4.0.1-alpha.0 + '@visx/group': 4.0.1-alpha.0(react@19.2.8) + '@visx/point': 4.0.1-alpha.0 + '@visx/scale': 4.0.1-alpha.0 + '@visx/shape': 4.0.1-alpha.0(react@19.2.8) + classnames: 2.5.1 + react: 19.2.8 + + '@visx/group@4.0.1-alpha.0(react@19.2.8)': + dependencies: + '@types/react': 19.2.18 + classnames: 2.5.1 + react: 19.2.8 + + '@visx/point@4.0.1-alpha.0': {} + + '@visx/responsive@4.0.1-alpha.0(react@19.2.8)': + dependencies: + '@types/lodash': 4.17.25 + '@types/react': 19.2.18 + lodash: 4.17.21 + react: 19.2.8 + + '@visx/scale@4.0.1-alpha.0': + dependencies: + '@visx/vendor': 4.0.0-alpha.0 + + '@visx/shape@4.0.1-alpha.0(react@19.2.8)': + dependencies: + '@types/lodash': 4.17.25 + '@types/react': 19.2.18 + '@visx/curve': 4.0.1-alpha.0 + '@visx/group': 4.0.1-alpha.0(react@19.2.8) + '@visx/scale': 4.0.1-alpha.0 + '@visx/vendor': 4.0.0-alpha.0 + classnames: 2.5.1 + lodash: 4.17.21 + react: 19.2.8 + + '@visx/vendor@4.0.0-alpha.0': + dependencies: + '@types/d3-array': 3.0.3 + '@types/d3-color': 3.1.0 + '@types/d3-delaunay': 6.0.1 + '@types/d3-format': 3.0.1 + '@types/d3-geo': 3.1.0 + '@types/d3-interpolate': 3.0.1 + '@types/d3-path': 3.1.1 + '@types/d3-scale': 4.0.2 + '@types/d3-shape': 3.1.7 + '@types/d3-time': 3.0.0 + '@types/d3-time-format': 2.1.0 + d3-array: 3.2.1 + d3-color: 3.1.0 + d3-delaunay: 6.0.2 + d3-format: 3.1.0 + d3-geo: 3.1.0 + d3-interpolate: 3.0.1 + d3-path: 3.1.0 + d3-scale: 4.0.2 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + internmap: 2.0.3 '@vladfrangu/async_event_emitter@2.4.7': {} acorn@8.15.0: {} + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.5 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + ansi-escapes@7.3.0: dependencies: environment: 1.1.0 @@ -1970,10 +2936,31 @@ snapshots: array-union@2.1.0: {} + aws-ssl-profiles@1.1.2: {} + balanced-match@1.0.2: {} + base64-js@1.5.1: {} + + better-result@2.10.0: {} + + better-sqlite3@12.11.1: + dependencies: + bindings: 1.5.0 + prebuild-install: 7.1.3 + binary-extensions@2.3.0: {} + bindings@1.5.0: + dependencies: + file-uri-to-path: 1.0.0 + + bl@4.1.0: + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 + brace-expansion@2.0.2: dependencies: balanced-match: 1.0.2 @@ -1982,11 +2969,31 @@ snapshots: dependencies: fill-range: 7.1.1 + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + bundle-require@5.1.0(esbuild@0.27.7): dependencies: esbuild: 0.27.7 load-tsconfig: 0.2.5 + c12@3.3.4: + dependencies: + chokidar: 5.0.0 + confbox: 0.2.4 + defu: 6.1.7 + dotenv: 17.4.2 + exsolve: 1.1.1 + giget: 3.3.1 + jiti: 2.7.0 + ohash: 2.0.12 + pathe: 2.0.3 + perfect-debounce: 2.1.0 + pkg-types: 2.3.1 + rc9: 3.0.1 + cac@6.7.14: {} chokidar@3.6.0: @@ -2005,6 +3012,14 @@ snapshots: dependencies: readdirp: 4.1.2 + chokidar@5.0.0: + dependencies: + readdirp: 5.1.1 + + chownr@1.1.4: {} + + classnames@2.5.1: {} + cli-cursor@5.0.0: dependencies: restore-cursor: 5.1.0 @@ -2026,6 +3041,8 @@ snapshots: confbox@0.1.8: {} + confbox@0.2.4: {} + consola@3.4.2: {} cross-spawn@7.0.6: @@ -2034,10 +3051,78 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + csstype@3.2.3: {} + + d3-array@3.2.1: + dependencies: + internmap: 2.0.3 + + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-color@3.1.0: {} + + d3-delaunay@6.0.2: + dependencies: + delaunator: 5.1.0 + + d3-format@3.1.0: {} + + d3-geo@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@3.1.0: {} + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.0 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + debug@4.4.3: dependencies: ms: 2.1.3 + decompress-response@6.0.0: + dependencies: + mimic-response: 3.1.0 + + deep-extend@0.6.0: {} + + deepmerge-ts@7.1.5: {} + + defu@6.1.7: {} + + delaunator@5.1.0: + dependencies: + robust-predicates: 3.0.3 + + denque@2.1.0: {} + + destr@2.0.5: {} + + detect-libc@2.1.2: {} + dir-glob@3.0.1: dependencies: path-type: 4.0.0 @@ -2063,14 +3148,31 @@ snapshots: - bufferutil - utf-8-validate + dotenv@17.4.2: {} + eastasianwidth@0.2.0: {} + effect@3.20.0: + dependencies: + '@standard-schema/spec': 1.1.0 + fast-check: 3.23.2 + + elkjs@0.11.1: {} + emoji-regex@10.6.0: {} emoji-regex@8.0.0: {} emoji-regex@9.2.2: {} + empathic@2.0.0: {} + + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + + env-paths@3.0.0: {} + environment@1.1.0: {} esbuild@0.27.7: @@ -2133,6 +3235,16 @@ snapshots: eventemitter3@5.0.4: {} + expand-template@2.0.3: {} + + exsolve@1.1.1: {} + + fast-check@3.23.2: + dependencies: + pure-rand: 6.1.0 + + fast-decode-uri-component@1.0.1: {} + fast-deep-equal@3.1.3: {} fast-glob@3.3.3: @@ -2143,6 +3255,12 @@ snapshots: merge2: 1.4.1 micromatch: 4.0.8 + fast-querystring@1.1.2: + dependencies: + fast-decode-uri-component: 1.0.1 + + fast-uri@3.1.5: {} + fastq@1.20.1: dependencies: reusify: 1.1.0 @@ -2151,10 +3269,18 @@ snapshots: optionalDependencies: picomatch: 4.0.5 + file-uri-to-path@1.0.0: {} + fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 + find-my-way@9.7.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-querystring: 1.1.2 + safe-regex2: 5.1.1 + fix-dts-default-cjs-exports@1.0.1: dependencies: magic-string: 0.30.19 @@ -2166,15 +3292,27 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 + fs-constants@1.0.0: {} + fsevents@2.3.3: optional: true + generate-function@2.3.1: + dependencies: + is-property: 1.0.2 + get-east-asian-width@1.6.0: {} + get-port-please@3.2.0: {} + get-tsconfig@4.12.0: dependencies: resolve-pkg-maps: 1.0.0 + giget@3.3.1: {} + + github-from-package@0.0.0: {} + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -2197,10 +3335,28 @@ snapshots: merge2: 1.4.1 slash: 3.0.0 + graceful-fs@4.2.11: {} + + grammex@3.1.13: {} + + graphmatch@1.1.1: {} + husky@9.1.7: {} + iconv-lite@0.7.3: + dependencies: + safer-buffer: 2.1.2 + + ieee754@1.2.1: {} + ignore@5.3.2: {} + inherits@2.0.4: {} + + ini@1.3.8: {} + + internmap@2.0.3: {} + is-binary-path@2.1.0: dependencies: binary-extensions: 2.3.0 @@ -2219,6 +3375,8 @@ snapshots: is-number@7.0.0: {} + is-property@1.0.2: {} + isexe@2.0.0: {} jackspeak@3.4.3: @@ -2227,8 +3385,12 @@ snapshots: optionalDependencies: '@pkgjs/parseargs': 0.11.0 + jiti@2.7.0: {} + joycon@3.1.1: {} + json-schema-traverse@1.0.0: {} + lilconfig@3.1.3: {} lines-and-columns@1.2.4: {} @@ -2264,8 +3426,14 @@ snapshots: strip-ansi: 7.2.0 wrap-ansi: 9.0.2 + long@5.3.2: {} + lru-cache@10.4.3: {} + lru-cache@11.5.2: {} + + lru.min@1.1.4: {} + magic-bytes.js@1.13.0: {} magic-string@0.30.19: @@ -2281,12 +3449,18 @@ snapshots: mimic-function@5.0.1: {} + mimic-response@3.1.0: {} + minimatch@9.0.5: dependencies: brace-expansion: 2.0.2 + minimist@1.2.8: {} + minipass@7.1.2: {} + mkdirp-classic@0.5.3: {} + mlly@1.8.0: dependencies: acorn: 8.15.0 @@ -2298,18 +3472,46 @@ snapshots: mylas@2.1.14: {} + mysql2@3.15.3: + dependencies: + aws-ssl-profiles: 1.1.2 + denque: 2.1.0 + generate-function: 2.3.1 + iconv-lite: 0.7.3 + long: 5.3.2 + lru.min: 1.1.4 + named-placeholders: 1.1.6 + seq-queue: 0.0.5 + sqlstring: 2.3.3 + mz@2.7.0: dependencies: any-promise: 1.3.0 object-assign: 4.1.1 thenify-all: 1.6.0 + named-placeholders@1.1.6: + dependencies: + lru.min: 1.1.4 + + napi-build-utils@2.0.0: {} + + node-abi@3.94.0: + dependencies: + semver: 7.8.5 + node-cron@4.6.0: {} normalize-path@3.0.0: {} object-assign@4.1.1: {} + ohash@2.0.12: {} + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + onetime@7.0.0: dependencies: mimic-function: 5.0.1 @@ -2383,6 +3585,8 @@ snapshots: pathe@2.0.3: {} + perfect-debounce@2.1.0: {} + picocolors@1.1.1: {} picomatch@2.3.1: {} @@ -2397,27 +3601,113 @@ snapshots: mlly: 1.8.0 pathe: 2.0.3 + pkg-types@2.3.1: + dependencies: + confbox: 0.2.4 + exsolve: 1.1.1 + pathe: 2.0.3 + plimit-lit@1.6.1: dependencies: queue-lit: 1.5.2 - postcss-load-config@6.0.1(tsx@4.23.0)(yaml@2.9.0): + postcss-load-config@6.0.1(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0): dependencies: lilconfig: 3.1.3 optionalDependencies: + jiti: 2.7.0 tsx: 4.23.0 yaml: 2.9.0 + postgres@3.4.7: {} + + prebuild-install@7.1.3: + dependencies: + detect-libc: 2.1.2 + expand-template: 2.0.3 + github-from-package: 0.0.0 + minimist: 1.2.8 + mkdirp-classic: 0.5.3 + napi-build-utils: 2.0.0 + node-abi: 3.94.0 + pump: 3.0.4 + rc: 1.2.8 + simple-get: 4.0.1 + tar-fs: 2.1.5 + tunnel-agent: 0.6.0 + + prisma@7.9.1(@types/react@19.2.18)(better-sqlite3@12.11.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3): + dependencies: + '@prisma/config': 7.9.1 + '@prisma/dev': 0.24.17(typescript@6.0.3) + '@prisma/engines': 7.9.1 + '@prisma/studio-core': 0.33.0(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + mysql2: 3.15.3 + postgres: 3.4.7 + optionalDependencies: + better-sqlite3: 12.11.1 + typescript: 6.0.3 + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + - magicast + - react + - react-dom + + proper-lockfile@4.1.2: + dependencies: + graceful-fs: 4.2.11 + retry: 0.12.0 + signal-exit: 3.0.7 + + pump@3.0.4: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + + pure-rand@6.1.0: {} + queue-lit@1.5.2: {} queue-microtask@1.2.3: {} + rc9@3.0.1: + dependencies: + defu: 6.1.7 + destr: 2.0.5 + + rc@1.2.8: + dependencies: + deep-extend: 0.6.0 + ini: 1.3.8 + minimist: 1.2.8 + strip-json-comments: 2.0.1 + + react-dom@19.2.8(react@19.2.8): + dependencies: + react: 19.2.8 + scheduler: 0.27.0 + + react@19.2.8: {} + + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + readdirp@3.6.0: dependencies: picomatch: 2.3.1 readdirp@4.1.2: {} + readdirp@5.1.1: {} + + remeda@2.33.4: {} + + require-from-string@2.0.2: {} + resolve-from@5.0.0: {} resolve-pkg-maps@1.0.0: {} @@ -2427,10 +3717,16 @@ snapshots: onetime: 7.0.0 signal-exit: 4.1.0 + ret@0.5.0: {} + + retry@0.12.0: {} + reusify@1.1.0: {} rfdc@1.4.1: {} + robust-predicates@3.0.3: {} + rollup@4.52.5: dependencies: '@types/estree': 1.0.8 @@ -2463,14 +3759,38 @@ snapshots: dependencies: queue-microtask: 1.2.3 + safe-buffer@5.2.1: {} + + safe-regex2@5.1.1: + dependencies: + ret: 0.5.0 + + safer-buffer@2.1.2: {} + + scheduler@0.27.0: {} + + semver@7.8.5: {} + + seq-queue@0.0.5: {} + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 shebang-regex@3.0.0: {} + signal-exit@3.0.7: {} + signal-exit@4.1.0: {} + simple-concat@1.0.1: {} + + simple-get@4.0.1: + dependencies: + decompress-response: 6.0.0 + once: 1.4.0 + simple-concat: 1.0.1 + slash@3.0.0: {} slice-ansi@7.1.2: @@ -2485,6 +3805,10 @@ snapshots: source-map@0.7.6: {} + sqlstring@2.3.3: {} + + std-env@3.10.0: {} + string-argv@0.3.2: {} string-width@4.2.3: @@ -2510,6 +3834,10 @@ snapshots: get-east-asian-width: 1.6.0 strip-ansi: 7.2.0 + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + strip-ansi@6.0.1: dependencies: ansi-regex: 5.0.1 @@ -2518,6 +3846,8 @@ snapshots: dependencies: ansi-regex: 6.2.2 + strip-json-comments@2.0.1: {} + sucrase@3.35.0: dependencies: '@jridgewell/gen-mapping': 0.3.13 @@ -2528,6 +3858,21 @@ snapshots: pirates: 4.0.7 ts-interface-checker: 0.1.13 + tar-fs@2.1.5: + dependencies: + chownr: 1.1.4 + mkdirp-classic: 0.5.3 + pump: 3.0.4 + tar-stream: 2.2.0 + + tar-stream@2.2.0: + dependencies: + bl: 4.1.0 + end-of-stream: 1.4.5 + fs-constants: 1.0.0 + inherits: 2.0.4 + readable-stream: 3.6.2 + thenify-all@1.6.0: dependencies: thenify: 3.3.1 @@ -2569,7 +3914,7 @@ snapshots: tslib@2.8.1: {} - tsup@8.5.1(tsx@4.23.0)(typescript@6.0.3)(yaml@2.9.0): + tsup@8.5.1(jiti@2.7.0)(tsx@4.23.0)(typescript@6.0.3)(yaml@2.9.0): dependencies: bundle-require: 5.1.0(esbuild@0.27.7) cac: 6.7.14 @@ -2580,7 +3925,7 @@ snapshots: fix-dts-default-cjs-exports: 1.0.1 joycon: 3.1.1 picocolors: 1.1.1 - postcss-load-config: 6.0.1(tsx@4.23.0)(yaml@2.9.0) + postcss-load-config: 6.0.1(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0) resolve-from: 5.0.0 rollup: 4.52.5 source-map: 0.7.6 @@ -2602,6 +3947,10 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + tunnel-agent@0.6.0: + dependencies: + safe-buffer: 5.2.1 + typescript@6.0.3: {} ufo@1.6.1: {} @@ -2610,6 +3959,12 @@ snapshots: undici@6.24.1: {} + util-deprecate@1.0.2: {} + + valibot@1.4.2(typescript@6.0.3): + optionalDependencies: + typescript: 6.0.3 + web-features@3.32.0: {} which@2.0.2: @@ -2640,7 +3995,14 @@ snapshots: string-width: 7.2.0 strip-ansi: 7.2.0 + wrappy@1.0.2: {} + ws@8.18.3: {} yaml@2.9.0: optional: true + + zeptomatch@2.1.0: + dependencies: + grammex: 3.1.13 + graphmatch: 1.1.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..ae3a396 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,5 @@ +allowBuilds: + '@prisma/engines': false + better-sqlite3: true + esbuild: false + prisma: false diff --git a/prisma.config.ts b/prisma.config.ts new file mode 100644 index 0000000..d4a7857 --- /dev/null +++ b/prisma.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'prisma/config'; +import './src/loadEnvFile.js'; + +export default defineConfig({ + schema: 'prisma/schema.prisma', + migrations: { + path: 'prisma/migrations', + }, + datasource: { + url: process.env.DATABASE_URL, + }, +}); diff --git a/prisma/migrations/20260817220534_add_bot_options_and_tags/migration.sql b/prisma/migrations/20260817220534_add_bot_options_and_tags/migration.sql new file mode 100644 index 0000000..723441e --- /dev/null +++ b/prisma/migrations/20260817220534_add_bot_options_and_tags/migration.sql @@ -0,0 +1,56 @@ +-- CreateTable +CREATE TABLE "options" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "key" TEXT NOT NULL, + "value" TEXT NOT NULL +); + +-- CreateTable +CREATE TABLE "tags" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "content" TEXT NOT NULL, + "desc" TEXT NOT NULL, + "lastModifiedBy" TEXT NOT NULL, + "uses" INTEGER NOT NULL DEFAULT 0, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "lastUsedAt" DATETIME +); + +-- CreateTable +CREATE TABLE "tag_aliases" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "name" TEXT NOT NULL, + "tagId" INTEGER NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "tag_aliases_tagId_fkey" FOREIGN KEY ("tagId") REFERENCES "tags" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "user_bot_messages" ( + "id" TEXT NOT NULL PRIMARY KEY, + "userId" TEXT NOT NULL, + "channelId" TEXT NOT NULL, + "expiresAt" DATETIME NOT NULL +); + +-- CreateIndex +CREATE UNIQUE INDEX "options_key_key" ON "options"("key"); + +-- CreateIndex +CREATE INDEX "tags_desc_idx" ON "tags"("desc"); + +-- CreateIndex +CREATE INDEX "tags_uses_idx" ON "tags"("uses"); + +-- CreateIndex +CREATE UNIQUE INDEX "tag_aliases_name_key" ON "tag_aliases"("name"); + +-- CreateIndex +CREATE INDEX "tag_aliases_tagId_idx" ON "tag_aliases"("tagId"); + +-- CreateIndex +CREATE INDEX "user_bot_messages_expiresAt_idx" ON "user_bot_messages"("expiresAt"); + +-- CreateIndex +CREATE INDEX "user_bot_messages_userId_channelId_idx" ON "user_bot_messages"("userId", "channelId"); diff --git a/prisma/migrations/20260818172804_add_tag_dates_indices/migration.sql b/prisma/migrations/20260818172804_add_tag_dates_indices/migration.sql new file mode 100644 index 0000000..4bf81f7 --- /dev/null +++ b/prisma/migrations/20260818172804_add_tag_dates_indices/migration.sql @@ -0,0 +1,5 @@ +-- CreateIndex +CREATE INDEX "tags_lastUsedAt_idx" ON "tags"("lastUsedAt"); + +-- CreateIndex +CREATE INDEX "tags_createdAt_idx" ON "tags"("createdAt"); diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..2a5a444 --- /dev/null +++ b/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "sqlite" diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..bc39c2b --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,68 @@ +// This is your Prisma schema file, +// learn more about it in the docs: https://pris.ly/d/prisma-schema + +// Get a free hosted Postgres database in seconds: `npx create-db` + +generator client { + provider = "prisma-client" + output = "../src/generated/prisma" +} + +datasource db { + provider = "sqlite" +} + +enum OptionKey { + TAG_PREFIX + MAX_TAGS_PER_MESSAGE + DAYS_TO_KEEP_TAGS +} + +model Options { + id Int @id @default(autoincrement()) + key OptionKey @unique + value String + + @@map("options") +} + +model Tag { + id Int @id @default(autoincrement()) + content String + desc String + lastModifiedBy String + uses Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) + lastUsedAt DateTime? + + aliases TagAlias[] + + @@index([desc]) + @@index([uses]) + @@index([lastUsedAt]) + @@index([createdAt]) + @@map("tags") +} + +model TagAlias { + id Int @id @default(autoincrement()) + name String @unique + tagId Int + tag Tag @relation(fields: [tagId], references: [id]) + createdAt DateTime @default(now()) + + @@index([tagId]) + @@map("tag_aliases") +} + +model UserBotMessages { + id String @id + userId String + channelId String + expiresAt DateTime + + @@index([expiresAt]) + @@index([userId, channelId]) + @@map("user_bot_messages") +} diff --git a/src/common/commands/index.ts b/src/common/commands/index.ts index 568fd1e..14818e4 100644 --- a/src/common/commands/index.ts +++ b/src/common/commands/index.ts @@ -8,6 +8,8 @@ import { createShowcaseCommand } from '@/features/showcase/create-showcase.js'; import { sendShowcasePinnedMessage } from '@/features/showcase/send-pinned-message.js'; import { tipsCommands } from '@/features/tips/index.js'; import type { Command } from './types.js'; +import { tagCommand } from '@/features/tags/index.js'; +import { botOptionsCommand } from '@/features/bot-options/index.js'; export const commands = new Map( [ @@ -20,6 +22,8 @@ export const commands = new Map( publicGuidesCommand, createShowcaseCommand, sendShowcasePinnedMessage, + tagCommand, + botOptionsCommand, ] .flat() .map((command) => [command.data.name, command]) diff --git a/src/common/events/index.ts b/src/common/events/index.ts index 2b8c335..d3f25bd 100644 --- a/src/common/events/index.ts +++ b/src/common/events/index.ts @@ -4,6 +4,7 @@ import { interactionCreateEvent } from '@/features/interaction-create/index.js'; import { readyEvent } from '@/features/ready/index.js'; import type { DiscordEvent } from './types.js'; import archiveChannels from '@/features/archive-channels/index.js'; +import { tagReceivedEvent } from '@/features/tags/tag-received.js'; export const events: DiscordEvent[] = [ readyEvent, @@ -11,4 +12,5 @@ export const events: DiscordEvent[] = [ hasVarEvent, interactionCreateEvent, archiveChannels, + tagReceivedEvent, ].flat(); diff --git a/src/common/interactions/select-menu-interaction.ts b/src/common/interactions/select-menu-interaction.ts new file mode 100644 index 0000000..38aa1f1 --- /dev/null +++ b/src/common/interactions/select-menu-interaction.ts @@ -0,0 +1,28 @@ +import type { StringSelectMenuInteraction } from 'discord.js'; +import { parseCustomId } from '@/util/custom-id.js'; + +export type SelectMenuSubmitInteraction = { + commandName: string; + handler: (interaction: StringSelectMenuInteraction) => Promise | void; +}; + +export const selectMenuSubmitInteractions = new Map< + string, + SelectMenuSubmitInteraction +>(); + +export const registerSelectMenuSubmitInteraction = ( + interaction: SelectMenuSubmitInteraction +) => { + console.log( + `Registering select menu submit interaction: ${interaction.commandName}` + ); + selectMenuSubmitInteractions.set(interaction.commandName, interaction); +}; + +export const handleSelectMenuInteraction = async ( + interaction: StringSelectMenuInteraction +): Promise => { + const commandName = parseCustomId(interaction.customId)[0]; + await selectMenuSubmitInteractions.get(commandName)?.handler(interaction); +}; diff --git a/src/db/prisma.ts b/src/db/prisma.ts new file mode 100644 index 0000000..140eb66 --- /dev/null +++ b/src/db/prisma.ts @@ -0,0 +1,6 @@ +import { config } from '@/env.js'; +import { PrismaClient } from '@generated/prisma/client.js'; +import { PrismaBetterSqlite3 } from '@prisma/adapter-better-sqlite3'; + +const adapter = new PrismaBetterSqlite3({ url: config.databaseUrl }); +export const prisma = new PrismaClient({ adapter }); diff --git a/src/env.ts b/src/env.ts index 0a66798..ba2d53b 100644 --- a/src/env.ts +++ b/src/env.ts @@ -33,6 +33,7 @@ export const config = { a: optionalEnv('ROLE_A_ID'), b: optionalEnv('ROLE_B_ID'), c: optionalEnv('ROLE_C_ID'), + tagAccess: requireEnv('TAG_ACCESS_ROLE_ID'), }, channelIds: { repelLogs: requireEnv('REPEL_LOG_CHANNEL_ID'), @@ -47,6 +48,7 @@ export const config = { channelId: optionalEnv('ONBOARDING_CHANNEL_ID'), roleId: optionalEnv('ONBOARDING_ROLE_ID'), }, + databaseUrl: requireEnv('DATABASE_URL'), }; export type Config = typeof config; diff --git a/src/error-messages/index.ts b/src/error-messages/index.ts new file mode 100644 index 0000000..2cdb559 --- /dev/null +++ b/src/error-messages/index.ts @@ -0,0 +1,16 @@ +import type { InteractionReplyOptions } from 'discord.js'; +import { OptionTypes } from './option-type.js'; +import { Tags } from './tags.js'; +import { User } from './user.js'; + +export type ErrorMessage = + | Required['components'][number] + | (( + ...args: string[] + ) => Required['components'][number]); + +export const ErrorMessages = { + Tags, + User, + OptionTypes, +} satisfies Record>; diff --git a/src/error-messages/option-type.ts b/src/error-messages/option-type.ts new file mode 100644 index 0000000..ea0286d --- /dev/null +++ b/src/error-messages/option-type.ts @@ -0,0 +1,9 @@ +import { basicErrorMessage } from '@/util/components/basic-message.js'; +import type { ErrorMessage } from './index.js'; + +export const OptionTypes = { + InvalidType: (optionName: string, expectedType: string) => + basicErrorMessage( + `Invalid value for option "${optionName}". Expected a ${expectedType}.` + ), +} satisfies Record; diff --git a/src/error-messages/tags.ts b/src/error-messages/tags.ts new file mode 100644 index 0000000..f30b415 --- /dev/null +++ b/src/error-messages/tags.ts @@ -0,0 +1,15 @@ +import { basicErrorMessage } from '@/util/components/basic-message.js'; +import type { ErrorMessage } from './index.js'; + +export const Tags = { + TagNotFound: (name) => basicErrorMessage(`Tag \`${name}\` not found.`), + TagAlreadyExists: (name) => + basicErrorMessage(`Tag \`${name}\` already exists.`), + + OwnershipRequired: basicErrorMessage( + 'You must be the owner of this tag to use this command.' + ), + InvalidTagName: basicErrorMessage( + 'Tag names must be 1–32 characters, alphanumeric with hyphens/underscores, and cannot be purely numeric.' + ), +} satisfies Record; diff --git a/src/error-messages/user.ts b/src/error-messages/user.ts new file mode 100644 index 0000000..f4afc6c --- /dev/null +++ b/src/error-messages/user.ts @@ -0,0 +1,14 @@ +import { basicErrorMessage } from '@/util/components/basic-message.js'; +import type { ErrorMessage } from './index.js'; + +export const User = { + UnableToVerifyPermissions: basicErrorMessage( + 'An error occurred while verifying your permissions.' + ), + MissingRole: basicErrorMessage( + 'You do not have the required role to use this command.' + ), + MissingPermissions: basicErrorMessage( + 'You do not have the required permissions to use this command.' + ), +} satisfies Record; diff --git a/src/features/bot-options/get-option.ts b/src/features/bot-options/get-option.ts new file mode 100644 index 0000000..9610ecc --- /dev/null +++ b/src/features/bot-options/get-option.ts @@ -0,0 +1,15 @@ +import type { OptionKey } from '@generated/prisma/enums.js'; +import { type ChatInputCommandInteraction, MessageFlags } from 'discord.js'; +import { getBotOption } from '@/options.js'; +import { basicMessage } from '@/util/components/basic-message.js'; + +export const getOptionHandler = async ( + interaction: ChatInputCommandInteraction +) => { + const optionKey = interaction.options.getString('option', true) as OptionKey; + const option = getBotOption(optionKey); + await interaction.reply({ + components: [basicMessage(`**${option.displayName}**: ${option.value}`)], + flags: MessageFlags.Ephemeral | MessageFlags.IsComponentsV2, + }); +}; diff --git a/src/features/bot-options/index.ts b/src/features/bot-options/index.ts new file mode 100644 index 0000000..32916b0 --- /dev/null +++ b/src/features/bot-options/index.ts @@ -0,0 +1,76 @@ +import { + type ApplicationCommandOptionChoiceData, + ApplicationCommandOptionType, + PermissionFlagsBits, + PermissionsBitField, +} from 'discord.js'; +import { createSlashCommand } from '@/common/commands/create-commands.js'; +import { BotOptions } from '@/options.js'; +import { getOptionHandler } from './get-option.js'; +import { setOptionHandler } from './set-option.js'; + +export const botOptionsCommand = createSlashCommand({ + data: { + name: 'bot-options', + description: 'Manage bot options', + default_member_permissions: new PermissionsBitField( + PermissionFlagsBits.ModerateMembers + ).toJSON(), + options: [ + { + name: 'set', + type: ApplicationCommandOptionType.Subcommand, + description: 'Set a bot option', + options: [ + { + name: 'option', + type: ApplicationCommandOptionType.String, + description: 'The option to set', + required: true, + choices: buildChoices(), + }, + { + name: 'value', + type: ApplicationCommandOptionType.String, + description: + "The value to set the option to (boolean values should be 'true' or 'false')", + required: true, + }, + ], + }, + { + name: 'get', + type: ApplicationCommandOptionType.Subcommand, + description: 'Get the value of a bot option', + options: [ + { + name: 'option', + type: ApplicationCommandOptionType.String, + description: 'The option to get', + required: true, + choices: buildChoices(), + }, + ], + }, + ], + }, + execute: async (interaction) => { + const subCommand = interaction.options.getSubcommand(); + const handlersMap = { + set: setOptionHandler, + get: getOptionHandler, + }; + + if (subCommand in handlersMap) { + await handlersMap[subCommand as keyof typeof handlersMap](interaction); + } + return; + }, +}); + +function buildChoices(): ApplicationCommandOptionChoiceData[] { + return Object.entries(BotOptions).map(([key, option]) => ({ + name: option.displayName, + value: key, + })); +} diff --git a/src/features/bot-options/set-option.ts b/src/features/bot-options/set-option.ts new file mode 100644 index 0000000..b479af7 --- /dev/null +++ b/src/features/bot-options/set-option.ts @@ -0,0 +1,56 @@ +import { type ChatInputCommandInteraction, MessageFlags } from 'discord.js'; +import { ErrorMessages } from '@/error-messages/index.js'; +import { getBotOption, setBotOption } from '@/options.js'; +import { isStaff } from '@/util/permissions.js'; +import { OptionKey } from '@generated/prisma/enums.js'; +import { getCommandUser } from '@/util/member.js'; +import { basicMessage } from '@/util/components/basic-message.js'; + +export const setOptionHandler = async ( + interaction: ChatInputCommandInteraction +) => { + const commandUser = getCommandUser(interaction); + if (!isStaff(commandUser)) { + await interaction.reply({ + components: [ErrorMessages.User.MissingPermissions], + flags: MessageFlags.Ephemeral | MessageFlags.IsComponentsV2, + }); + } + + const optionKey = interaction.options.getString('option', true) as OptionKey; + const value = interaction.options.getString('value', true); + + const option = getBotOption(optionKey); + + if (isInvalidOptionValue(option, value)) { + await interaction.reply({ + components: [ + ErrorMessages.OptionTypes.InvalidType(optionKey, option.type), + ], + flags: MessageFlags.Ephemeral | MessageFlags.IsComponentsV2, + }); + return; + } + + await setBotOption(optionKey, value); + + await interaction.reply({ + components: [basicMessage(`Set **${optionKey}** to **${value}**`)], + flags: MessageFlags.Ephemeral | MessageFlags.IsComponentsV2, + }); +}; + +function isInvalidOptionValue( + option: ReturnType, + value: string +) { + if (option.type === 'number') { + return Number.isNaN(Number(value)); + } + + if (option.type === 'boolean') { + return !['true', 'false'].includes(value); + } + + return false; +} diff --git a/src/features/interaction-create/index.ts b/src/features/interaction-create/index.ts index 69ade02..8c8dded 100644 --- a/src/features/interaction-create/index.ts +++ b/src/features/interaction-create/index.ts @@ -4,6 +4,7 @@ import { createEvent } from '@/common/events/create-event.js'; import { handleAutoCompleteInteraction } from '@/common/interactions/autocomplete-interaction.js'; import { handleButtonInteraction } from '@/common/interactions/button-interaction.js'; import { handleModalInteraction } from '@/common/interactions/modal-interaction.js'; +import { handleSelectMenuInteraction } from '@/common/interactions/select-menu-interaction.js'; import { isAllowedServer } from '@/util/server-guard.js'; export const interactionCreateEvent = createEvent( @@ -39,6 +40,14 @@ export const interactionCreateEvent = createEvent( return; } + if (interaction.isStringSelectMenu()) { + console.log( + `Received select menu interaction with custom ID: ${interaction.customId}` + ); + await handleSelectMenuInteraction(interaction); + return; + } + if (interaction.isAutocomplete()) { console.log( `Received autocomplete interaction with custom ID: ${interaction.commandName}` diff --git a/src/features/tags/create-tag.ts b/src/features/tags/create-tag.ts new file mode 100644 index 0000000..d88d84b --- /dev/null +++ b/src/features/tags/create-tag.ts @@ -0,0 +1,142 @@ +import { Prisma } from '@generated/prisma/client.js'; +import { + type ChatInputCommandInteraction, + LabelBuilder, + MessageFlags, + ModalBuilder, + TextInputBuilder, + TextInputStyle, +} from 'discord.js'; +import { + type ModalSubmitInteraction, + registerModalSubmitInteraction, +} from '@/common/interactions/modal-interaction.js'; +import { prisma } from '@/db/prisma.js'; +import { ErrorMessages } from '@/error-messages/index.js'; +import { TagService } from '@/services/tags/tag-service.js'; +import { basicMessage } from '@/util/components/basic-message.js'; +import { customId } from '@/util/custom-id.js'; +import { getTagPrimaryAlias, isValidTagName } from '@/util/tags.js'; +import { canAccessTags } from './permissions.js'; +import { getCommandUser } from '@/util/member.js'; + +export const createTagCommandHandler = async ( + interaction: ChatInputCommandInteraction +) => { + const commandUser = getCommandUser(interaction); + if (!canAccessTags(commandUser)) { + await interaction.reply({ + components: [ErrorMessages.User.MissingRole], + flags: MessageFlags.Ephemeral | MessageFlags.IsComponentsV2, + }); + return; + } + + const modal = new ModalBuilder() + .setCustomId(customId('create-tag', interaction.user.id, Date.now())) + .setTitle('Create Tag') + .addLabelComponents( + new LabelBuilder() + .setLabel('Aliases') + .setDescription( + 'Comma-separated list of aliases (first one is the primary name)' + ) + .setTextInputComponent( + new TextInputBuilder() + .setCustomId('aliases') + .setStyle(TextInputStyle.Short) + .setRequired(true) + ), + new LabelBuilder() + .setLabel('Short Description') + .setDescription('What is this tag about?') + .setTextInputComponent( + new TextInputBuilder() + .setCustomId('desc') + .setStyle(TextInputStyle.Short) + .setRequired(true) + ), + new LabelBuilder() + .setLabel('Content') + .setDescription('The content of the tag') + .setTextInputComponent( + new TextInputBuilder() + .setCustomId('content') + .setStyle(TextInputStyle.Paragraph) + .setRequired(true) + ) + ); + await interaction.showModal(modal); +}; + +const submissionHandler: ModalSubmitInteraction = { + commandName: 'create-tag', + handler: async (interaction) => { + const commandUser = getCommandUser(interaction); + if (!canAccessTags(commandUser)) { + await interaction.reply({ + components: [ErrorMessages.User.MissingRole], + flags: MessageFlags.Ephemeral | MessageFlags.IsComponentsV2, + }); + return; + } + + const aliasesRaw = interaction.fields.getTextInputValue('aliases'); + const aliases = aliasesRaw + .split(',') + .map((alias) => alias.trim()) + .filter(Boolean); + const content = interaction.fields.getTextInputValue('content'); + const desc = interaction.fields.getTextInputValue('desc'); + + if ( + aliases.length === 0 || + aliases.some((alias) => !isValidTagName(alias)) + ) { + await interaction.reply({ + components: [ErrorMessages.Tags.InvalidTagName], + flags: MessageFlags.Ephemeral | MessageFlags.IsComponentsV2, + }); + return; + } + + const userId = interaction.user.id; + try { + const tag = await TagService.create({ + content, + desc, + userId, + aliases, + }); + + await interaction.reply({ + components: [ + basicMessage( + `Tag created with name: \`${getTagPrimaryAlias(tag)}\`.` + ), + ], + flags: MessageFlags.Ephemeral | MessageFlags.IsComponentsV2, + }); + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + if (error.code === 'P2002') { + const existingTags = await prisma.tagAlias.findMany({ + where: { + name: { in: aliases }, + }, + }); + await interaction.reply({ + components: [ + ErrorMessages.Tags.TagAlreadyExists( + existingTags.map((t) => t.name).join(', ') + ), + ], + flags: MessageFlags.Ephemeral | MessageFlags.IsComponentsV2, + }); + return; + } + } + } + }, +}; +registerModalSubmitInteraction(submissionHandler); diff --git a/src/features/tags/delete-tag.ts b/src/features/tags/delete-tag.ts new file mode 100644 index 0000000..f292a4a --- /dev/null +++ b/src/features/tags/delete-tag.ts @@ -0,0 +1,56 @@ +import { type ChatInputCommandInteraction, MessageFlags } from 'discord.js'; +import { ErrorMessages } from '@/error-messages/index.js'; +import { TagService } from '@/services/tags/tag-service.js'; +import { + basicErrorMessage, + basicMessage, +} from '@/util/components/basic-message.js'; +import { getTagPrimaryAlias } from '@/util/tags.js'; +import { getCommandUser } from '@/util/member.js'; +import { canAccessTags } from './permissions.js'; + +export const deleteTagCommandHandler = async ( + interaction: ChatInputCommandInteraction +) => { + const name = interaction.options.getString('name', true); + + const commandUser = getCommandUser(interaction); + + if (!canAccessTags(commandUser)) { + await interaction.reply({ + components: [ErrorMessages.User.MissingRole], + flags: MessageFlags.Ephemeral | MessageFlags.IsComponentsV2, + }); + return; + } + + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + const tag = await TagService.getByName(name); + if (tag === null) { + await interaction.editReply({ + components: [ErrorMessages.Tags.TagNotFound(name)], + flags: MessageFlags.IsComponentsV2, + }); + return; + } + + try { + await TagService.delete(tag.id); + await interaction.editReply({ + components: [ + basicMessage(`Tag \`${getTagPrimaryAlias(tag)}\` has been deleted.`), + ], + flags: MessageFlags.IsComponentsV2, + }); + } catch (error) { + console.error('Error deleting tag:', error); + if (!interaction.replied) { + await interaction.editReply({ + components: [ + basicErrorMessage('An error occurred while deleting the tag.'), + ], + flags: MessageFlags.IsComponentsV2, + }); + } + } +}; diff --git a/src/features/tags/edit-tag.ts b/src/features/tags/edit-tag.ts new file mode 100644 index 0000000..c3f13de --- /dev/null +++ b/src/features/tags/edit-tag.ts @@ -0,0 +1,163 @@ +import { Prisma } from '@generated/prisma/client.js'; +import { + type ChatInputCommandInteraction, + LabelBuilder, + MessageFlags, + ModalBuilder, + TextInputBuilder, + TextInputStyle, +} from 'discord.js'; +import { + type ModalSubmitInteraction, + registerModalSubmitInteraction, +} from '@/common/interactions/modal-interaction.js'; +import { prisma } from '@/db/prisma.js'; +import { ErrorMessages } from '@/error-messages/index.js'; +import { TagService } from '@/services/tags/tag-service.js'; +import { + basicErrorMessage, + basicMessage, +} from '@/util/components/basic-message.js'; +import { customId, parseCustomId } from '@/util/custom-id.js'; +import { isValidTagName } from '@/util/tags.js'; +import { getCommandUser } from '@/util/member.js'; +import { canAccessTags } from './permissions.js'; + +const BASE_NAME = 'tags-edit'; + +export const editTagCommandHandler = async ( + interaction: ChatInputCommandInteraction +) => { + const commandUser = getCommandUser(interaction); + + if (!canAccessTags(commandUser)) { + await interaction.reply({ + components: [ErrorMessages.User.MissingRole], + flags: MessageFlags.Ephemeral | MessageFlags.IsComponentsV2, + }); + return; + } + + const name = interaction.options.getString('name', true); + + const tag = await TagService.getByName(name); + if (tag === null) { + await interaction.reply({ + components: [ErrorMessages.Tags.TagNotFound(name)], + flags: MessageFlags.IsComponentsV2 | MessageFlags.Ephemeral, + }); + return; + } + + const modal = new ModalBuilder() + .setTitle(`Edit Tag: ${name}`) + .setCustomId(customId(BASE_NAME, name)) + .addLabelComponents( + new LabelBuilder() + .setLabel('Aliases') + .setDescription( + 'Comma-separated list of aliases (first one is the primary name)' + ) + .setTextInputComponent( + new TextInputBuilder() + .setCustomId('aliases') + .setStyle(TextInputStyle.Short) + .setRequired(true) + .setValue(tag.aliases.map((a) => a.name).join(', ')) + ), + new LabelBuilder() + .setLabel('Short Description') + .setDescription('What is this tag about?') + .setTextInputComponent( + new TextInputBuilder() + .setCustomId('desc') + .setStyle(TextInputStyle.Short) + .setRequired(true) + .setValue(tag.desc) + ), + new LabelBuilder() + .setLabel('Content') + .setDescription('The new content of the tag') + .setTextInputComponent( + new TextInputBuilder() + .setCustomId('content') + .setStyle(TextInputStyle.Paragraph) + .setRequired(true) + .setValue(tag.content) + ) + ); + + await interaction.showModal(modal); +}; + +const modalHandler: ModalSubmitInteraction = { + commandName: BASE_NAME, + handler: async (interaction) => { + const [_, tagName] = parseCustomId(interaction.customId); + const aliasesRaw = interaction.fields.getTextInputValue('aliases'); + const newAliases = aliasesRaw + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + const content = interaction.fields.getTextInputValue('content'); + const desc = interaction.fields.getTextInputValue('desc'); + if ( + newAliases.length === 0 || + newAliases.some((alias) => !isValidTagName(alias)) + ) { + await interaction.reply({ + components: [ErrorMessages.Tags.InvalidTagName], + flags: MessageFlags.Ephemeral | MessageFlags.IsComponentsV2, + }); + return; + } + + try { + const updatedTag = await TagService.update(tagName, { + content, + desc, + aliases: newAliases, + userId: interaction.user.id, + }); + + await interaction.reply({ + components: [ + basicMessage( + `Tag ${updatedTag?.aliases.map((tag) => tag.name).join(', ')} has been updated.` + ), + ], + flags: MessageFlags.Ephemeral | MessageFlags.IsComponentsV2, + }); + } catch (error) { + console.error(error); + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === 'P2002' + ) { + const existingTags = await prisma.tagAlias.findMany({ + where: { + name: { in: newAliases }, + }, + }); + await interaction.reply({ + components: [ + ErrorMessages.Tags.TagAlreadyExists( + existingTags.map((t) => t.name).join(', ') + ), + ], + flags: MessageFlags.Ephemeral | MessageFlags.IsComponentsV2, + }); + return; + } + await interaction.reply({ + components: [ + basicErrorMessage( + `Failed to update tag \`${tagName}\`. It may have been deleted.` + ), + ], + flags: MessageFlags.Ephemeral | MessageFlags.IsComponentsV2, + }); + } + }, +}; +registerModalSubmitInteraction(modalHandler); diff --git a/src/features/tags/get-tag-info.ts b/src/features/tags/get-tag-info.ts new file mode 100644 index 0000000..9c546bb --- /dev/null +++ b/src/features/tags/get-tag-info.ts @@ -0,0 +1,54 @@ +import { + type ChatInputCommandInteraction, + Colors, + ContainerBuilder, + MessageFlags, + time, +} from 'discord.js'; +import { TagService } from '@/services/tags/tag-service.js'; +import { basicMessage } from '@/util/components/basic-message.js'; +import { getTagPrimaryAlias } from '@/util/tags.js'; + +export const getTagInfoCommandHandler = async ( + interaction: ChatInputCommandInteraction +) => { + await interaction.deferReply(); + const tagName = interaction.options.getString('name', true); + const tag = await TagService.getByName(tagName); + if (tag === null) { + await interaction.editReply({ + components: [basicMessage(`Tag \`${tagName}\` not found.`)], + flags: MessageFlags.IsComponentsV2, + }); + return; + } + + const container = new ContainerBuilder().setAccentColor(Colors.DarkVividPink); + container + .addTextDisplayComponents((textDisplay) => + textDisplay.setContent( + [ + `**Tag info for \`${getTagPrimaryAlias(tag)}\`**`, + `**Aliases:** ${tag.aliases.map((alias) => `\`${alias.name}\``).join(', ')}`, + ].join('\n') + ) + ) + .addSeparatorComponents((separator) => separator.setDivider(true)) + .addTextDisplayComponents((textDisplay) => + textDisplay.setContent( + [`**uses:** ${tag.uses}`, ` `, tag.desc].join('\n') + ) + ) + .addSeparatorComponents((separator) => separator.setDivider(true)) + .addTextDisplayComponents((textDisplay) => + textDisplay.setContent( + `-# Last modified by: <@${tag.lastModifiedBy}> ${time(tag.updatedAt, 'R')}` + ) + ); + + await interaction.editReply({ + components: [container], + flags: MessageFlags.IsComponentsV2, + allowedMentions: { parse: [] }, + }); +}; diff --git a/src/features/tags/index.ts b/src/features/tags/index.ts new file mode 100644 index 0000000..bbec7df --- /dev/null +++ b/src/features/tags/index.ts @@ -0,0 +1,160 @@ +import { + type ApplicationCommandOptionChoiceData, + ApplicationCommandOptionType, +} from 'discord.js'; +import { createSlashCommand } from '@/common/commands/create-commands.js'; +import { + type AutoCompleteSubmitInteraction, + registerAutocompleteInteraction, +} from '@/common/interactions/autocomplete-interaction.js'; +import { prisma } from '@/db/prisma.js'; +import { createTagCommandHandler } from './create-tag.js'; +import { deleteTagCommandHandler } from './delete-tag.js'; +import { editTagCommandHandler } from './edit-tag.js'; +import { getTagInfoCommandHandler } from './get-tag-info.js'; +import { listTagsCommandHandler } from './list-tags.js'; +import { pruneTagsCommandHandler } from './prune-tags.js'; +import { topTagsCommandHandler } from './top-tags.js'; + +export const tagCommand = createSlashCommand({ + data: { + name: 'tags', + description: 'Manage tags in the server', + options: [ + { + name: 'create', + type: ApplicationCommandOptionType.Subcommand, + description: 'Create a new tag', + }, + { + name: 'edit', + type: ApplicationCommandOptionType.Subcommand, + description: 'Edit an existing tag', + options: [ + { + name: 'name', + type: ApplicationCommandOptionType.String, + description: 'The name of the tag to edit', + required: true, + autocomplete: true, + }, + ], + }, + { + name: 'list', + type: ApplicationCommandOptionType.Subcommand, + description: 'List all tags in the server', + options: [ + { + name: 'search', + type: ApplicationCommandOptionType.String, + description: 'Search tags by name', + required: false, + }, + ], + }, + { + name: 'top', + type: ApplicationCommandOptionType.Subcommand, + description: 'Displays the top 10 most used tags in the server', + options: [], + }, + { + name: 'delete', + type: ApplicationCommandOptionType.Subcommand, + description: 'Delete an existing tag', + options: [ + { + name: 'name', + type: ApplicationCommandOptionType.String, + description: 'The name of the tag to delete', + required: true, + autocomplete: true, + }, + ], + }, + { + name: 'info', + type: ApplicationCommandOptionType.Subcommand, + description: 'Get information about a tag', + options: [ + { + name: 'name', + type: ApplicationCommandOptionType.String, + description: 'The name of the tag to get information about', + required: true, + autocomplete: true, + }, + ], + }, + { + name: 'prune', + type: ApplicationCommandOptionType.Subcommand, + description: 'List unused tags that can be pruned', + options: [ + { + name: 'per_page', + type: ApplicationCommandOptionType.Integer, + description: + 'How many prunable tags to show per page (default: 10, max: 25)', + required: false, + min_value: 1, + max_value: 25, + }, + ], + }, + ], + }, + async execute(interaction) { + const subCommand = interaction.options.getSubcommand(); + const handlersMap = { + create: createTagCommandHandler, + edit: editTagCommandHandler, + list: listTagsCommandHandler, + top: topTagsCommandHandler, + delete: deleteTagCommandHandler, + info: getTagInfoCommandHandler, + prune: pruneTagsCommandHandler, + }; + + if (subCommand in handlersMap) { + await handlersMap[subCommand as keyof typeof handlersMap](interaction); + } + + return; + }, +}); + +const autoCompleteHandler: AutoCompleteSubmitInteraction = { + commandName: 'tags', + handler: async (interaction) => { + const focusedOption = interaction.options.getFocused(true); + if (focusedOption.name !== 'name') { + return; + } + const input = focusedOption.value; + const allTags = await prisma.tag.findMany({ + where: { + aliases: { + some: { + name: { contains: input }, + }, + }, + }, + include: { aliases: true }, + take: 25, + }); + + const choices = allTags.flatMap((tag) => + tag.aliases.map( + (alias): ApplicationCommandOptionChoiceData => ({ + name: alias.name, + value: alias.name, + }) + ) + ); + + await interaction.respond(choices); + }, +}; +registerAutocompleteInteraction(autoCompleteHandler); diff --git a/src/features/tags/list-tags.ts b/src/features/tags/list-tags.ts new file mode 100644 index 0000000..c6a812e --- /dev/null +++ b/src/features/tags/list-tags.ts @@ -0,0 +1,206 @@ +import { + OptionKey, + type Tag, + type TagAlias, +} from '@generated/prisma/client.js'; +import type { TagWhereInput } from '@generated/prisma/models.js'; +import { + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + type ChatInputCommandInteraction, + Colors, + ComponentType, + ContainerBuilder, + type MessageActionRowComponentBuilder, + MessageFlags, + SeparatorBuilder, + TextDisplayBuilder, + type TopLevelComponent, +} from 'discord.js'; +import { + type ButtonSubmitInteraction, + registerButtonSubmitInteraction, +} from '@/common/interactions/button-interaction.js'; +import { prisma } from '@/db/prisma.js'; +import { getBotOption } from '@/options.js'; +import { customId, parseCustomId } from '@/util/custom-id.js'; +import { getTagPrimaryAlias } from '@/util/tags.js'; +import { clampText } from '@/util/text.js'; + +export type FullTag = Tag & { aliases: TagAlias[] }; + +const PAGE_SIZE = 10; + +const buildTagWhere = (search?: string | null): TagWhereInput | undefined => + search + ? { + OR: [ + { aliases: { some: { name: { contains: search } } } }, + { desc: { contains: search } }, + ], + } + : undefined; + +const fetchTags = async (page: number, search?: string | null) => { + const where = buildTagWhere(search); + const [tags, totalCount] = await Promise.all([ + prisma.tag.findMany({ + where, + include: { aliases: { orderBy: { id: 'asc' }, take: 1 } }, + orderBy: { aliases: { _count: 'asc' } }, + take: PAGE_SIZE, + skip: (page - 1) * PAGE_SIZE, + }), + prisma.tag.count(where ? { where } : undefined), + ]); + return { tags, totalCount }; +}; + +export const buildListTagsComponents = ( + tags: FullTag[], + page: number, + userId: string, + totalCount: number, + search?: string | null +) => { + const totalPages = Math.ceil(totalCount / PAGE_SIZE); + const container = new ContainerBuilder().setAccentColor(Colors.DarkGreen); + + const headerText = search + ? `### Tags (Page ${page}/${totalPages}) - Search: "${search}"` + : `### Tags (Page ${page}/${totalPages})`; + + const offset = (page - 1) * PAGE_SIZE; + + const tagLines = tags + .map((tag, index) => { + const primaryName = getTagPrimaryAlias(tag) ?? '(unnamed)'; + return `${index + offset + 1}) **${primaryName}** • ${clampText(tag.desc, 120)} ${tag.uses > 0 ? `• Used **${tag.uses}x**` : ''}`; + }) + .join('\n'); + + container + .addTextDisplayComponents(new TextDisplayBuilder().setContent(headerText)) + .addSeparatorComponents(new SeparatorBuilder()) + .addTextDisplayComponents( + new TextDisplayBuilder().setContent(tagLines || 'No tags found.') + ) + .addSeparatorComponents(new SeparatorBuilder()) + .addTextDisplayComponents( + new TextDisplayBuilder().setContent( + `-# Total tags: ${totalCount} | Prefix: ${getBotOption(OptionKey.TAG_PREFIX).value}` + ) + ); + const actionRow = + new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(customId('list-tags', 'prev', userId)) + .setEmoji('⬅️') + .setLabel('Prev') + .setStyle(ButtonStyle.Secondary) + .setDisabled(page <= 1), + new ButtonBuilder() + .setCustomId(customId('list-tags', 'next', userId)) + .setLabel('Next') + .setEmoji('➡️') + .setStyle(ButtonStyle.Secondary) + .setDisabled(page >= totalPages) + ); + + const components = [container, actionRow]; + + return { components, totalPages }; +}; + +export const listTagsCommandHandler = async ( + interaction: ChatInputCommandInteraction +) => { + const search = interaction.options.getString('search', false); + const userId = interaction.user.id; + const currentPage = 1; + + const { tags, totalCount } = await fetchTags(currentPage, search); + const { components } = buildListTagsComponents( + tags, + totalCount > 0 ? currentPage : 0, + userId, + totalCount, + search + ); + + await interaction.reply({ + components, + flags: MessageFlags.IsComponentsV2 | MessageFlags.Ephemeral, + }); +}; + +const handleButtonSubmission: ButtonSubmitInteraction = { + commandName: 'list-tags', + handler: async (buttonInteraction) => { + const [_, action, userId] = parseCustomId(buttonInteraction.customId); + if (buttonInteraction.user.id !== userId) { + await buttonInteraction.reply({ + content: 'Only the command invoker can use these buttons.', + flags: MessageFlags.Ephemeral, + }); + return; + } + + if (!buttonInteraction.isMessageComponent()) { + return; + } + + const info = getInfoFromComponents(buttonInteraction.message.components); + if (info === undefined) { + await buttonInteraction.reply({ + content: 'An error occurred while processing the pagination.', + flags: MessageFlags.Ephemeral, + }); + return; + } + + const { page, search } = info; + const direction = action === 'next' ? 1 : -1; + const currentPage = page + direction; + await buttonInteraction.deferUpdate(); + + const { tags, totalCount } = await fetchTags(currentPage, search); + const { components } = buildListTagsComponents( + tags, + currentPage, + userId, + totalCount, + search + ); + + await buttonInteraction.editReply({ + components, + flags: MessageFlags.IsComponentsV2, + }); + }, +}; + +const getInfoFromComponents = ( + components: TopLevelComponent[] +): + | { + page: number; + search?: string; + } + | undefined => { + const container = components[0]; + if (container.type !== ComponentType.Container) { + return; + } + const component = container.components[0]; + if (component.type !== ComponentType.TextDisplay) { + return; + } + return { + page: parseInt(component.content.match(/Page (\d+)/)?.[1] || '1', 10), + search: component.content.match(/Search: "(.+)"/)?.[1], + }; +}; + +registerButtonSubmitInteraction(handleButtonSubmission); diff --git a/src/features/tags/permissions.ts b/src/features/tags/permissions.ts new file mode 100644 index 0000000..75fb618 --- /dev/null +++ b/src/features/tags/permissions.ts @@ -0,0 +1,6 @@ +import type { GuildMember } from 'discord.js'; +import { hasTagAccess, isStaff } from '@/util/permissions.js'; + +export const canAccessTags = (member: GuildMember): boolean => { + return isStaff(member) || hasTagAccess(member); +}; diff --git a/src/features/tags/prune-tags.test.ts b/src/features/tags/prune-tags.test.ts new file mode 100644 index 0000000..5540adf --- /dev/null +++ b/src/features/tags/prune-tags.test.ts @@ -0,0 +1,135 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { ComponentType, type TopLevelComponent } from 'discord.js'; +import { + clampPrunePerPage, + getCandidateAndKeepIds, + parseHeader, +} from './prune-tags.js'; + +const asComponents = (value: unknown): readonly TopLevelComponent[] => + value as readonly TopLevelComponent[]; + +void describe('clampPrunePerPage', () => { + void it('defaults to 10 when no value is provided', () => { + assert.equal(clampPrunePerPage(null), 10); + }); + + void it('returns the value as-is when within bounds', () => { + assert.equal(clampPrunePerPage(5), 5); + }); + + void it('clamps values below 1 up to 1', () => { + assert.equal(clampPrunePerPage(0), 1); + assert.equal(clampPrunePerPage(-10), 1); + }); + + void it('clamps values above 25 down to 25', () => { + assert.equal(clampPrunePerPage(100), 25); + }); +}); + +void describe('parseHeader', () => { + const withHeaderText = (content: string) => + asComponents([ + { + type: ComponentType.Container, + components: [{ type: ComponentType.TextDisplay, content }], + }, + ]); + + void it('extracts the page and per-page count from the header text', () => { + const components = withHeaderText( + '### 🧹 Prunable Tags (Page 2/5 • Per Page 10)' + ); + + assert.deepEqual(parseHeader(components), { page: 2, perPage: 10 }); + }); + + void it('returns undefined when the first component is not a container', () => { + const components = asComponents([ + { type: ComponentType.TextDisplay, content: 'oops' }, + ]); + + assert.equal(parseHeader(components), undefined); + }); + + void it('returns undefined when the container has no text display header', () => { + const components = asComponents([ + { + type: ComponentType.Container, + components: [{ type: ComponentType.Separator }], + }, + ]); + + assert.equal(parseHeader(components), undefined); + }); + + void it('returns undefined when the header text does not match the expected format', () => { + const components = withHeaderText('### Something else entirely'); + + assert.equal(parseHeader(components), undefined); + }); +}); + +void describe('getCandidateAndKeepIds', () => { + const withSelectOptions = (options: { value: string; default?: boolean }[]) => + asComponents([ + { type: ComponentType.Container, components: [] }, + { + type: ComponentType.ActionRow, + components: [{ type: ComponentType.StringSelect, options }], + }, + ]); + + void it('returns all candidate ids and only the kept ids as keepIds', () => { + const components = withSelectOptions([ + { value: '1', default: false }, + { value: '2', default: true }, + { value: '3', default: true }, + ]); + + assert.deepEqual(getCandidateAndKeepIds(components), { + candidateIds: [1, 2, 3], + keepIds: [2, 3], + }); + }); + + void it('returns empty arrays when the select row is missing', () => { + const components = asComponents([ + { type: ComponentType.Container, components: [] }, + ]); + + assert.deepEqual(getCandidateAndKeepIds(components), { + candidateIds: [], + keepIds: [], + }); + }); + + void it('returns empty arrays when the second component is not an action row', () => { + const components = asComponents([ + { type: ComponentType.Container, components: [] }, + { type: ComponentType.TextDisplay, content: 'not a row' }, + ]); + + assert.deepEqual(getCandidateAndKeepIds(components), { + candidateIds: [], + keepIds: [], + }); + }); + + void it('returns empty arrays when the action row does not contain a select menu', () => { + const components = asComponents([ + { type: ComponentType.Container, components: [] }, + { + type: ComponentType.ActionRow, + components: [{ type: ComponentType.Button }], + }, + ]); + + assert.deepEqual(getCandidateAndKeepIds(components), { + candidateIds: [], + keepIds: [], + }); + }); +}); diff --git a/src/features/tags/prune-tags.ts b/src/features/tags/prune-tags.ts new file mode 100644 index 0000000..aec624b --- /dev/null +++ b/src/features/tags/prune-tags.ts @@ -0,0 +1,340 @@ +import { + ActionRowBuilder, + type ButtonInteraction, + ButtonBuilder, + ButtonStyle, + type ChatInputCommandInteraction, + ComponentType, + Colors, + ContainerBuilder, + type MessageActionRowComponentBuilder, + MessageFlags, + SeparatorBuilder, + type StringSelectMenuInteraction, + StringSelectMenuBuilder, + StringSelectMenuOptionBuilder, + TextDisplayBuilder, + type TopLevelComponent, + time, +} from 'discord.js'; +import { + type ButtonSubmitInteraction, + registerButtonSubmitInteraction, +} from '@/common/interactions/button-interaction.js'; +import { + type SelectMenuSubmitInteraction, + registerSelectMenuSubmitInteraction, +} from '@/common/interactions/select-menu-interaction.js'; +import { ErrorMessages } from '@/error-messages/index.js'; +import { TagService } from '@/services/tags/tag-service.js'; +import { + basicErrorMessage, + basicMessage, +} from '@/util/components/basic-message.js'; +import { customId, parseCustomId } from '@/util/custom-id.js'; +import { getCommandUser } from '@/util/member.js'; +import { isStaff } from '@/util/permissions.js'; +import { clampText } from '@/util/text.js'; +import { getTagPrimaryAlias } from '@/util/tags.js'; + +export const PRUNE_TAGS_COMMAND_NAME = 'prune-tags'; +const DEFAULT_PER_PAGE = 10; +// Discord string select menus support at most 25 options. +const MAX_PER_PAGE = 25; + +const HEADER_REGEX = /Page (\d+)\/(\d+) • Per Page (\d+)/; + +export const clampPrunePerPage = (perPage: number | null): number => { + if (perPage === null) { + return DEFAULT_PER_PAGE; + } + return Math.min(Math.max(perPage, 1), MAX_PER_PAGE); +}; + +const buildPruneTagsMessage = async ({ + page, + perPage, +}: { + page: number; + perPage: number; +}): Promise<{ + components: ( + | ContainerBuilder + | ActionRowBuilder + )[]; + totalCount: number; + totalPages: number; +}> => { + const { tags, totalCount } = await TagService.getPrunableTags(page, perPage); + const totalPages = Math.max(1, Math.ceil(totalCount / perPage)); + const offset = (page - 1) * perPage; + + const tagLines = tags + .map((tag, index) => { + const primaryName = getTagPrimaryAlias(tag); + const lastUsed = tag.lastUsedAt + ? time(tag.lastUsedAt, 'R') + : 'never used'; + return `${index + offset + 1}) **${primaryName}** • ${clampText(tag.desc, 100)} • ${tag.uses} use${tag.uses === 1 ? '' : 's'} • last used ${lastUsed}`; + }) + .join('\n'); + + const container = new ContainerBuilder() + .setAccentColor(Colors.Orange) + .addTextDisplayComponents( + new TextDisplayBuilder().setContent( + `### 🧹 Prunable Tags (Page ${page}/${totalPages} • Per Page ${perPage})` + ) + ) + .addSeparatorComponents(new SeparatorBuilder()) + .addTextDisplayComponents( + new TextDisplayBuilder().setContent(tagLines || 'No prunable tags found.') + ) + .addSeparatorComponents(new SeparatorBuilder()) + .addTextDisplayComponents( + new TextDisplayBuilder().setContent( + `-# Total prunable: ${totalCount} | Select tags to KEEP below, then press Prune` + ) + ) + .addActionRowComponents( + new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(customId(PRUNE_TAGS_COMMAND_NAME, 'prev')) + .setEmoji('⬅️') + .setLabel('Prev') + .setStyle(ButtonStyle.Secondary) + .setDisabled(page <= 1), + new ButtonBuilder() + .setCustomId(customId(PRUNE_TAGS_COMMAND_NAME, 'next')) + .setEmoji('➡️') + .setLabel('Next') + .setStyle(ButtonStyle.Secondary) + .setDisabled(page >= totalPages) + ) + ); + + const components: ( + | ContainerBuilder + | ActionRowBuilder + )[] = [container]; + + if (tags.length > 0) { + const keepRow = + new ActionRowBuilder().addComponents( + new StringSelectMenuBuilder() + .setCustomId(customId(PRUNE_TAGS_COMMAND_NAME, 'keep')) + .setPlaceholder('Select tags to KEEP (excludes them from pruning)') + .setMinValues(0) + .setMaxValues(tags.length) + .addOptions( + tags.map((tag) => + new StringSelectMenuOptionBuilder() + .setLabel(clampText(getTagPrimaryAlias(tag), 100)) + .setValue(String(tag.id)) + ) + ) + ); + const pruneRow = + new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(customId(PRUNE_TAGS_COMMAND_NAME, 'execute')) + .setLabel('Prune Tags') + .setEmoji('🗑️') + .setStyle(ButtonStyle.Danger) + ); + components.push(keepRow, pruneRow); + } + + return { components, totalCount, totalPages }; +}; + +export const parseHeader = ( + components: readonly TopLevelComponent[] +): { page: number; perPage: number } | undefined => { + const container = components[0]; + if (container?.type !== ComponentType.Container) { + return; + } + const header = container.components[0]; + if (header?.type !== ComponentType.TextDisplay) { + return; + } + const match = header.content.match(HEADER_REGEX); + if (!match) { + return; + } + return { page: Number(match[1]), perPage: Number(match[3]) }; +}; + +export const getCandidateAndKeepIds = ( + components: readonly TopLevelComponent[] +): { candidateIds: number[]; keepIds: number[] } => { + const selectRow = components[1]; + if (selectRow?.type !== ComponentType.ActionRow) { + return { candidateIds: [], keepIds: [] }; + } + const select = selectRow.components[0]; + if (select?.type !== ComponentType.StringSelect) { + return { candidateIds: [], keepIds: [] }; + } + const candidateIds = select.options.map((option) => Number(option.value)); + const keepIds = select.options + .filter((option) => option.default) + .map((option) => Number(option.value)); + return { candidateIds, keepIds }; +}; + +export const pruneTagsCommandHandler = async ( + interaction: ChatInputCommandInteraction +) => { + if (!isStaff(getCommandUser(interaction))) { + await interaction.reply({ + components: [ErrorMessages.User.MissingPermissions], + flags: MessageFlags.Ephemeral | MessageFlags.IsComponentsV2, + }); + return; + } + + const perPage = clampPrunePerPage(interaction.options.getInteger('per_page')); + + await interaction.deferReply(); + const { components } = await buildPruneTagsMessage({ + page: 1, + perPage, + }); + await interaction.editReply({ + components, + flags: MessageFlags.IsComponentsV2, + }); +}; + +const handlePruneExecution = async ( + buttonInteraction: ButtonInteraction, + perPage: number +): Promise => { + const { candidateIds, keepIds } = getCandidateAndKeepIds( + buttonInteraction.message.components + ); + const keepSet = new Set(keepIds); + const deleteIds = candidateIds.filter((id) => !keepSet.has(id)); + + await buttonInteraction.deferUpdate(); + + if (deleteIds.length === 0) { + await buttonInteraction.followUp({ + components: [ + basicErrorMessage('No tags were pruned (everything was kept).'), + ], + flags: MessageFlags.Ephemeral | MessageFlags.IsComponentsV2, + }); + return; + } + + const deletedCount = await TagService.deleteMany(deleteIds); + + const { components } = await buildPruneTagsMessage({ page: 1, perPage }); + await buttonInteraction.editReply({ + components, + flags: MessageFlags.IsComponentsV2, + }); + + await buttonInteraction.followUp({ + components: [ + basicMessage( + `🗑️ Pruned ${deletedCount} tag${deletedCount === 1 ? '' : 's'}.` + ), + ], + flags: MessageFlags.Ephemeral | MessageFlags.IsComponentsV2, + }); +}; + +const handleButtonSubmission: ButtonSubmitInteraction = { + commandName: PRUNE_TAGS_COMMAND_NAME, + handler: async (buttonInteraction) => { + if (!isStaff(getCommandUser(buttonInteraction))) { + await buttonInteraction.reply({ + components: [ErrorMessages.User.MissingPermissions], + flags: MessageFlags.Ephemeral | MessageFlags.IsComponentsV2, + }); + return; + } + + const [, action] = parseCustomId(buttonInteraction.customId); + const info = parseHeader(buttonInteraction.message.components); + if (!info) { + await buttonInteraction.reply({ + components: [ + basicErrorMessage( + 'An error occurred while processing the pagination.' + ), + ], + flags: MessageFlags.Ephemeral | MessageFlags.IsComponentsV2, + }); + return; + } + + if (action === 'execute') { + await handlePruneExecution(buttonInteraction, info.perPage); + return; + } + + const direction = action === 'next' ? 1 : -1; + const nextPage = info.page + direction; + + await buttonInteraction.deferUpdate(); + const { components } = await buildPruneTagsMessage({ + page: nextPage, + perPage: info.perPage, + }); + await buttonInteraction.editReply({ + components, + flags: MessageFlags.IsComponentsV2, + }); + }, +}; +registerButtonSubmitInteraction(handleButtonSubmission); + +const handleSelectSubmission: SelectMenuSubmitInteraction = { + commandName: PRUNE_TAGS_COMMAND_NAME, + handler: async (selectInteraction: StringSelectMenuInteraction) => { + if (!isStaff(getCommandUser(selectInteraction))) { + await selectInteraction.reply({ + components: [ErrorMessages.User.MissingPermissions], + flags: MessageFlags.Ephemeral | MessageFlags.IsComponentsV2, + }); + return; + } + + const keepSet = new Set(selectInteraction.values); + const updatedSelect = new StringSelectMenuBuilder() + .setCustomId(selectInteraction.customId) + .setPlaceholder('Select tags to KEEP (excludes them from pruning)') + .setMinValues(0) + .setMaxValues(selectInteraction.component.options.length) + .addOptions( + selectInteraction.component.options.map((option) => + new StringSelectMenuOptionBuilder() + .setLabel(option.label) + .setValue(option.value) + .setDefault(keepSet.has(option.value)) + ) + ); + + const components = selectInteraction.message.components.map( + (component, index) => { + if (index !== 1) { + return component; + } + return new ActionRowBuilder().addComponents( + updatedSelect + ); + } + ); + + await selectInteraction.update({ + components, + flags: MessageFlags.IsComponentsV2, + }); + }, +}; +registerSelectMenuSubmitInteraction(handleSelectSubmission); diff --git a/src/features/tags/tag-received.ts b/src/features/tags/tag-received.ts new file mode 100644 index 0000000..d52635d --- /dev/null +++ b/src/features/tags/tag-received.ts @@ -0,0 +1,100 @@ +import { OptionKey } from '@generated/prisma/enums.js'; +import { Events, type MessageCreateOptions } from 'discord.js'; +import { createEvent } from '@/common/events/create-event.js'; +import { getBotOption } from '@/options.js'; +import { TagsCache } from '@/services/tags/tag-cache.js'; +import { TagService } from '@/services/tags/tag-service.js'; +import { UserBotMessagesService } from '@/services/user-bot-messages/user-bot-messages-service.js'; +import type { FullTag } from '@/features/tags/list-tags.js'; +import { stripAllCode } from '@/util/strip-code.js'; + +export const tagReceivedEvent = createEvent( + { + name: Events.MessageCreate, + }, + async (message) => { + if (message.author.bot || message.author.system) { + return; + } + + const prefix = getBotOption(OptionKey.TAG_PREFIX).value; + const tagRegex = new RegExp( + `(?:^|\\s)${prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([a-zA-Z][\\w-]*)`, + 'g' + ); + const stripped = stripAllCode(message.content); + const matches = [...stripped.matchAll(tagRegex)]; + if (matches.length === 0) { + return; + } + + const maxTags = getBotOption(OptionKey.MAX_TAGS_PER_MESSAGE).value; + const seenTagIds = new Set(); + const resolvedTags: FullTag[] = []; + + for (const match of matches) { + if (resolvedTags.length >= maxTags) { + break; + } + + const aliasName = match[1]; + + const cachedTagId = TagsCache.getTagId(aliasName); + if (cachedTagId !== undefined) { + if (seenTagIds.has(cachedTagId)) { + continue; + } + + const cached = TagsCache.getTag(cachedTagId); + if (cached) { + seenTagIds.add(cachedTagId); + resolvedTags.push(cached); + continue; + } + } + + const tag = await TagService.getByName(aliasName); + if (!tag) { + continue; + } + + if (seenTagIds.has(tag.id)) { + continue; + } + seenTagIds.add(tag.id); + resolvedTags.push(tag); + } + + if (resolvedTags.length === 0) { + return; + } + + let index = 0; + for (const tag of resolvedTags) { + void TagService.incrementUses(tag.id); + + const options: MessageCreateOptions = { + content: tag.content, + reply: + index > 0 + ? undefined + : { + messageReference: message.reference?.messageId ?? message.id, + }, + allowedMentions: { + parse: [], + repliedUser: message.reference !== null, + }, + }; + + const sentMessage = await message.channel.send(options); + void UserBotMessagesService.addUserBotMessage({ + messageId: sentMessage.id, + userId: message.author.id, + channelId: message.channel.id, + }); + + index++; + } + } +); diff --git a/src/features/tags/top-tags.ts b/src/features/tags/top-tags.ts new file mode 100644 index 0000000..2c89e3f --- /dev/null +++ b/src/features/tags/top-tags.ts @@ -0,0 +1,56 @@ +import { + type ChatInputCommandInteraction, + Colors, + ContainerBuilder, + MessageFlags, + TextDisplayBuilder, +} from 'discord.js'; +import { TagService } from '@/services/tags/tag-service.js'; +import { basicMessage } from '@/util/components/basic-message.js'; +import { getTagPrimaryAlias } from '@/util/tags.js'; + +export const topTagsCommandHandler = async ( + interaction: ChatInputCommandInteraction +) => { + await interaction.deferReply(); + const topTags = await TagService.getTopTags(10); + + if (topTags.length === 0) { + await interaction.editReply({ + components: [basicMessage('No tags have been used yet.')], + flags: MessageFlags.IsComponentsV2, + }); + return; + } + const longestName = Math.max( + ...topTags.map((tag) => getTagPrimaryAlias(tag).length) + ); + const medals = ['🥇', '🥈', '🥉']; + + const response = [ + '```', + ...topTags.map((tag, index) => { + const isMedal = index < 3; + + const prefix = isMedal + ? `${medals[index]} \u2005` + : `${String(index + 1).padStart(2, ' ')}. `; + + const paddedName = getTagPrimaryAlias(tag).padEnd(longestName + 2); + + return `${prefix}${paddedName}${tag.uses} use${tag.uses !== 1 ? 's' : ''}`; + }), + '```', + ].join('\n'); + + await interaction.editReply({ + components: [ + new ContainerBuilder() + .setAccentColor(Colors.Gold) + .addTextDisplayComponents( + new TextDisplayBuilder().setContent(`### Top Tags\n\n${response}`) + ), + ], + flags: MessageFlags.IsComponentsV2, + }); +}; diff --git a/src/options.ts b/src/options.ts new file mode 100644 index 0000000..e3adc1e --- /dev/null +++ b/src/options.ts @@ -0,0 +1,99 @@ +import { OptionKey } from '@generated/prisma/enums.js'; +import { prisma } from './db/prisma.js'; + +export type OptionValue = { + value: string; + type: 'string' | 'number' | 'boolean'; + displayName: string; +}; + +const OptionsDefaults = { + [OptionKey.TAG_PREFIX]: { + value: '$', + type: 'string', + displayName: 'Tag Prefix', + }, + [OptionKey.MAX_TAGS_PER_MESSAGE]: { + value: '5', + type: 'number', + displayName: 'Max Tags Per Message', + }, + [OptionKey.DAYS_TO_KEEP_TAGS]: { + value: '90', + type: 'number', + displayName: 'Days to Keep Tags', + }, +} as const satisfies Record; + +export type OptionTypeOf = + (typeof OptionsDefaults)[K]['type']; + +export type ResolvedType = T extends 'string' + ? string + : T extends 'number' + ? number + : T extends 'boolean' + ? boolean + : never; + +export type ResolvedOption = Omit & { + value: ResolvedType>; +}; + +export const BotOptions: Record = { + ...OptionsDefaults, +}; + +export const initBotOptions = async () => { + for (const [key, option] of Object.entries(OptionsDefaults)) { + const data = await prisma.options.upsert({ + where: { key: key as OptionKey }, + update: {}, + create: { + key: key as OptionKey, + value: option.value, + }, + }); + BotOptions[data.key as OptionKey] = { + value: data.value, + type: option.type, + displayName: option.displayName, + }; + } +}; + +export const getBotOption = ( + key: K +): ResolvedOption => { + const option = BotOptions[key]; + + const resolveValue = (): ResolvedType> => { + switch (option.type) { + case 'string': + return option.value as ResolvedType>; + case 'number': + return Number(option.value) as ResolvedType>; + case 'boolean': + return (option.value === 'true') as ResolvedType>; + default: + throw new Error(`Unsupported option type: ${option.type as string}`); + } + }; + + return { + ...option, + value: resolveValue(), + }; +}; + +export const setBotOption = async ( + key: K, + value: string +) => { + await prisma.options.update({ + where: { key }, + data: { value }, + }); + BotOptions[key].value = value; + return getBotOption(key); +}; diff --git a/src/services/tags/tag-cache.ts b/src/services/tags/tag-cache.ts new file mode 100644 index 0000000..6ef6b94 --- /dev/null +++ b/src/services/tags/tag-cache.ts @@ -0,0 +1,70 @@ +import { LRUCache } from 'lru-cache/raw'; +import type { FullTag } from '@/features/tags/list-tags.js'; + +class TagsCacheImpl { + private readonly byName = new LRUCache({ + max: 500, + ttl: 1000 * 60 * 10, + }); + + private readonly byId = new LRUCache({ + max: 500, + ttl: 1000 * 60 * 10, + }); + + addTag(tag: FullTag): void { + this.byId.set(tag.id, tag); + for (const alias of tag.aliases) { + this.byName.set(alias.name, tag.id); + } + } + + removeTag(tag: FullTag): void { + this.byId.delete(tag.id); + for (const alias of tag.aliases) { + this.byName.delete(alias.name); + } + } + + removeTagById(tagId: number): void { + const tag = this.byId.get(tagId); + if (tag) { + this.removeTag(tag); + } + } + + addAlias(tagId: number, aliasName: string): void { + this.byName.set(aliasName, tagId); + } + + removeAlias(aliasName: string): void { + this.byName.delete(aliasName); + } + + getTag(tagId: number): FullTag | undefined { + return this.byId.get(tagId); + } + + getTagId(aliasName: string): number | undefined { + return this.byName.get(aliasName); + } + + hasAlias(aliasName: string): boolean { + return this.byName.has(aliasName); + } + + hasTag(tagId: number): boolean { + return this.byId.has(tagId); + } + + clear(): void { + this.byName.clear(); + this.byId.clear(); + } + + get size(): number { + return this.byId.size; + } +} + +export const TagsCache = new TagsCacheImpl(); diff --git a/src/services/tags/tag-service.ts b/src/services/tags/tag-service.ts new file mode 100644 index 0000000..00d0c84 --- /dev/null +++ b/src/services/tags/tag-service.ts @@ -0,0 +1,215 @@ +import { + OptionKey, + type Tag, + type TagAlias, +} from '@generated/prisma/client.js'; +import type { TagWhereInput } from '@generated/prisma/models.js'; +import { prisma } from '@/db/prisma.js'; +import { TagsCache } from './tag-cache.js'; +import { getBotOption } from '@/options.js'; +import { DAY } from '@/constants/time.js'; + +type FullTag = Tag & { aliases: TagAlias[] }; + +const include = { aliases: true } as const; + +export const TagService = { + async getByName(name: string): Promise { + const cachedTagId = TagsCache.getTagId(name); + if (cachedTagId !== undefined) { + const cached = TagsCache.getTag(cachedTagId); + if (cached) { + return cached; + } + } + + const tag = await prisma.tag.findFirst({ + where: { aliases: { some: { name } } }, + include, + }); + + if (tag) { + TagsCache.addTag(tag); + } + return tag; + }, + + async getById(id: number): Promise { + const cached = TagsCache.getTag(id); + if (cached) { + return cached; + } + + const tag = await prisma.tag.findUnique({ where: { id }, include }); + if (tag) { + TagsCache.addTag(tag); + } + return tag; + }, + + async create(data: { + content: string; + desc: string; + userId: string; + aliases: string[]; + }): Promise { + const tag = await prisma.tag.create({ + data: { + content: data.content, + desc: data.desc, + lastModifiedBy: data.userId, + aliases: { + create: data.aliases.map((name) => ({ name })), + }, + }, + include, + }); + TagsCache.addTag(tag); + return tag; + }, + + async update( + aliasName: string, + data: { + content: string; + desc: string; + aliases: string[]; + userId: string; + } + ): Promise { + const existing = await this.getByName(aliasName); + if (!existing) { + return null; + } + + if (data.aliases.length === 0) { + throw new Error('Cannot update a tag to have no aliases.'); + } + + TagsCache.removeTag(existing); + + await prisma.$transaction([ + prisma.tag.update({ + where: { id: existing.id }, + data: { + content: data.content, + desc: data.desc, + lastModifiedBy: data.userId, + updatedAt: new Date(), + }, + }), + prisma.tagAlias.deleteMany({ + where: { tagId: existing.id }, + }), + prisma.tagAlias.createMany({ + data: data.aliases.map((name) => ({ + name, + tagId: existing.id, + })), + }), + ]); + + const tag = await prisma.tag.findUnique({ + where: { id: existing.id }, + include, + }); + if (tag) { + TagsCache.addTag(tag); + } + return tag; + }, + + async delete(id: number): Promise { + const existing = await this.getById(id); + if (!existing) { + return false; + } + + TagsCache.removeTag(existing); + + await prisma.tagAlias.deleteMany({ where: { tagId: id } }); + await prisma.tag.delete({ where: { id } }); + return true; + }, + + async incrementUses(id: number): Promise { + await prisma.tag.update({ + where: { id }, + data: { uses: { increment: 1 } }, + }); + + const cached = TagsCache.getTag(id); + if (cached) { + cached.uses += 1; + } + }, + async getTopTags(limit: number): Promise { + const tags = await prisma.tag.findMany({ + orderBy: { uses: 'desc' }, + take: limit, + include, + }); + for (const tag of tags) { + TagsCache.addTag(tag); + } + return tags; + }, + + async getUnusedTags(perPage = 10): Promise { + const { tags } = await this.getPrunableTags(1, perPage); + return tags; + }, + + async getPrunableTags( + page: number, + perPage: number + ): Promise<{ tags: FullTag[]; totalCount: number }> { + const daysToKeepTags = getBotOption(OptionKey.DAYS_TO_KEEP_TAGS).value; + const where: TagWhereInput = { + OR: [ + { + uses: 0, + lastUsedAt: null, + createdAt: { gt: new Date(Date.now() - daysToKeepTags * DAY) }, + }, + { + lastUsedAt: { + lt: new Date(Date.now() - daysToKeepTags * DAY), + }, + }, + ], + }; + + const [tags, totalCount] = await Promise.all([ + prisma.tag.findMany({ + where, + take: perPage, + skip: (page - 1) * perPage, + orderBy: { lastUsedAt: 'asc' }, + include, + }), + prisma.tag.count({ where }), + ]); + + for (const tag of tags) { + TagsCache.addTag(tag); + } + return { tags, totalCount }; + }, + + async deleteMany(ids: number[]): Promise { + if (ids.length === 0) { + return 0; + } + + for (const id of ids) { + TagsCache.removeTagById(id); + } + + await prisma.tagAlias.deleteMany({ where: { tagId: { in: ids } } }); + const { count } = await prisma.tag.deleteMany({ + where: { id: { in: ids } }, + }); + return count; + }, +}; diff --git a/src/services/user-bot-messages/user-bot-messages-service.ts b/src/services/user-bot-messages/user-bot-messages-service.ts new file mode 100644 index 0000000..dcca54a --- /dev/null +++ b/src/services/user-bot-messages/user-bot-messages-service.ts @@ -0,0 +1,70 @@ +import type { GuildMember } from 'discord.js'; +import { prisma } from '@/db/prisma.js'; +import { isStaff } from '@/util/permissions.js'; +import { DAY } from '@/constants/time.js'; + +const CLEANUP_INTERVAL_MS = 60 * 60 * 1000; // every hour + +export const UserBotMessagesService = { + async deleteUserBotMessage({ + messageId, + user, + }: { + messageId: string; + user: GuildMember; + }): Promise { + const message = await prisma.userBotMessages.findUnique({ + where: { id: messageId }, + }); + + if (message === null) { + return false; + } + + if (message.userId !== user.id && !isStaff(user)) { + return false; + } + + try { + await prisma.userBotMessages.delete({ where: { id: messageId } }); + return true; + } catch { + return false; + } + }, + + async addUserBotMessage({ + userId, + messageId, + channelId, + }: { + userId: string; + messageId: string; + channelId: string; + }): Promise { + try { + await prisma.userBotMessages.create({ + data: { + channelId, + id: messageId, + userId, + expiresAt: new Date(Date.now() + 7 * DAY), + }, + }); + } catch {} + }, + + async startExpiredMessageCleanup() { + const cleanup = async () => { + const { count } = await prisma.userBotMessages.deleteMany({ + where: { expiresAt: { lte: new Date() } }, + }); + if (count > 0) { + console.log(`Cleaned up ${count} expired user bot messages.`); + } + }; + + void cleanup(); + setInterval(cleanup, CLEANUP_INTERVAL_MS); + }, +}; diff --git a/src/util/components/basic-message.ts b/src/util/components/basic-message.ts new file mode 100644 index 0000000..6d9c716 --- /dev/null +++ b/src/util/components/basic-message.ts @@ -0,0 +1,19 @@ +import { + Colors, + ContainerBuilder, + type InteractionReplyOptions, + type RGBTuple, + TextDisplayBuilder, +} from 'discord.js'; + +export const basicMessage = ( + content: string, + options: Partial<{ color: RGBTuple | number }> = {} +): Required['components'][number] => { + return new ContainerBuilder() + .setAccentColor(options.color ?? Colors.DarkBlue) + .addTextDisplayComponents(new TextDisplayBuilder().setContent(content)); +}; + +export const basicErrorMessage = (content: string) => + basicMessage(content, { color: Colors.Red }); diff --git a/src/util/member.ts b/src/util/member.ts index 09463bf..7abf159 100644 --- a/src/util/member.ts +++ b/src/util/member.ts @@ -39,3 +39,13 @@ export const isUserModerator = ( interaction.guild?.ownerId === member.id ); }; + +export const getCommandUser = (interaction: BaseInteraction): GuildMember => { + const commandUser = interaction.member; + if (commandUser instanceof GuildMember) { + return commandUser; + } + throw new Error( + 'Command user is not a GuildMember. This should never happen since commands can only be used in guilds.' + ); +}; diff --git a/src/util/permissions.ts b/src/util/permissions.ts new file mode 100644 index 0000000..a28fedc --- /dev/null +++ b/src/util/permissions.ts @@ -0,0 +1,36 @@ +import { config } from '@/env.js'; +import type { GuildMember } from 'discord.js'; + +export const isServerOwner = (member: GuildMember): boolean => { + return member.guild.ownerId === member.id; +}; + +export const isModerator = (member: GuildMember): boolean => { + const moderatorRoles = config.roleIds.moderators.map((roleId) => + member.guild.roles.cache.get(roleId) + ); + if (moderatorRoles.length === 0) { + throw new Error( + 'Moderator role not found in the guild. Please check the configuration.' + ); + } + + const lowestModeratorRolePosition = moderatorRoles.reduce((lowest, role) => { + if (!role) { + throw new Error( + 'Moderator role not found in the guild. Please check the configuration.' + ); + } + return role.position < lowest ? role.position : lowest; + }, Number.MAX_SAFE_INTEGER); + + return member.roles.highest.position >= lowestModeratorRolePosition; +}; + +export const hasTagAccess = (member: GuildMember): boolean => { + return member.roles.cache.has(config.roleIds.tagAccess); +}; + +export const isStaff = (member: GuildMember): boolean => { + return isServerOwner(member) || isModerator(member); +}; diff --git a/src/util/strip-code.ts b/src/util/strip-code.ts new file mode 100644 index 0000000..995d060 --- /dev/null +++ b/src/util/strip-code.ts @@ -0,0 +1,8 @@ +export const stripCodeBlocks = (text: string): string => + text.replace(/```[\s\S]*?```/g, '').trim(); + +export const stripInlineCode = (text: string): string => + text.replace(/`[^`]*`/g, '').trim(); + +export const stripAllCode = (text: string): string => + stripInlineCode(stripCodeBlocks(text)).trim(); diff --git a/src/util/tags.ts b/src/util/tags.ts new file mode 100644 index 0000000..74f4fe3 --- /dev/null +++ b/src/util/tags.ts @@ -0,0 +1,10 @@ +import type { FullTag } from '@/features/tags/list-tags.js'; + +// Matches 1–32 chars of alphanumeric, hyphen, or underscore +// and rejects purely numeric names. +const TAG_NAME_REGEX = /^(?!\d+$)[a-zA-Z0-9_-]{1,32}$/; + +export const isValidTagName = (name: string): boolean => + TAG_NAME_REGEX.test(name); + +export const getTagPrimaryAlias = (tag: FullTag) => tag.aliases[0].name; diff --git a/tsconfig.json b/tsconfig.json index 3a07130..83ad5cb 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,8 +11,9 @@ "types": ["node"], "strict": true, "paths": { - "@/*": ["./src/*"] + "@/*": ["./src/*"], + "@generated/*": ["./src/generated/*"] } }, - "include": ["src/**/*", "scripts/**/*"] + "include": ["src/**/*", "scripts/**/*", "prisma.config.ts"] } diff --git a/tsup.config.ts b/tsup.config.ts index 3c4eff0..1005c5e 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -7,6 +7,6 @@ export default defineConfig({ outDir: 'dist', format: ['esm'], target: 'esnext', - entry: ['src/**/*.ts', 'scripts/**/*.ts'], + entry: ['src/**/*.ts'], minify: true, });