commit 81418ea57d5f3dc7514c6150afc9746017e554bc Author: SmileQWQ Date: Sat Apr 11 13:36:43 2026 +0800 feat: initialize SubTracker repository diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..9d4ce59 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +node_modules +apps/*/node_modules +apps/*/dist +packages/*/dist +.git +.idea +.vscode +data +apps/api/dev.db +apps/api/storage/logos +npm-debug.log diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e0f0c05 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,33 @@ +name: CI + +on: + push: + branches: + - main + pull_request: + +jobs: + build-and-check: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Prisma generate + run: npm run prisma:generate + + - name: Lint + run: npm run lint + + - name: Build + run: npm run build diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 0000000..817bd92 --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,53 @@ +name: Docker Publish + +on: + push: + branches: + - main + tags: + - 'v*' + workflow_dispatch: + +env: + IMAGE_NAME: ghcr.io/smile-qwq/subtracker-api + +jobs: + docker: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract Docker metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.IMAGE_NAME }} + tags: | + type=raw,value=latest,enable={{is_default_branch}} + type=ref,event=branch + type=ref,event=tag + type=sha + + - name: Build and push Docker image + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..00d2e3f --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +node_modules +dist +coverage +.env +.env.local +*.log +apps/api/prisma/dev.db +apps/api/prisma/dev.db-journal +apps/api/prisma/test.db +apps/api/prisma/test.db-journal +apps/api/apps/api/storage/ diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 0000000..b62bd5f --- /dev/null +++ b/DEPLOYMENT.md @@ -0,0 +1,142 @@ +# SubTracker Deployment + +本文档面向服务器部署,默认前提: + +- 前端静态文件由 **外部 Nginx** 托管 +- API 使用 Docker 镜像部署 +- SQLite 与 Logo 文件通过宿主机目录持久化 + +## 1. API 镜像约定 + +当前推荐镜像名: + +```text +ghcr.io/smile-qwq/subtracker-api:latest +``` + +`docker-compose.yml` 使用环境变量注入镜像名: + +```bash +SUBTRACKER_API_IMAGE=ghcr.io/smile-qwq/subtracker-api:latest +``` + +如果不传,Compose 也会默认使用上面的镜像地址。 + +## 2. 启动 API + +在服务器目录准备好: + +- `docker-compose.yml` +- `data/` +- `data/logos/` + +然后执行: + +```bash +docker compose pull +docker compose up -d +``` + +## 3. 核心环境变量 + +Compose 中默认只保留这些部署变量: + +- `SUBTRACKER_API_IMAGE` +- `PORT` +- `HOST` +- `DATABASE_URL` +- `WEB_ORIGIN` +- `LOG_LEVEL` + +示例: + +```bash +SUBTRACKER_API_IMAGE=ghcr.io/smile-qwq/subtracker-api:latest +PORT=3001 +HOST=0.0.0.0 +DATABASE_URL=file:/app/data/subtracker.db +WEB_ORIGIN=https://subtracker.example.com +LOG_LEVEL=warn +``` + +其余业务默认值继续由后端 `config.ts` 提供,不需要在生产环境全部展开。 + +## 4. 持久化目录 + +Compose 已挂载: + +- `./data` → SQLite 数据库目录 +- `./data/logos` → 本地 Logo 文件目录 + +请确保宿主机目录可写。 + +## 5. 外部 Nginx 反代示例 + +```nginx +server { + listen 80; + server_name subtracker.example.com; + + root /var/www/subtracker; + index index.html; + + location / { + try_files $uri $uri/ /index.html; + } + + location /api/ { + proxy_pass http://127.0.0.1:3001/api/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /static/logos/ { + proxy_pass http://127.0.0.1:3001/static/logos/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} +``` + +## 6. 前端部署 + +前端不进入 Docker。 + +推荐流程: + +```bash +npm install +npm run build -w apps/web +``` + +然后把 `apps/web/dist` 发布到外部 Nginx 静态目录。 + +## 7. GitHub Actions 与镜像发布 + +仓库已预置两个 workflow: + +- `CI`:在 `main` 和 PR 上执行安装、Prisma Generate、Lint、Build +- `Docker Publish`:在 `main`、`v*` tag 或手动触发时发布 API 镜像到 GHCR + +默认镜像发布地址: + +```text +ghcr.io/smile-qwq/subtracker-api +``` + +## 8. 升级流程 + +当 API 镜像发布完成后,服务器只需: + +```bash +docker compose pull +docker compose up -d +``` + +前端静态资源单独更新到 Nginx 目录即可。 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1e3bbef --- /dev/null +++ b/Dockerfile @@ -0,0 +1,34 @@ +FROM node:20-alpine AS builder + +WORKDIR /app + +COPY package*.json ./ +COPY apps/api/package.json apps/api/package.json +COPY packages/shared/package.json packages/shared/package.json + +RUN npm ci + +COPY . . + +RUN npm run prisma:generate -w apps/api \ + && npm run build -w packages/shared \ + && npm run build -w apps/api \ + && npm prune --omit=dev + +FROM node:20-alpine + +WORKDIR /app +ENV NODE_ENV=production + +COPY --from=builder /app/node_modules ./node_modules +COPY --from=builder /app/packages/shared/dist ./packages/shared/dist +COPY --from=builder /app/packages/shared/package.json ./packages/shared/package.json +COPY --from=builder /app/apps/api/dist ./apps/api/dist +COPY --from=builder /app/apps/api/package.json ./apps/api/package.json +COPY --from=builder /app/apps/api/prisma ./apps/api/prisma + +RUN mkdir -p /app/data /app/apps/api/storage/logos + +EXPOSE 3001 + +CMD ["node", "apps/api/dist/index.js"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..9610df7 --- /dev/null +++ b/README.md @@ -0,0 +1,54 @@ +# SubTracker + +SubTracker 是一个简洁但功能完整的个人订阅管理系统,用来统一管理多币种订阅、续费提醒、预算统计、Logo 与 AI 辅助录入。 + +## 功能亮点 + +- 订阅管理:新增、编辑、续费、暂停、停用、删除、拖拽排序 +- 多币种支持:自动汇率换算、基准货币切换、汇率转换器 +- 预算统计:月预算、年预算、分类预算、仪表盘总览 +- 通知能力:Webhook、SMTP 邮件、PushPlus +- Logo 能力:上传、本地复用、网络搜索并保存到本地 +- AI 识别:支持文本或图片识别后自动填充订阅信息 + +## 技术栈 + +- 前端:Vue 3、Vite、TypeScript、Naive UI、Pinia、Vue Router、TanStack Query、ECharts +- 后端:Fastify、Prisma、SQLite、Zod、node-cron + +## 本地开发 + +```bash +npm install +npm run prisma:generate +npm run prisma:push +npm run prisma:seed +npm run dev +``` + +默认地址: + +- Web:`http://127.0.0.1:5173` +- API:`http://localhost:3001` + +默认账号: + +- 用户名:`admin` +- 密码:`admin` + +## 部署 + +- 详细部署文档见:[DEPLOYMENT.md](./DEPLOYMENT.md) +- 推荐方式: + - 前端静态文件由外部 Nginx 托管 + - API 使用 Docker 镜像部署 + - Nginx 反代 `/api/` 和 `/static/logos/` 到 API + +## 常用命令 + +```bash +npm run dev +npm run build +npm run lint +npm test +``` diff --git a/apps/api/.env.example b/apps/api/.env.example new file mode 100644 index 0000000..04f2914 --- /dev/null +++ b/apps/api/.env.example @@ -0,0 +1,10 @@ +PORT=3001 +HOST=0.0.0.0 +DATABASE_URL="file:./dev.db" +WEB_ORIGIN="http://localhost:5173" +BASE_CURRENCY=CNY +DEFAULT_NOTIFY_DAYS=3 +EXCHANGE_RATE_PROVIDER=er-api +EXCHANGE_RATE_URL=https://open.er-api.com/v6/latest +CRON_SCAN=0 */3 * * * +CRON_REFRESH_RATES=0 2 * * * diff --git a/apps/api/package.json b/apps/api/package.json new file mode 100644 index 0000000..bd28762 --- /dev/null +++ b/apps/api/package.json @@ -0,0 +1,37 @@ +{ + "name": "@subtracker/api", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsup src/index.ts --format esm --dts", + "start": "node dist/index.js", + "test": "vitest run", + "lint": "tsc --noEmit", + "prisma:generate": "prisma generate", + "prisma:migrate": "prisma migrate dev --name init", + "prisma:push": "prisma db push", + "prisma:seed": "tsx prisma/seed.ts" + }, + "dependencies": { + "@fastify/cors": "^10.0.2", + "@prisma/client": "^6.6.0", + "@subtracker/shared": "0.1.0", + "dayjs": "^1.11.13", + "fastify": "^5.2.2", + "node-cron": "^4.0.5", + "nodemailer": "^8.0.5", + "tesseract.js": "^7.0.0", + "zod": "^3.24.3" + }, + "devDependencies": { + "@types/node": "^22.14.1", + "@types/nodemailer": "^8.0.0", + "prisma": "^6.6.0", + "tsup": "^8.4.0", + "tsx": "^4.19.3", + "typescript": "^5.8.3", + "vitest": "^3.1.1" + } +} diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma new file mode 100644 index 0000000..7beb12c --- /dev/null +++ b/apps/api/prisma/schema.prisma @@ -0,0 +1,134 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "sqlite" + url = env("DATABASE_URL") +} + +enum SubscriptionStatus { + active + paused + cancelled + expired +} + +enum BillingIntervalUnit { + day + week + month + quarter + year +} + +enum WebhookDeliveryStatus { + pending + success + failed +} + +model Category { + id String @id @default(cuid()) + name String + color String @default("#3b82f6") + icon String @default("apps-outline") + sortOrder Int @default(0) + subscriptions Subscription[] + + @@unique([name]) +} + +model Subscription { + id String @id @default(cuid()) + name String + categoryId String? + category Category? @relation(fields: [categoryId], references: [id], onDelete: SetNull) + description String @default("") + websiteUrl String? + logoUrl String? + logoSource String? + logoFetchedAt DateTime? + status SubscriptionStatus @default(active) + amount Float + currency String + billingIntervalCount Int @default(1) + billingIntervalUnit BillingIntervalUnit + startDate DateTime + nextRenewalDate DateTime + notifyDaysBefore Int @default(3) + webhookEnabled Boolean @default(true) + notes String @default("") + paymentRecords PaymentRecord[] + deliveries WebhookDelivery[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([status, nextRenewalDate]) +} + +model PaymentRecord { + id String @id @default(cuid()) + subscriptionId String + subscription Subscription @relation(fields: [subscriptionId], references: [id], onDelete: Cascade) + amount Float + currency String + baseCurrency String + convertedAmount Float + exchangeRate Float + paidAt DateTime + periodStart DateTime + periodEnd DateTime + createdAt DateTime @default(now()) + + @@index([subscriptionId, paidAt]) +} + +model ExchangeRateSnapshot { + id String @id @default(cuid()) + baseCurrency String @unique + ratesJson Json + provider String + fetchedAt DateTime + isStale Boolean @default(false) + updatedAt DateTime @updatedAt +} + +model WebhookEndpoint { + id String @id @default(cuid()) + name String + url String + secret String + enabled Boolean @default(true) + eventsJson Json + deliveries WebhookDelivery[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model WebhookDelivery { + id String @id @default(cuid()) + endpointId String + endpoint WebhookEndpoint @relation(fields: [endpointId], references: [id], onDelete: Cascade) + subscriptionId String? + subscription Subscription? @relation(fields: [subscriptionId], references: [id], onDelete: SetNull) + eventType String + resourceKey String + periodKey String + payloadJson Json + status WebhookDeliveryStatus @default(pending) + responseCode Int? + responseBody String? + attemptCount Int @default(0) + lastAttemptAt DateTime? + createdAt DateTime @default(now()) + + @@unique([endpointId, eventType, resourceKey, periodKey]) + @@index([status, createdAt]) +} + +model Setting { + key String @id + valueJson Json + updatedAt DateTime @updatedAt +} diff --git a/apps/api/prisma/seed.ts b/apps/api/prisma/seed.ts new file mode 100644 index 0000000..127199b --- /dev/null +++ b/apps/api/prisma/seed.ts @@ -0,0 +1,42 @@ +import { PrismaClient } from '@prisma/client' + +const prisma = new PrismaClient() + +async function main() { + await prisma.setting.upsert({ + where: { key: 'baseCurrency' }, + update: { valueJson: 'CNY' }, + create: { key: 'baseCurrency', valueJson: 'CNY' } + }) + + await prisma.setting.upsert({ + where: { key: 'defaultNotifyDays' }, + update: { valueJson: 3 }, + create: { key: 'defaultNotifyDays', valueJson: 3 } + }) + + const defaults = [ + { name: '开发工具', color: '#4f46e5', icon: 'code-slash-outline', sortOrder: 1 }, + { name: '影音娱乐', color: '#ef4444', icon: 'film-outline', sortOrder: 2 }, + { name: '效率办公', color: '#10b981', icon: 'briefcase-outline', sortOrder: 3 } + ] + + for (const item of defaults) { + await prisma.category.upsert({ + where: { name: item.name }, + update: item, + create: item + }) + } + + console.log('Seed completed.') +} + +main() + .catch((error) => { + console.error(error) + process.exit(1) + }) + .finally(async () => { + await prisma.$disconnect() + }) diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts new file mode 100644 index 0000000..cd165d0 --- /dev/null +++ b/apps/api/src/app.ts @@ -0,0 +1,94 @@ +import Fastify from 'fastify' +import cors from '@fastify/cors' +import { readFile } from 'node:fs/promises' +import path from 'node:path' +import { config } from './config' +import { sendError } from './http' +import { authRoutes } from './routes/auth' +import { categoryRoutes } from './routes/categories' +import { subscriptionRoutes } from './routes/subscriptions' +import { statisticsRoutes } from './routes/statistics' +import { calendarRoutes } from './routes/calendar' +import { exchangeRateRoutes } from './routes/exchange-rates' +import { webhookRoutes } from './routes/webhooks' +import { settingsRoutes } from './routes/settings' +import { notificationRoutes } from './routes/notifications' +import { aiRoutes } from './routes/ai' +import { verifyToken } from './services/auth.service' + +export async function buildApp() { + const app = Fastify({ + logger: { + level: process.env.LOG_LEVEL ?? 'warn' + } + }) + + await app.register(cors, { + origin: config.webOrigin + }) + + app.get('/health', async () => ({ ok: true, timestamp: new Date().toISOString() })) + + app.get('/static/logos/:filename', async (request, reply) => { + const filename = (request.params as { filename: string }).filename + const safeName = path.basename(filename) + const filePath = path.resolve(process.cwd(), 'apps/api/storage/logos', safeName) + const ext = path.extname(safeName).toLowerCase() + const mimeMap: Record = { + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.webp': 'image/webp', + '.svg': 'image/svg+xml' + } + + try { + const file = await readFile(filePath) + reply.header('Content-Type', mimeMap[ext] ?? 'application/octet-stream') + return reply.send(file) + } catch { + return sendError(reply, 404, 'not_found', 'Logo not found') + } + }) + + app.addHook('onRequest', async (request, reply) => { + const url = request.url.split('?')[0] + if (request.method === 'OPTIONS' || url === '/health' || url.startsWith('/static/logos/') || url === '/api/v1/auth/login') { + return + } + + const authorization = request.headers.authorization + const token = authorization?.startsWith('Bearer ') ? authorization.slice(7) : undefined + const user = await verifyToken(token) + + if (!user) { + return sendError(reply, 401, 'unauthorized', '请先登录') + } + + request.auth = user + }) + + await app.register( + async (router) => { + await authRoutes(router) + await categoryRoutes(router) + await subscriptionRoutes(router) + await statisticsRoutes(router) + await calendarRoutes(router) + await exchangeRateRoutes(router) + await webhookRoutes(router) + await settingsRoutes(router) + await notificationRoutes(router) + await aiRoutes(router) + }, + { prefix: '/api/v1' } + ) + + app.setErrorHandler((error, _request, reply) => { + app.log.error(error) + const message = error instanceof Error ? error.message : 'Unknown server error' + return sendError(reply, 500, 'internal_error', message) + }) + + return app +} diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts new file mode 100644 index 0000000..da9e715 --- /dev/null +++ b/apps/api/src/config.ts @@ -0,0 +1,11 @@ +export const config = { + port: Number(process.env.PORT ?? 3001), + host: process.env.HOST ?? '0.0.0.0', + webOrigin: process.env.WEB_ORIGIN ?? 'http://localhost:5173', + baseCurrency: (process.env.BASE_CURRENCY ?? 'CNY').toUpperCase(), + defaultNotifyDays: Number(process.env.DEFAULT_NOTIFY_DAYS ?? 3), + exchangeRateProvider: process.env.EXCHANGE_RATE_PROVIDER ?? 'er-api', + exchangeRateUrl: process.env.EXCHANGE_RATE_URL ?? 'https://open.er-api.com/v6/latest', + cronScan: process.env.CRON_SCAN ?? '0 */3 * * *', + cronRefreshRates: process.env.CRON_REFRESH_RATES ?? '0 2 * * *' +} diff --git a/apps/api/src/db.ts b/apps/api/src/db.ts new file mode 100644 index 0000000..6260dd0 --- /dev/null +++ b/apps/api/src/db.ts @@ -0,0 +1,3 @@ +import { PrismaClient } from '@prisma/client' + +export const prisma = new PrismaClient() diff --git a/apps/api/src/fastify.d.ts b/apps/api/src/fastify.d.ts new file mode 100644 index 0000000..6efd081 --- /dev/null +++ b/apps/api/src/fastify.d.ts @@ -0,0 +1,9 @@ +import 'fastify' + +declare module 'fastify' { + interface FastifyRequest { + auth?: { + username: string + } + } +} diff --git a/apps/api/src/http.ts b/apps/api/src/http.ts new file mode 100644 index 0000000..d12d29c --- /dev/null +++ b/apps/api/src/http.ts @@ -0,0 +1,19 @@ +import type { FastifyReply } from 'fastify' + +export function sendOk(reply: FastifyReply, data: T, meta?: Record) { + return reply.status(200).send({ data, meta }) +} + +export function sendCreated(reply: FastifyReply, data: T) { + return reply.status(201).send({ data }) +} + +export function sendError(reply: FastifyReply, status: number, code: string, message: string, details?: unknown) { + return reply.status(status).send({ + error: { + code, + message, + details + } + }) +} diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts new file mode 100644 index 0000000..8b43e72 --- /dev/null +++ b/apps/api/src/index.ts @@ -0,0 +1,24 @@ +import { buildApp } from './app' +import { config } from './config' +import { startSchedulers } from './services/scheduler.service' +import { refreshExchangeRates } from './services/exchange-rate.service' + +async function start() { + const app = await buildApp() + + try { + await refreshExchangeRates() + } catch (error) { + console.warn('[api] 初始汇率刷新失败,将回退到已有快照。', error) + } + + startSchedulers() + + await app.listen({ port: config.port, host: config.host }) + console.log(`[api] 已启动: http://${config.host}:${config.port}`) +} + +start().catch((error) => { + console.error(error) + process.exit(1) +}) diff --git a/apps/api/src/routes/ai.ts b/apps/api/src/routes/ai.ts new file mode 100644 index 0000000..8cbd700 --- /dev/null +++ b/apps/api/src/routes/ai.ts @@ -0,0 +1,41 @@ +import { FastifyInstance } from 'fastify' +import { AiConfigSchema } from '@subtracker/shared' +import { sendError, sendOk } from '../http' +import { recognizeSubscriptionByAi, testAiConnection } from '../services/ai.service' + +export async function aiRoutes(app: FastifyInstance) { + app.post('/ai/test', async (request, reply) => { + try { + if (request.body) { + const parsed = AiConfigSchema.partial().safeParse(request.body) + if (!parsed.success) { + return sendError(reply, 422, 'validation_error', 'Invalid AI config payload', parsed.error.flatten()) + } + return sendOk( + reply, + await testAiConnection({ + enabled: parsed.data.enabled ?? false, + providerName: parsed.data.providerName ?? 'Custom', + baseUrl: parsed.data.baseUrl ?? '', + apiKey: parsed.data.apiKey ?? '', + model: parsed.data.model ?? '', + timeoutMs: parsed.data.timeoutMs ?? 30000, + promptTemplate: parsed.data.promptTemplate ?? '' + }) + ) + } + + return sendOk(reply, await testAiConnection()) + } catch (error) { + return sendError(reply, 400, 'ai_test_failed', error instanceof Error ? error.message : 'AI test failed') + } + }) + + app.post('/ai/recognize-subscription', async (request, reply) => { + try { + return sendOk(reply, await recognizeSubscriptionByAi(request.body)) + } catch (error) { + return sendError(reply, 400, 'ai_recognition_failed', error instanceof Error ? error.message : 'AI recognition failed') + } + }) +} diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts new file mode 100644 index 0000000..7f11893 --- /dev/null +++ b/apps/api/src/routes/auth.ts @@ -0,0 +1,44 @@ +import { FastifyInstance } from 'fastify' +import { ChangeCredentialsSchema, LoginSchema } from '@subtracker/shared' +import { sendError, sendOk } from '../http' +import { changeCredentials, loginWithCredentials } from '../services/auth.service' + +export async function authRoutes(app: FastifyInstance) { + app.post('/auth/login', async (request, reply) => { + const parsed = LoginSchema.safeParse(request.body) + if (!parsed.success) { + return sendError(reply, 422, 'validation_error', 'Invalid login payload', parsed.error.flatten()) + } + + const result = await loginWithCredentials(parsed.data.username, parsed.data.password) + if (!result) { + return sendError(reply, 401, 'invalid_credentials', '用户名或密码错误') + } + + return sendOk(reply, result) + }) + + app.get('/auth/me', async (request, reply) => { + if (!request.auth) { + return sendError(reply, 401, 'unauthorized', '请先登录') + } + + return sendOk(reply, { + user: request.auth + }) + }) + + app.post('/auth/change-credentials', async (request, reply) => { + const parsed = ChangeCredentialsSchema.safeParse(request.body) + if (!parsed.success) { + return sendError(reply, 422, 'validation_error', 'Invalid credentials payload', parsed.error.flatten()) + } + + const result = await changeCredentials(parsed.data) + if (!result) { + return sendError(reply, 401, 'invalid_credentials', '原用户名或原密码错误') + } + + return sendOk(reply, result) + }) +} diff --git a/apps/api/src/routes/calendar.ts b/apps/api/src/routes/calendar.ts new file mode 100644 index 0000000..19ff446 --- /dev/null +++ b/apps/api/src/routes/calendar.ts @@ -0,0 +1,49 @@ +import dayjs from 'dayjs' +import { FastifyInstance } from 'fastify' +import { z } from 'zod' +import { prisma } from '../db' +import { sendOk, sendError } from '../http' +import { ensureExchangeRates, getBaseCurrency } from '../services/exchange-rate.service' +import { convertAmount } from '../utils/money' + +export async function calendarRoutes(app: FastifyInstance) { + app.get('/calendar/events', async (request, reply) => { + const querySchema = z.object({ + start: z.string().date().optional(), + end: z.string().date().optional() + }) + + const parsed = querySchema.safeParse(request.query) + if (!parsed.success) { + return sendError(reply, 422, 'validation_error', 'Invalid query', parsed.error.flatten()) + } + + const start = parsed.data.start ? dayjs(parsed.data.start).startOf('day').toDate() : dayjs().startOf('month').toDate() + const end = parsed.data.end ? dayjs(parsed.data.end).endOf('day').toDate() : dayjs().endOf('month').toDate() + + const subscriptions = await prisma.subscription.findMany({ + where: { + nextRenewalDate: { + gte: start, + lte: end + } + }, + orderBy: { nextRenewalDate: 'asc' } + }) + + const baseCurrency = await getBaseCurrency() + const rates = await ensureExchangeRates(baseCurrency) + + const events = subscriptions.map((item) => ({ + id: item.id, + title: item.name, + date: item.nextRenewalDate.toISOString(), + currency: item.currency, + amount: item.amount, + convertedAmount: convertAmount(item.amount, item.currency, baseCurrency, rates.baseCurrency, rates.rates), + status: item.status + })) + + return sendOk(reply, events) + }) +} diff --git a/apps/api/src/routes/categories.ts b/apps/api/src/routes/categories.ts new file mode 100644 index 0000000..1288f88 --- /dev/null +++ b/apps/api/src/routes/categories.ts @@ -0,0 +1,81 @@ +import { FastifyInstance } from 'fastify' +import { z } from 'zod' +import { prisma } from '../db' +import { sendCreated, sendError, sendOk } from '../http' +import { CategorySchema } from '@subtracker/shared' + +export async function categoryRoutes(app: FastifyInstance) { + app.get('/categories', async (_, reply) => { + const categories = await prisma.category.findMany({ orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }] }) + return sendOk(reply, categories) + }) + + app.post('/categories', async (request, reply) => { + const parsed = CategorySchema.safeParse(request.body) + if (!parsed.success) { + return sendError(reply, 422, 'validation_error', 'Invalid category payload', parsed.error.flatten()) + } + + try { + const created = await prisma.category.create({ + data: { + name: parsed.data.name, + color: parsed.data.color, + icon: parsed.data.icon, + sortOrder: parsed.data.sortOrder + } + }) + return sendCreated(reply, created) + } catch (error) { + return sendError(reply, 409, 'conflict', 'Category name already exists', error) + } + }) + + app.patch('/categories/:id', async (request, reply) => { + const params = z.object({ id: z.string() }).safeParse(request.params) + if (!params.success) { + return sendError(reply, 422, 'validation_error', 'Invalid category id') + } + + const parsed = CategorySchema.partial() + .refine((value) => Object.keys(value).length > 0, 'Empty update payload') + .safeParse(request.body) + + if (!parsed.success) { + return sendError(reply, 422, 'validation_error', 'Invalid category payload', parsed.error.flatten()) + } + + try { + const updated = await prisma.category.update({ + where: { id: params.data.id }, + data: { + ...(parsed.data.name !== undefined ? { name: parsed.data.name } : {}), + ...(parsed.data.color !== undefined ? { color: parsed.data.color } : {}), + ...(parsed.data.icon !== undefined ? { icon: parsed.data.icon } : {}), + ...(parsed.data.sortOrder !== undefined ? { sortOrder: parsed.data.sortOrder } : {}) + } + }) + + return sendOk(reply, updated) + } catch (error) { + return sendError(reply, 409, 'conflict', 'Category update failed', error) + } + }) + + app.delete('/categories/:id', async (request, reply) => { + const params = z.object({ id: z.string() }).safeParse(request.params) + if (!params.success) { + return sendError(reply, 422, 'validation_error', 'Invalid category id') + } + + try { + await prisma.category.delete({ + where: { id: params.data.id } + }) + + return sendOk(reply, { id: params.data.id, deleted: true }) + } catch (error) { + return sendError(reply, 404, 'not_found', 'Category not found', error) + } + }) +} diff --git a/apps/api/src/routes/exchange-rates.ts b/apps/api/src/routes/exchange-rates.ts new file mode 100644 index 0000000..a2a3a58 --- /dev/null +++ b/apps/api/src/routes/exchange-rates.ts @@ -0,0 +1,20 @@ +import { FastifyInstance } from 'fastify' +import { sendError, sendOk } from '../http' +import { ensureExchangeRates, getLatestSnapshot, refreshExchangeRates } from '../services/exchange-rate.service' + +export async function exchangeRateRoutes(app: FastifyInstance) { + app.get('/exchange-rates/latest', async (_, reply) => { + const snapshot = await getLatestSnapshot() + return sendOk(reply, snapshot) + }) + + app.post('/exchange-rates/refresh', async (_, reply) => { + try { + await refreshExchangeRates() + const latest = await ensureExchangeRates() + return sendOk(reply, latest) + } catch (error) { + return sendError(reply, 500, 'refresh_failed', 'Failed to refresh exchange rates', error) + } + }) +} diff --git a/apps/api/src/routes/notifications.ts b/apps/api/src/routes/notifications.ts new file mode 100644 index 0000000..3aa00a6 --- /dev/null +++ b/apps/api/src/routes/notifications.ts @@ -0,0 +1,115 @@ +import { FastifyInstance } from 'fastify' +import { z } from 'zod' +import { sendError, sendOk } from '../http' +import { EmailConfigSchema, PushPlusConfigSchema } from '@subtracker/shared' +import { + sendTestEmailNotification, + sendTestEmailNotificationWithConfig, + sendTestPushplusNotification, + sendTestPushplusNotificationWithConfig +} from '../services/channel-notification.service' +import { + getPrimaryWebhookEndpoint, + sendTestWebhookNotification, + sendTestWebhookNotificationWithConfig, + upsertPrimaryWebhookEndpoint +} from '../services/webhook.service' + +const WebhookSettingsSchema = z.object({ + url: z.string().trim().max(500).default(''), + secret: z.string().trim().max(200).default(''), + enabled: z.boolean().default(false) +}) + +export async function notificationRoutes(app: FastifyInstance) { + app.get('/notifications/webhook', async (_, reply) => { + const current = await getPrimaryWebhookEndpoint() + return sendOk(reply, { + id: current?.id ?? '', + enabled: current?.enabled ?? false, + url: current?.url ?? '', + secret: current?.secret ?? '' + }) + }) + + app.put('/notifications/webhook', async (request, reply) => { + const parsed = WebhookSettingsSchema.safeParse(request.body) + if (!parsed.success) { + return sendError(reply, 422, 'validation_error', 'Invalid webhook settings payload', parsed.error.flatten()) + } + + if (parsed.data.enabled && (!parsed.data.url || !parsed.data.secret)) { + return sendError(reply, 422, 'validation_error', '启用 Webhook 时必须填写 URL 和 Secret') + } + + const saved = await upsertPrimaryWebhookEndpoint(parsed.data) + return sendOk(reply, { + id: saved.id, + enabled: saved.enabled, + url: saved.url, + secret: saved.secret + }) + }) + + app.post('/notifications/test/email', async (request, reply) => { + try { + if (request.body) { + const parsed = EmailConfigSchema.partial().safeParse(request.body) + if (!parsed.success) { + return sendError(reply, 422, 'validation_error', 'Invalid email config payload', parsed.error.flatten()) + } + await sendTestEmailNotificationWithConfig({ + host: parsed.data.host ?? '', + port: parsed.data.port ?? 587, + secure: parsed.data.secure ?? false, + username: parsed.data.username ?? '', + password: parsed.data.password ?? '', + from: parsed.data.from ?? '', + to: parsed.data.to ?? '' + }) + } else { + await sendTestEmailNotification() + } + return sendOk(reply, { success: true }) + } catch (error) { + return sendError(reply, 400, 'email_test_failed', error instanceof Error ? error.message : 'Email test failed') + } + }) + + app.post('/notifications/test/pushplus', async (request, reply) => { + try { + if (request.body) { + const parsed = PushPlusConfigSchema.partial().safeParse(request.body) + if (!parsed.success) { + return sendError(reply, 422, 'validation_error', 'Invalid PushPlus config payload', parsed.error.flatten()) + } + await sendTestPushplusNotificationWithConfig({ + token: parsed.data.token ?? '', + topic: parsed.data.topic ?? '' + }) + } else { + await sendTestPushplusNotification() + } + return sendOk(reply, { success: true }) + } catch (error) { + return sendError(reply, 400, 'pushplus_test_failed', error instanceof Error ? error.message : 'PushPlus test failed') + } + }) + + app.post('/notifications/test/webhook', async (request, reply) => { + try { + if (request.body) { + const parsed = WebhookSettingsSchema.safeParse(request.body) + if (!parsed.success) { + return sendError(reply, 422, 'validation_error', 'Invalid webhook settings payload', parsed.error.flatten()) + } + await sendTestWebhookNotificationWithConfig(parsed.data) + } else { + await sendTestWebhookNotification() + } + return sendOk(reply, { success: true }) + } catch (error) { + return sendError(reply, 400, 'webhook_test_failed', error instanceof Error ? error.message : 'Webhook test failed') + } + }) +} diff --git a/apps/api/src/routes/settings.ts b/apps/api/src/routes/settings.ts new file mode 100644 index 0000000..f72a01f --- /dev/null +++ b/apps/api/src/routes/settings.ts @@ -0,0 +1,27 @@ +import { FastifyInstance } from 'fastify' +import { sendError, sendOk } from '../http' +import { getAppSettings, setSetting } from '../services/settings.service' +import { SettingsSchema } from '@subtracker/shared' + +export async function settingsRoutes(app: FastifyInstance) { + app.get('/settings', async (_, reply) => { + const settings = await getAppSettings() + return sendOk(reply, settings) + }) + + app.patch('/settings', async (request, reply) => { + const parsed = SettingsSchema.partial().safeParse(request.body) + if (!parsed.success) { + return sendError(reply, 422, 'validation_error', 'Invalid settings payload', parsed.error.flatten()) + } + + await Promise.all( + Object.entries(parsed.data).map(([key, value]) => { + return value === undefined ? Promise.resolve() : setSetting(key, value) + }) + ) + + const settings = await getAppSettings() + return sendOk(reply, settings) + }) +} diff --git a/apps/api/src/routes/statistics.ts b/apps/api/src/routes/statistics.ts new file mode 100644 index 0000000..08854b2 --- /dev/null +++ b/apps/api/src/routes/statistics.ts @@ -0,0 +1,10 @@ +import { FastifyInstance } from 'fastify' +import { sendOk } from '../http' +import { getOverviewStatistics } from '../services/statistics.service' + +export async function statisticsRoutes(app: FastifyInstance) { + app.get('/statistics/overview', async (_, reply) => { + const overview = await getOverviewStatistics() + return sendOk(reply, overview) + }) +} diff --git a/apps/api/src/routes/subscriptions.ts b/apps/api/src/routes/subscriptions.ts new file mode 100644 index 0000000..93b50ae --- /dev/null +++ b/apps/api/src/routes/subscriptions.ts @@ -0,0 +1,345 @@ +import dayjs from 'dayjs' +import { FastifyInstance } from 'fastify' +import { z } from 'zod' +import { prisma } from '../db' +import { sendCreated, sendError, sendOk } from '../http' +import { + CreateSubscriptionSchema, + LogoSearchSchema, + LogoUploadSchema, + RenewSubscriptionSchema, + UpdateSubscriptionSchema +} from '@subtracker/shared' +import { + appendSubscriptionOrder, + removeSubscriptionOrder, + setSubscriptionOrder, + sortSubscriptionsByOrder +} from '../services/subscription-order.service' +import { renewSubscription } from '../services/subscription.service' +import { dispatchNotificationEvent } from '../services/channel-notification.service' +import { + deleteLocalLogoFromLibrary, + getLocalLogoLibrary, + importRemoteLogo, + normalizeLogoForStorage, + saveUploadedLogo, + searchSubscriptionLogos +} from '../services/logo.service' + +export async function subscriptionRoutes(app: FastifyInstance) { + app.post('/subscriptions/logo/search', async (request, reply) => { + const parsed = LogoSearchSchema.safeParse(request.body) + if (!parsed.success) { + return sendError(reply, 422, 'validation_error', 'Invalid logo search payload', parsed.error.flatten()) + } + + return sendOk(reply, await searchSubscriptionLogos(parsed.data)) + }) + + app.get('/subscriptions/logo/library', async (_request, reply) => { + return sendOk(reply, await getLocalLogoLibrary()) + }) + + app.delete('/subscriptions/logo/library/:filename', async (request, reply) => { + const parsed = z + .object({ + filename: z.string().min(1).max(255) + }) + .safeParse(request.params) + + if (!parsed.success) { + return sendError(reply, 422, 'validation_error', 'Invalid logo filename', parsed.error.flatten()) + } + + try { + return sendOk(reply, await deleteLocalLogoFromLibrary(parsed.data.filename)) + } catch (error) { + return sendError(reply, 400, 'logo_delete_failed', error instanceof Error ? error.message : 'Logo delete failed') + } + }) + + app.post('/subscriptions/logo/upload', async (request, reply) => { + const parsed = LogoUploadSchema.safeParse(request.body) + if (!parsed.success) { + return sendError(reply, 422, 'validation_error', 'Invalid logo upload payload', parsed.error.flatten()) + } + + try { + return sendOk(reply, await saveUploadedLogo(parsed.data)) + } catch (error) { + return sendError(reply, 400, 'logo_upload_failed', error instanceof Error ? error.message : 'Logo upload failed') + } + }) + + app.post('/subscriptions/logo/import', async (request, reply) => { + const parsed = z + .object({ + logoUrl: z.string().url(), + source: z.string().max(100).optional() + }) + .safeParse(request.body) + + if (!parsed.success) { + return sendError(reply, 422, 'validation_error', 'Invalid logo import payload', parsed.error.flatten()) + } + + try { + return sendOk(reply, await importRemoteLogo(parsed.data)) + } catch (error) { + return sendError(reply, 400, 'logo_import_failed', error instanceof Error ? error.message : 'Logo import failed') + } + }) + + app.get('/subscriptions', async (request, reply) => { + const querySchema = z.object({ + q: z.string().optional(), + status: z.string().optional(), + categoryId: z.string().optional() + }) + + const parsed = querySchema.safeParse(request.query) + if (!parsed.success) { + return sendError(reply, 422, 'validation_error', 'Invalid query', parsed.error.flatten()) + } + + const where: Record = {} + if (parsed.data.q) { + where.OR = [{ name: { contains: parsed.data.q } }, { description: { contains: parsed.data.q } }] + } + if (parsed.data.status) where.status = parsed.data.status + if (parsed.data.categoryId) where.categoryId = parsed.data.categoryId + + const rows = await prisma.subscription.findMany({ + where, + include: { category: true }, + orderBy: [{ createdAt: 'asc' }] + }) + + return sendOk(reply, await sortSubscriptionsByOrder(rows)) + }) + + app.post('/subscriptions/reorder', async (request, reply) => { + const parsed = z + .object({ + ids: z.array(z.string()).min(1) + }) + .safeParse(request.body) + + if (!parsed.success) { + return sendError(reply, 422, 'validation_error', 'Invalid reorder payload', parsed.error.flatten()) + } + + await setSubscriptionOrder(parsed.data.ids) + return sendOk(reply, { success: true }) + }) + + app.get('/subscriptions/:id', async (request, reply) => { + const params = z.object({ id: z.string() }).safeParse(request.params) + if (!params.success) { + return sendError(reply, 422, 'validation_error', 'Invalid subscription id') + } + + const row = await prisma.subscription.findUnique({ + where: { id: params.data.id }, + include: { + category: true + } + }) + + if (!row) { + return sendError(reply, 404, 'not_found', 'Subscription not found') + } + + return sendOk(reply, row) + }) + + app.post('/subscriptions', async (request, reply) => { + const parsed = CreateSubscriptionSchema.safeParse(request.body) + if (!parsed.success) { + return sendError(reply, 422, 'validation_error', 'Invalid subscription payload', parsed.error.flatten()) + } + + let normalizedLogo + try { + normalizedLogo = await normalizeLogoForStorage({ + logoUrl: parsed.data.logoUrl ?? null, + logoSource: parsed.data.logoSource ?? null + }) + } catch (error) { + return sendError(reply, 400, 'logo_import_failed', error instanceof Error ? error.message : 'Logo import failed') + } + + const created = await prisma.subscription.create({ + data: { + name: parsed.data.name, + categoryId: parsed.data.categoryId ?? null, + description: parsed.data.description, + amount: parsed.data.amount, + currency: parsed.data.currency, + billingIntervalCount: parsed.data.billingIntervalCount, + billingIntervalUnit: parsed.data.billingIntervalUnit, + startDate: dayjs(parsed.data.startDate).toDate(), + nextRenewalDate: dayjs(parsed.data.nextRenewalDate).toDate(), + notifyDaysBefore: parsed.data.notifyDaysBefore, + webhookEnabled: parsed.data.webhookEnabled, + notes: parsed.data.notes, + websiteUrl: parsed.data.websiteUrl ?? null, + logoUrl: normalizedLogo.logoUrl, + logoSource: normalizedLogo.logoSource, + logoFetchedAt: normalizedLogo.logoFetchedAt + }, + include: { category: true } + }) + + await appendSubscriptionOrder(created.id) + return sendCreated(reply, created) + }) + + app.patch('/subscriptions/:id', async (request, reply) => { + const params = z.object({ id: z.string() }).safeParse(request.params) + if (!params.success) { + return sendError(reply, 422, 'validation_error', 'Invalid subscription id') + } + + const parsed = UpdateSubscriptionSchema.safeParse(request.body) + if (!parsed.success) { + return sendError(reply, 422, 'validation_error', 'Invalid update payload', parsed.error.flatten()) + } + + const payload = parsed.data + + try { + const normalizedLogo = + payload.logoUrl !== undefined || payload.logoSource !== undefined + ? await normalizeLogoForStorage({ + logoUrl: payload.logoUrl ?? null, + logoSource: payload.logoSource ?? null + }) + : null + + const updated = await prisma.subscription.update({ + where: { id: params.data.id }, + data: { + ...(payload.name !== undefined ? { name: payload.name } : {}), + ...(payload.categoryId !== undefined ? { categoryId: payload.categoryId } : {}), + ...(payload.description !== undefined ? { description: payload.description } : {}), + ...(payload.status !== undefined ? { status: payload.status } : {}), + ...(payload.amount !== undefined ? { amount: payload.amount } : {}), + ...(payload.currency !== undefined ? { currency: payload.currency } : {}), + ...(payload.billingIntervalCount !== undefined ? { billingIntervalCount: payload.billingIntervalCount } : {}), + ...(payload.billingIntervalUnit !== undefined ? { billingIntervalUnit: payload.billingIntervalUnit } : {}), + ...(payload.startDate !== undefined ? { startDate: dayjs(payload.startDate).toDate() } : {}), + ...(payload.nextRenewalDate !== undefined ? { nextRenewalDate: dayjs(payload.nextRenewalDate).toDate() } : {}), + ...(payload.notifyDaysBefore !== undefined ? { notifyDaysBefore: payload.notifyDaysBefore } : {}), + ...(payload.webhookEnabled !== undefined ? { webhookEnabled: payload.webhookEnabled } : {}), + ...(payload.notes !== undefined ? { notes: payload.notes } : {}), + ...(payload.websiteUrl !== undefined ? { websiteUrl: payload.websiteUrl } : {}), + ...(normalizedLogo + ? { + logoUrl: normalizedLogo.logoUrl, + logoSource: normalizedLogo.logoSource, + logoFetchedAt: normalizedLogo.logoFetchedAt + } + : {}) + }, + include: { category: true } + }) + + return sendOk(reply, updated) + } catch (error) { + if (error instanceof Error && error.message.includes('Logo')) { + return sendError(reply, 400, 'logo_import_failed', error.message) + } + return sendError(reply, 404, 'not_found', 'Subscription not found') + } + }) + + app.post('/subscriptions/:id/renew', async (request, reply) => { + const params = z.object({ id: z.string() }).safeParse(request.params) + if (!params.success) { + return sendError(reply, 422, 'validation_error', 'Invalid subscription id') + } + + const parsed = RenewSubscriptionSchema.safeParse(request.body ?? {}) + if (!parsed.success) { + return sendError(reply, 422, 'validation_error', 'Invalid renew payload', parsed.error.flatten()) + } + + try { + const result = await renewSubscription( + params.data.id, + parsed.data.paidAt ? dayjs(parsed.data.paidAt).toDate() : undefined, + parsed.data.amount, + parsed.data.currency + ) + + await dispatchNotificationEvent({ + eventType: 'subscription.renewed', + resourceKey: `subscription:${params.data.id}`, + periodKey: dayjs(result.payment.paidAt).format('YYYY-MM-DD'), + subscriptionId: params.data.id, + payload: { + subscriptionId: params.data.id, + paymentId: result.payment.id, + amount: result.payment.amount, + currency: result.payment.currency, + convertedAmount: result.payment.convertedAmount, + baseCurrency: result.payment.baseCurrency, + paidAt: result.payment.paidAt.toISOString(), + nextRenewalDate: result.subscription.nextRenewalDate.toISOString() + } + }) + + return sendOk(reply, result) + } catch (error) { + return sendError(reply, 404, 'not_found', error instanceof Error ? error.message : 'Renew failed') + } + }) + + app.post('/subscriptions/:id/pause', async (request, reply) => { + const params = z.object({ id: z.string() }).safeParse(request.params) + if (!params.success) { + return sendError(reply, 422, 'validation_error', 'Invalid subscription id') + } + + const updated = await prisma.subscription.update({ + where: { id: params.data.id }, + data: { status: 'paused' } + }) + + return sendOk(reply, updated) + }) + + app.post('/subscriptions/:id/cancel', async (request, reply) => { + const params = z.object({ id: z.string() }).safeParse(request.params) + if (!params.success) { + return sendError(reply, 422, 'validation_error', 'Invalid subscription id') + } + + const updated = await prisma.subscription.update({ + where: { id: params.data.id }, + data: { status: 'cancelled' } + }) + + return sendOk(reply, updated) + }) + + app.delete('/subscriptions/:id', async (request, reply) => { + const params = z.object({ id: z.string() }).safeParse(request.params) + if (!params.success) { + return sendError(reply, 422, 'validation_error', 'Invalid subscription id') + } + + try { + await prisma.subscription.delete({ + where: { id: params.data.id } + }) + + await removeSubscriptionOrder(params.data.id) + return sendOk(reply, { id: params.data.id, deleted: true }) + } catch { + return sendError(reply, 404, 'not_found', 'Subscription not found') + } + }) +} diff --git a/apps/api/src/routes/webhooks.ts b/apps/api/src/routes/webhooks.ts new file mode 100644 index 0000000..462b06f --- /dev/null +++ b/apps/api/src/routes/webhooks.ts @@ -0,0 +1,76 @@ +import { FastifyInstance } from 'fastify' +import { z } from 'zod' +import { prisma } from '../db' +import { sendCreated, sendError, sendOk } from '../http' +import { CreateWebhookEndpointSchema, UpdateWebhookEndpointSchema } from '@subtracker/shared' +import { listWebhookDeliveries } from '../services/webhook.service' + +export async function webhookRoutes(app: FastifyInstance) { + app.get('/webhooks', async (_, reply) => { + const rows = await prisma.webhookEndpoint.findMany({ orderBy: { createdAt: 'desc' } }) + return sendOk(reply, rows) + }) + + app.post('/webhooks', async (request, reply) => { + const parsed = CreateWebhookEndpointSchema.safeParse(request.body) + if (!parsed.success) { + return sendError(reply, 422, 'validation_error', 'Invalid webhook payload', parsed.error.flatten()) + } + + const created = await prisma.webhookEndpoint.create({ + data: { + name: parsed.data.name, + url: parsed.data.url, + secret: parsed.data.secret, + enabled: parsed.data.enabled, + eventsJson: parsed.data.events + } + }) + + return sendCreated(reply, created) + }) + + app.patch('/webhooks/:id', async (request, reply) => { + const params = z.object({ id: z.string() }).safeParse(request.params) + if (!params.success) { + return sendError(reply, 422, 'validation_error', 'Invalid webhook id') + } + + const parsed = UpdateWebhookEndpointSchema.safeParse(request.body) + if (!parsed.success) { + return sendError(reply, 422, 'validation_error', 'Invalid webhook payload', parsed.error.flatten()) + } + + const payload = parsed.data + + try { + const updated = await prisma.webhookEndpoint.update({ + where: { id: params.data.id }, + data: { + ...(payload.name !== undefined ? { name: payload.name } : {}), + ...(payload.url !== undefined ? { url: payload.url } : {}), + ...(payload.secret !== undefined ? { secret: payload.secret } : {}), + ...(payload.enabled !== undefined ? { enabled: payload.enabled } : {}), + ...(payload.events !== undefined ? { eventsJson: payload.events } : {}) + } + }) + return sendOk(reply, updated) + } catch { + return sendError(reply, 404, 'not_found', 'Webhook not found') + } + }) + + app.get('/webhook-deliveries', async (request, reply) => { + const querySchema = z.object({ + limit: z.coerce.number().int().min(1).max(500).default(100) + }) + + const parsed = querySchema.safeParse(request.query) + if (!parsed.success) { + return sendError(reply, 422, 'validation_error', 'Invalid query', parsed.error.flatten()) + } + + const deliveries = await listWebhookDeliveries(parsed.data.limit) + return sendOk(reply, deliveries) + }) +} diff --git a/apps/api/src/services/ai.service.ts b/apps/api/src/services/ai.service.ts new file mode 100644 index 0000000..bf907d6 --- /dev/null +++ b/apps/api/src/services/ai.service.ts @@ -0,0 +1,285 @@ +import { mkdir } from 'node:fs/promises' +import path from 'node:path' +import { createWorker, type Worker } from 'tesseract.js' +import { AiRecognizeSubscriptionSchema } from '@subtracker/shared' +import type { AiRecognitionResultDto } from '@subtracker/shared' +import { getAppSettings } from './settings.service' + +const defaultPrompt = `你是订阅账单信息提取助手。请从输入的文本或截图中提取订阅信息,并且只返回 JSON。 +输出字段: +- name +- description +- amount +- currency +- billingIntervalCount +- billingIntervalUnit(day|week|month|quarter|year) +- startDate(YYYY-MM-DD) +- nextRenewalDate(YYYY-MM-DD) +- notifyDaysBefore +- websiteUrl +- notes +- confidence(0~1) +- rawText + +规则: +1. 不确定就留空,不要猜。 +2. 金额必须是数字。 +3. 货币必须是 3 位大写代码,例如 CNY、USD。 +4. 周期单位必须在 day/week/month/quarter/year 中。 +5. 只返回 JSON,不要返回 Markdown。` + +export type AiSettings = Awaited>['aiConfig'] + +const ocrCachePath = path.resolve(process.cwd(), 'apps/api/storage/tesseract-cache') +let ocrWorkerPromise: Promise | null = null + +function ensureAiConfig(aiConfig: AiSettings) { + if (!aiConfig.enabled) { + throw new Error('AI 识别未启用') + } + + if (!aiConfig.baseUrl || !aiConfig.apiKey || !aiConfig.model) { + throw new Error('AI 配置不完整') + } +} + +function modelLooksVisionCapable(model: string) { + const normalized = model.toLowerCase() + return ['vision', 'vl', 'gpt-4o', 'gpt-4.1', 'gemini', 'claude-3', 'qwen-vl'].some((keyword) => + normalized.includes(keyword) + ) +} + +function looksLikeImageFormatUnsupported(errorText: string) { + const normalized = errorText.toLowerCase() + return ( + normalized.includes('unknown variant `image_url`') || + normalized.includes("unknown variant 'image_url'") || + normalized.includes('expected `text`') || + normalized.includes('expected "text"') || + normalized.includes('does not support image') || + normalized.includes('image input') + ) +} + +async function getOcrWorker() { + if (!ocrWorkerPromise) { + ocrWorkerPromise = (async () => { + await mkdir(ocrCachePath, { recursive: true }) + return createWorker(['eng', 'chi_sim'], 1, { + cachePath: ocrCachePath, + logger: () => {} + }) + })() + } + + return ocrWorkerPromise +} + +async function extractTextFromImageWithOcr(imageBase64: string) { + const worker = await getOcrWorker() + const imageBuffer = Buffer.from(imageBase64, 'base64') + const result = await worker.recognize(imageBuffer) + return (result.data.text || '').trim() +} + +async function requestAiChatCompletion(params: { + aiConfig: AiSettings + messages: Array> + responseFormat?: { type: 'json_object' } +}) { + const { aiConfig } = params + ensureAiConfig(aiConfig) + + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), aiConfig.timeoutMs) + + try { + const response = await fetch(`${aiConfig.baseUrl.replace(/\/$/, '')}/chat/completions`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${aiConfig.apiKey}` + }, + body: JSON.stringify({ + model: aiConfig.model, + temperature: 0.1, + ...(params.responseFormat ? { response_format: params.responseFormat } : {}), + messages: params.messages + }), + signal: controller.signal + }) + + if (!response.ok) { + const errorText = await response.text() + throw new Error(`AI 接口请求失败:${response.status}${errorText ? ` - ${errorText}` : ''}`) + } + + const payload = (await response.json()) as { + choices?: Array<{ message?: { content?: string } }> + } + + const raw = payload.choices?.[0]?.message?.content + if (!raw) { + throw new Error('AI 未返回有效内容') + } + + return raw + } finally { + clearTimeout(timeout) + } +} + +function buildTextOnlyUserContent(input: { text?: string; ocrText?: string }) { + const parts = [ + input.text?.trim() ? `原始文本:\n${input.text.trim()}` : '', + input.ocrText?.trim() ? `OCR 提取文本:\n${input.ocrText.trim()}` : '' + ].filter(Boolean) + + return parts.join('\n\n').trim() +} + +async function recognizeByTextOnly(params: { + aiConfig: AiSettings + text?: string + ocrText?: string +}) { + const userText = buildTextOnlyUserContent({ + text: params.text, + ocrText: params.ocrText + }) + + if (!userText) { + throw new Error('未获取到可用于识别的文本内容') + } + + const raw = await requestAiChatCompletion({ + aiConfig: params.aiConfig, + responseFormat: { type: 'json_object' }, + messages: [ + { + role: 'system', + content: params.aiConfig.promptTemplate?.trim() || defaultPrompt + }, + { + role: 'user', + content: userText + } + ] + }) + + return JSON.parse(raw) as AiRecognitionResultDto +} + +async function recognizeByVision(params: { + aiConfig: AiSettings + text?: string + imageBase64: string + mimeType?: string +}) { + const content: Array> = [] + + if (params.text?.trim()) { + content.push({ + type: 'text', + text: params.text.trim() + }) + } + + content.push({ + type: 'image_url', + image_url: { + url: `data:${params.mimeType || 'image/png'};base64,${params.imageBase64}` + } + }) + + const raw = await requestAiChatCompletion({ + aiConfig: params.aiConfig, + responseFormat: { type: 'json_object' }, + messages: [ + { + role: 'system', + content: params.aiConfig.promptTemplate?.trim() || defaultPrompt + }, + { + role: 'user', + content + } + ] + }) + + return JSON.parse(raw) as AiRecognitionResultDto +} + +export async function recognizeSubscriptionByAi(input: unknown): Promise { + const parsed = AiRecognizeSubscriptionSchema.safeParse(input) + if (!parsed.success) { + throw new Error('AI 识别输入不合法') + } + + const settings = await getAppSettings() + const { aiConfig } = settings + + const text = parsed.data.text?.trim() + const imageBase64 = parsed.data.imageBase64 + + if (!imageBase64) { + return recognizeByTextOnly({ + aiConfig, + text + }) + } + + if (modelLooksVisionCapable(aiConfig.model)) { + try { + return await recognizeByVision({ + aiConfig, + text, + imageBase64, + mimeType: parsed.data.mimeType + }) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + if (!looksLikeImageFormatUnsupported(message)) { + throw error + } + } + } + + const ocrText = await extractTextFromImageWithOcr(imageBase64) + if (!ocrText) { + throw new Error('图片 OCR 未识别出有效文本,请改为手动输入文本内容') + } + + return recognizeByTextOnly({ + aiConfig, + text, + ocrText + }) +} + +export async function testAiConnection(overrideConfig?: AiSettings) { + const settings = await getAppSettings() + const aiConfig = overrideConfig ?? settings.aiConfig + + const raw = await requestAiChatCompletion({ + aiConfig, + messages: [ + { + role: 'system', + content: '请只返回 OK' + }, + { + role: 'user', + content: 'ping' + } + ] + }) + + return { + success: true, + providerName: aiConfig.providerName, + model: aiConfig.model, + response: raw.trim() + } +} diff --git a/apps/api/src/services/auth.service.ts b/apps/api/src/services/auth.service.ts new file mode 100644 index 0000000..5c2740d --- /dev/null +++ b/apps/api/src/services/auth.service.ts @@ -0,0 +1,165 @@ +import { createHmac, randomBytes, scryptSync, timingSafeEqual } from 'node:crypto' +import { getSetting, setSetting } from './settings.service' + +const CREDENTIALS_KEY = 'authCredentials' +const SESSION_SECRET_KEY = 'authSessionSecret' +const DEFAULT_USERNAME = 'admin' +const DEFAULT_PASSWORD = 'admin' +const TOKEN_TTL_MS = 7 * 24 * 60 * 60 * 1000 + +type StoredCredentials = { + username: string + passwordHash: string + passwordSalt: string +} + +type SessionPayload = { + sub: string + iat: number + exp: number +} + +export type AuthUser = { + username: string +} + +function hashPassword(password: string, salt: string) { + return scryptSync(password, salt, 64).toString('hex') +} + +function createPasswordRecord(password: string) { + const passwordSalt = randomBytes(16).toString('hex') + const passwordHash = hashPassword(password, passwordSalt) + return { + passwordSalt, + passwordHash + } +} + +function encodeBase64Url(value: string) { + return Buffer.from(value, 'utf8').toString('base64url') +} + +function decodeBase64Url(value: string) { + return Buffer.from(value, 'base64url').toString('utf8') +} + +async function getSessionSecret() { + const existing = await getSetting(SESSION_SECRET_KEY, null) + if (existing) return existing + + const created = randomBytes(32).toString('hex') + await setSetting(SESSION_SECRET_KEY, created) + return created +} + +export async function getStoredCredentials() { + const existing = await getSetting(CREDENTIALS_KEY, null) + if (existing) return existing + + const defaultRecord = createPasswordRecord(DEFAULT_PASSWORD) + const created: StoredCredentials = { + username: DEFAULT_USERNAME, + ...defaultRecord + } + + await setSetting(CREDENTIALS_KEY, created) + return created +} + +function verifyPassword(password: string, record: StoredCredentials) { + const actual = Buffer.from(hashPassword(password, record.passwordSalt), 'hex') + const expected = Buffer.from(record.passwordHash, 'hex') + + if (actual.length !== expected.length) { + return false + } + + return timingSafeEqual(actual, expected) +} + +async function signPayload(payload: SessionPayload) { + const secret = await getSessionSecret() + const body = encodeBase64Url(JSON.stringify(payload)) + const signature = createHmac('sha256', secret).update(body).digest('base64url') + return `${body}.${signature}` +} + +export async function issueToken(username: string) { + const now = Date.now() + return signPayload({ + sub: username, + iat: now, + exp: now + TOKEN_TTL_MS + }) +} + +export async function verifyToken(token?: string) { + if (!token) return null + + const [body, signature] = token.split('.') + if (!body || !signature) return null + + const secret = await getSessionSecret() + const expectedSignature = createHmac('sha256', secret).update(body).digest('base64url') + + const actual = Buffer.from(signature) + const expected = Buffer.from(expectedSignature) + if (actual.length !== expected.length || !timingSafeEqual(actual, expected)) { + return null + } + + try { + const payload = JSON.parse(decodeBase64Url(body)) as SessionPayload + if (!payload.sub || payload.exp < Date.now()) { + return null + } + + return { + username: payload.sub + } satisfies AuthUser + } catch { + return null + } +} + +export async function loginWithCredentials(username: string, password: string) { + const credentials = await getStoredCredentials() + if (credentials.username !== username || !verifyPassword(password, credentials)) { + return null + } + + return { + token: await issueToken(credentials.username), + user: { + username: credentials.username + } satisfies AuthUser + } +} + +export async function changeCredentials(input: { + oldUsername: string + oldPassword: string + newUsername: string + newPassword: string +}) { + const credentials = await getStoredCredentials() + if (credentials.username !== input.oldUsername || !verifyPassword(input.oldPassword, credentials)) { + return null + } + + const nextPassword = createPasswordRecord(input.newPassword) + const nextCredentials: StoredCredentials = { + username: input.newUsername, + ...nextPassword + } + + await setSetting(CREDENTIALS_KEY, nextCredentials) + + return { + token: await issueToken(input.newUsername), + user: { + username: input.newUsername + } satisfies AuthUser + } +} diff --git a/apps/api/src/services/channel-notification.service.ts b/apps/api/src/services/channel-notification.service.ts new file mode 100644 index 0000000..4a47344 --- /dev/null +++ b/apps/api/src/services/channel-notification.service.ts @@ -0,0 +1,189 @@ +import nodemailer from 'nodemailer' +import type { EmailConfigInput, PushPlusConfigInput, WebhookEventType } from '@subtracker/shared' +import { dispatchWebhookEvent } from './webhook.service' +import { getAppSettings, getSetting, setSetting } from './settings.service' + +type NotificationDispatchParams = { + eventType: WebhookEventType + resourceKey: string + periodKey: string + subscriptionId?: string + payload: Record +} + +function buildNotificationKey(channel: 'email' | 'pushplus', params: NotificationDispatchParams) { + return `notification:${channel}:${params.eventType}:${params.resourceKey}:${params.periodKey}` +} + +async function hasNotificationBeenSent(channel: 'email' | 'pushplus', params: NotificationDispatchParams) { + return getSetting(buildNotificationKey(channel, params), false) +} + +async function markNotificationSent(channel: 'email' | 'pushplus', params: NotificationDispatchParams) { + await setSetting(buildNotificationKey(channel, params), true) +} + +function buildNotificationTitle(params: NotificationDispatchParams) { + switch (params.eventType) { + case 'subscription.reminder_due': + return `订阅即将续费:${String(params.payload.name ?? '')}` + case 'subscription.overdue': + return `订阅已过期:${String(params.payload.name ?? '')}` + case 'subscription.renewed': + return `订阅已续费:${String(params.payload.subscriptionId ?? params.payload.name ?? '')}` + case 'exchange-rate.stale': + return '汇率快照已过期' + default: + return 'SubTracker 通知' + } +} + +function buildNotificationBody(params: NotificationDispatchParams) { + return [ + `事件:${params.eventType}`, + `资源:${params.resourceKey}`, + `周期:${params.periodKey}`, + '', + JSON.stringify(params.payload, null, 2) + ].join('\n') +} + +async function sendEmailWithConfig(params: NotificationDispatchParams, config: EmailConfigInput) { + const { host, port, secure, username, password, from, to } = config + if (!host || !port || !username || !password || !from || !to) { + throw new Error('邮箱通知未启用或配置不完整') + } + + const transporter = nodemailer.createTransport({ + host, + port, + secure, + auth: { + user: username, + pass: password + } + }) + + await transporter.sendMail({ + from, + to, + subject: buildNotificationTitle(params), + text: buildNotificationBody(params) + }) +} + +async function sendEmailNotification(params: NotificationDispatchParams) { + const settings = await getAppSettings() + if (!settings.emailNotificationsEnabled) return false + + const alreadySent = await hasNotificationBeenSent('email', params) + if (alreadySent) return false + + await sendEmailWithConfig(params, settings.emailConfig) + await markNotificationSent('email', params) + return true +} + +async function sendPushplusWithConfig(params: NotificationDispatchParams, config: PushPlusConfigInput) { + const { token, topic } = config + if (!token) { + throw new Error('PushPlus 通知未启用或配置不完整') + } + + const response = await fetch('https://www.pushplus.plus/send', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + token, + topic: topic || undefined, + title: buildNotificationTitle(params), + content: `
${buildNotificationBody(params)}
`, + template: 'html' + }) + }) + + if (!response.ok) { + throw new Error(`PushPlus request failed with status ${response.status}`) + } +} + +async function sendPushplusNotification(params: NotificationDispatchParams) { + const settings = await getAppSettings() + if (!settings.pushplusNotificationsEnabled) return false + + const alreadySent = await hasNotificationBeenSent('pushplus', params) + if (alreadySent) return false + + await sendPushplusWithConfig(params, settings.pushplusConfig) + await markNotificationSent('pushplus', params) + return true +} + +export async function dispatchNotificationEvent(params: NotificationDispatchParams) { + await dispatchWebhookEvent(params) + await Promise.allSettled([sendEmailNotification(params), sendPushplusNotification(params)]) +} + +export async function sendTestEmailNotification() { + const success = await sendEmailNotification({ + eventType: 'subscription.reminder_due', + resourceKey: 'test:email', + periodKey: new Date().toISOString().slice(0, 10), + payload: { + name: '测试订阅', + nextRenewalDate: new Date().toISOString() + } + }) + + if (!success) { + throw new Error('邮箱通知未启用或配置不完整') + } +} + +export async function sendTestEmailNotificationWithConfig(config: EmailConfigInput) { + await sendEmailWithConfig( + { + eventType: 'subscription.reminder_due', + resourceKey: 'test:email', + periodKey: new Date().toISOString().slice(0, 10), + payload: { + name: '测试订阅', + nextRenewalDate: new Date().toISOString() + } + }, + config + ) +} + +export async function sendTestPushplusNotification() { + const success = await sendPushplusNotification({ + eventType: 'subscription.reminder_due', + resourceKey: 'test:pushplus', + periodKey: new Date().toISOString().slice(0, 10), + payload: { + name: '测试订阅', + nextRenewalDate: new Date().toISOString() + } + }) + + if (!success) { + throw new Error('PushPlus 通知未启用或配置不完整') + } +} + +export async function sendTestPushplusNotificationWithConfig(config: PushPlusConfigInput) { + await sendPushplusWithConfig( + { + eventType: 'subscription.reminder_due', + resourceKey: 'test:pushplus', + periodKey: new Date().toISOString().slice(0, 10), + payload: { + name: '测试订阅', + nextRenewalDate: new Date().toISOString() + } + }, + config + ) +} diff --git a/apps/api/src/services/exchange-rate.service.ts b/apps/api/src/services/exchange-rate.service.ts new file mode 100644 index 0000000..21c6714 --- /dev/null +++ b/apps/api/src/services/exchange-rate.service.ts @@ -0,0 +1,105 @@ +import dayjs from 'dayjs' +import { prisma } from '../db' +import { config } from '../config' +import { convertAmount } from '../utils/money' +import { getSetting } from './settings.service' +import type { ExchangeRateSnapshotDto } from '@subtracker/shared' + +type ProviderResponse = { + result?: string + base_code?: string + base?: string + time_last_update_utc?: string + rates: Record +} + +export async function getBaseCurrency(): Promise { + const baseCurrency = await getSetting('baseCurrency', config.baseCurrency) + return String(baseCurrency).toUpperCase() +} + +export async function getLatestSnapshot(baseCurrency?: string): Promise { + const base = (baseCurrency ?? (await getBaseCurrency())).toUpperCase() + let snapshot = await prisma.exchangeRateSnapshot.findUnique({ where: { baseCurrency: base } }) + + if (!snapshot) { + snapshot = await refreshExchangeRates(base) + } + + return { + baseCurrency: snapshot.baseCurrency, + rates: snapshot.ratesJson as Record, + fetchedAt: snapshot.fetchedAt.toISOString(), + provider: snapshot.provider, + isStale: snapshot.isStale + } +} + +export async function refreshExchangeRates(baseCurrency?: string) { + const base = (baseCurrency ?? (await getBaseCurrency())).toUpperCase() + + try { + const response = await fetch(`${config.exchangeRateUrl}/${base}`) + if (!response.ok) { + throw new Error(`Rate provider failed with status ${response.status}`) + } + + const payload = (await response.json()) as ProviderResponse + const rates = payload.rates ?? {} + + if (!Object.keys(rates).length) { + throw new Error('Rate payload is empty') + } + + return await prisma.exchangeRateSnapshot.upsert({ + where: { baseCurrency: base }, + update: { + ratesJson: rates, + provider: config.exchangeRateProvider, + fetchedAt: new Date(), + isStale: false + }, + create: { + baseCurrency: base, + ratesJson: rates, + provider: config.exchangeRateProvider, + fetchedAt: new Date(), + isStale: false + } + }) + } catch (error) { + const existing = await prisma.exchangeRateSnapshot.findUnique({ where: { baseCurrency: base } }) + if (existing) { + return prisma.exchangeRateSnapshot.update({ + where: { baseCurrency: base }, + data: { + isStale: true + } + }) + } + + throw error + } +} + +export async function ensureExchangeRates(baseCurrency?: string): Promise { + const base = (baseCurrency ?? (await getBaseCurrency())).toUpperCase() + const existing = await prisma.exchangeRateSnapshot.findUnique({ where: { baseCurrency: base } }) + + if (!existing) { + return getLatestSnapshot(base) + } + + const shouldRefresh = dayjs().diff(dayjs(existing.fetchedAt), 'hour') >= 24 + + if (shouldRefresh) { + await refreshExchangeRates(base) + } + + return getLatestSnapshot(base) +} + +export async function convertToBase(amount: number, sourceCurrency: string): Promise { + const snapshot = await ensureExchangeRates() + return convertAmount(amount, sourceCurrency, snapshot.baseCurrency, snapshot.baseCurrency, snapshot.rates) +} diff --git a/apps/api/src/services/logo.service.ts b/apps/api/src/services/logo.service.ts new file mode 100644 index 0000000..df4f380 --- /dev/null +++ b/apps/api/src/services/logo.service.ts @@ -0,0 +1,770 @@ +import { mkdir, readdir, stat, unlink, writeFile } from 'node:fs/promises' +import crypto from 'node:crypto' +import path from 'node:path' +import { prisma } from '../db' +import type { LogoSearchResultDto } from '@subtracker/shared' + +const logoDir = path.resolve(process.cwd(), 'apps/api/storage/logos') +const SEARCH_USER_AGENT = + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36' +const LOGO_REQUEST_TIMEOUT_MS = 20000 +const SEARCH_CACHE_TTL_MS = 5 * 60 * 1000 +const allowedTypes = new Set(['image/png', 'image/jpeg', 'image/jpg', 'image/webp', 'image/svg+xml']) +const extensionMap: Record = { + 'image/png': '.png', + 'image/jpeg': '.jpg', + 'image/jpg': '.jpg', + 'image/webp': '.webp', + 'image/svg+xml': '.svg' +} + +type RawCandidate = LogoSearchResultDto & { + scoreHint?: number + fallback?: boolean +} + +type ImageMeta = { + finalUrl: string + contentType: string + width?: number + height?: number + buffer: Buffer +} + +type SearchCacheEntry = { + expiresAt: number + items: LogoSearchResultDto[] +} + +const searchCache = new Map() + +function normalizeWebsiteUrl(input?: string) { + if (!input) return '' + try { + const withProtocol = /^https?:\/\//i.test(input) ? input : `https://${input}` + const url = new URL(withProtocol) + return url.origin + } catch { + return '' + } +} + +function deriveDomainSeed(name: string) { + return name + .toLowerCase() + .replace(/[^a-z0-9]+/g, '') + .trim() +} + +function resolveUrl(origin: string, value?: string | null) { + if (!value) return '' + try { + return new URL(value, `${origin}/`).toString() + } catch { + return '' + } +} + +function sourcePriority(source: string) { + switch (source) { + case 'manifest': + case 'manifest:any': + case 'manifest:maskable': + case 'manifest:monochrome': + return 100 + case 'apple-touch-icon': + return 96 + case 'html-icon': + return 90 + case 'mask-icon': + return 84 + case 'og-image': + return 72 + case 'duckduckgo': + return 80 + case 'brave': + return 68 + case 'favicon': + return 38 + case 'google-favicon': + return 28 + case 'clearbit': + return 24 + case 'icon-horse': + return 22 + default: + return 50 + } +} + +function makeCandidate( + list: RawCandidate[], + label: string, + logoUrl: string, + source: string, + options?: { + websiteUrl?: string + width?: number + height?: number + fallback?: boolean + } +) { + if (!logoUrl) return + + let normalizedUrl = '' + try { + normalizedUrl = new URL(logoUrl).toString() + } catch { + return + } + + if (!/^https?:\/\//i.test(normalizedUrl)) return + if (list.some((item) => item.logoUrl === normalizedUrl)) return + + list.push({ + label, + logoUrl: normalizedUrl, + source, + websiteUrl: options?.websiteUrl, + width: options?.width, + height: options?.height, + fallback: options?.fallback ?? false, + scoreHint: sourcePriority(source) + }) +} + +async function fetchText(url: string, headers?: Record) { + const response = await fetch(url, { + headers: { + 'User-Agent': SEARCH_USER_AGENT, + ...headers + }, + redirect: 'follow', + signal: AbortSignal.timeout(LOGO_REQUEST_TIMEOUT_MS) + }) + + if (!response.ok) { + throw new Error(`Request failed: ${response.status}`) + } + + return response.text() +} + +async function fetchJson(url: string, headers?: Record) { + const response = await fetch(url, { + headers: { + 'User-Agent': SEARCH_USER_AGENT, + ...headers + }, + redirect: 'follow', + signal: AbortSignal.timeout(LOGO_REQUEST_TIMEOUT_MS) + }) + + if (!response.ok) { + throw new Error(`Request failed: ${response.status}`) + } + + return (await response.json()) as T +} + +function extractLinkMatches(html: string) { + const matches: Array<{ rel: string; href: string }> = [] + const regex = /]*rel=["']([^"']+)["'][^>]*href=["']([^"']+)["'][^>]*>/gi + for (const match of html.matchAll(regex)) { + matches.push({ + rel: (match[1] || '').toLowerCase(), + href: match[2] || '' + }) + } + return matches +} + +function extractMetaImageMatches(html: string) { + const matches: string[] = [] + const regex = /]*(?:property|name)=["'](?:og:image|twitter:image)["'][^>]*content=["']([^"']+)["'][^>]*>/gi + for (const match of html.matchAll(regex)) { + if (match[1]) matches.push(match[1]) + } + return matches +} + +async function fetchManifestCandidates(manifestUrl: string, websiteUrl: string) { + const candidates: RawCandidate[] = [] + + try { + const manifest = await fetchJson<{ icons?: Array<{ src?: string; sizes?: string; purpose?: string }> }>(manifestUrl) + for (const icon of manifest.icons ?? []) { + const iconUrl = icon.src ? new URL(icon.src, manifestUrl).toString() : '' + if (!iconUrl) continue + + const [width, height] = parseSizes(icon.sizes) + const sizeLabel = icon.sizes ? ` (${icon.sizes})` : '' + makeCandidate(candidates, `Manifest 图标${sizeLabel}`, iconUrl, icon.purpose ? `manifest:${icon.purpose}` : 'manifest', { + websiteUrl, + width, + height + }) + } + } catch { + return candidates + } + + return candidates +} + +function parseSizes(sizes?: string) { + if (!sizes) return [undefined, undefined] as const + const first = sizes.split(/\s+/).find((item) => /^\d+x\d+$/i.test(item)) + if (!first) return [undefined, undefined] as const + const [width, height] = first.toLowerCase().split('x').map((item) => Number(item)) + return [Number.isFinite(width) ? width : undefined, Number.isFinite(height) ? height : undefined] as const +} + +async function fetchWebsiteCandidates(origin: string) { + const candidates: RawCandidate[] = [] + + try { + const html = await fetchText(origin) + const links = extractLinkMatches(html) + + for (const link of links) { + const href = resolveUrl(origin, link.href) + if (!href) continue + + if (link.rel.includes('apple-touch-icon')) { + makeCandidate(candidates, 'Apple Touch Icon', href, 'apple-touch-icon', { websiteUrl: origin }) + } else if (link.rel.includes('mask-icon')) { + makeCandidate(candidates, 'Mask Icon', href, 'mask-icon', { websiteUrl: origin }) + } else if (link.rel.includes('icon')) { + makeCandidate(candidates, '站点图标', href, 'html-icon', { websiteUrl: origin }) + } else if (link.rel.includes('manifest')) { + const manifestCandidates = await fetchManifestCandidates(href, origin) + manifestCandidates.forEach((item) => makeCandidate(candidates, item.label, item.logoUrl, item.source, item)) + } + } + + for (const image of extractMetaImageMatches(html)) { + const imageUrl = resolveUrl(origin, image) + if (imageUrl) { + makeCandidate(candidates, '站点分享图', imageUrl, 'og-image', { websiteUrl: origin }) + } + } + } catch { + return candidates + } + + return candidates +} + +async function getDuckDuckGoVqd(searchTerm: string) { + try { + const html = await fetchText(`https://duckduckgo.com/?q=${encodeURIComponent(searchTerm)}&ia=images`) + const match = html.match(/vqd="?([\d-]+)"?/) + return match?.[1] ?? '' + } catch { + return '' + } +} + +async function fetchDuckDuckGoCandidates(searchTerm: string) { + const candidates: RawCandidate[] = [] + const vqd = await getDuckDuckGoVqd(searchTerm) + if (!vqd) return candidates + + try { + const params = new URLSearchParams({ + l: 'us-en', + o: 'json', + q: searchTerm, + vqd, + f: ',,transparent,Wide,', + p: '1' + }) + + const data = await fetchJson<{ + results?: Array<{ image?: string; thumbnail?: string; width?: number; height?: number }> + }>(`https://duckduckgo.com/i.js?${params.toString()}`, { + Accept: 'application/json', + Referer: 'https://duckduckgo.com/' + }) + + for (const [index, row] of (data.results ?? []).entries()) { + const imageUrl = row.image || row.thumbnail || '' + if (!imageUrl) continue + + makeCandidate(candidates, `DuckDuckGo 候选 ${index + 1}`, imageUrl, 'duckduckgo', { + width: row.width, + height: row.height + }) + if (candidates.length >= 30) break + } + } catch { + return candidates + } + + return candidates +} + +async function fetchBraveCandidates(searchTerm: string) { + const candidates: RawCandidate[] = [] + + try { + const html = await fetchText(`https://search.brave.com/images?q=${encodeURIComponent(searchTerm)}`, { + Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + Referer: 'https://search.brave.com/' + }) + + const imageRegex = /]*(?:src|data-src)=["']([^"']+)["'][^>]*>/gi + for (const [index, match] of Array.from(html.matchAll(imageRegex)).entries()) { + const imageUrl = match[1] || '' + if (!/^https?:\/\//i.test(imageUrl)) continue + if (imageUrl.includes('cdn.search.brave.com')) continue + if (/favicon|logo\.svg/i.test(imageUrl)) continue + + makeCandidate(candidates, `Brave 候选 ${index + 1}`, imageUrl, 'brave') + if (candidates.length >= 24) break + } + } catch { + return candidates + } + + return candidates +} + +function inferContentTypeFromUrl(url: string) { + try { + const pathname = new URL(url).pathname.toLowerCase() + if (pathname.endsWith('.png')) return 'image/png' + if (pathname.endsWith('.jpg') || pathname.endsWith('.jpeg')) return 'image/jpeg' + if (pathname.endsWith('.webp')) return 'image/webp' + if (pathname.endsWith('.svg')) return 'image/svg+xml' + } catch { + return '' + } + return '' +} + +function getImageDimensions(buffer: Buffer, contentType: string) { + if (contentType === 'image/png') { + if (buffer.length >= 24 && buffer.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) { + return { + width: buffer.readUInt32BE(16), + height: buffer.readUInt32BE(20) + } + } + } + + if (contentType === 'image/jpeg' || contentType === 'image/jpg') { + let offset = 2 + while (offset < buffer.length) { + if (buffer[offset] !== 0xff) break + const marker = buffer[offset + 1] + const length = buffer.readUInt16BE(offset + 2) + if (marker >= 0xc0 && marker <= 0xc3) { + return { + height: buffer.readUInt16BE(offset + 5), + width: buffer.readUInt16BE(offset + 7) + } + } + offset += 2 + length + } + } + + if (contentType === 'image/webp' && buffer.length >= 30) { + const chunk = buffer.toString('ascii', 12, 16) + if (chunk === 'VP8X') { + return { + width: 1 + buffer.readUIntLE(24, 3), + height: 1 + buffer.readUIntLE(27, 3) + } + } + } + + if (contentType === 'image/svg+xml') { + const text = buffer.toString('utf8', 0, Math.min(buffer.length, 4096)) + const widthMatch = text.match(/\bwidth=["']?([\d.]+)(?:px)?["']?/i) + const heightMatch = text.match(/\bheight=["']?([\d.]+)(?:px)?["']?/i) + if (widthMatch && heightMatch) { + return { + width: Number(widthMatch[1]), + height: Number(heightMatch[1]) + } + } + const viewBoxMatch = text.match(/\bviewBox=["'][^"']*?(\d+(?:\.\d+)?)\s+(\d+(?:\.\d+)?)["']/i) + if (viewBoxMatch) { + return { + width: Number(viewBoxMatch[1]), + height: Number(viewBoxMatch[2]) + } + } + } + + return {} +} + +async function inspectRemoteImage(url: string): Promise { + try { + const response = await fetch(url, { + headers: { + 'User-Agent': SEARCH_USER_AGENT, + Accept: 'image/avif,image/webp,image/png,image/svg+xml,image/*;q=0.9,*/*;q=0.8' + }, + redirect: 'follow', + signal: AbortSignal.timeout(LOGO_REQUEST_TIMEOUT_MS) + }) + + if (!response.ok) return null + + const headerType = (response.headers.get('content-type') || '').split(';')[0].trim().toLowerCase() + const contentType = headerType || inferContentTypeFromUrl(response.url || url) + if (!allowedTypes.has(contentType)) return null + + const arrayBuffer = await response.arrayBuffer() + const buffer = Buffer.from(arrayBuffer) + if (!buffer.length) return null + + const { width, height } = getImageDimensions(buffer, contentType) + + return { + finalUrl: response.url || url, + contentType, + width, + height, + buffer + } + } catch { + return null + } +} + +function passesQualityFilter(meta: ImageMeta, source: string) { + const { width, height } = meta + + if (!width || !height) { + return sourcePriority(source) >= 80 + } + + if (width < 48 || height < 48) return false + + const ratio = width / height + if (ratio > 4 || ratio < 0.25) return false + + const area = width * height + if (area < 2304) return false + + return true +} + +function computeScore(candidate: RawCandidate, meta: ImageMeta) { + let score = candidate.scoreHint ?? sourcePriority(candidate.source) + const width = meta.width ?? candidate.width ?? 0 + const height = meta.height ?? candidate.height ?? 0 + + if (width && height) { + score += Math.min(Math.min(width, height) / 8, 24) + const ratio = width / height + if (ratio >= 0.75 && ratio <= 1.5) score += 12 + else if (ratio >= 0.45 && ratio <= 2.2) score += 6 + else score -= 18 + } else { + score -= 8 + } + + if (candidate.fallback) score -= 15 + return score +} + +async function mapWithConcurrency(items: T[], concurrency: number, worker: (item: T) => Promise) { + const results: R[] = new Array(items.length) + let index = 0 + + const runners = Array.from({ length: Math.min(concurrency, items.length) }, async () => { + while (index < items.length) { + const current = index++ + results[current] = await worker(items[current]) + } + }) + + await Promise.all(runners) + return results +} + +function dedupeBy(items: T[], getKey: (item: T) => string) { + const seen = new Set() + const result: T[] = [] + for (const item of items) { + const key = getKey(item) + if (!key || seen.has(key)) continue + seen.add(key) + result.push(item) + } + return result +} + +function filenameFromLogoUrl(logoUrl: string) { + return path.basename(new URL(logoUrl, 'http://localhost').pathname) +} + +export async function searchSubscriptionLogos(params: { name: string; websiteUrl?: string; categoryName?: string }) { + const cacheKey = JSON.stringify({ + name: params.name.trim().toLowerCase(), + websiteUrl: params.websiteUrl?.trim().toLowerCase() ?? '', + categoryName: params.categoryName?.trim().toLowerCase() ?? '' + }) + const cached = searchCache.get(cacheKey) + if (cached && cached.expiresAt > Date.now()) { + return cached.items + } + + const explicitOrigin = normalizeWebsiteUrl(params.websiteUrl) + const fallbackSeed = deriveDomainSeed(params.name) + const guessedOrigin = explicitOrigin || (fallbackSeed ? `https://${fallbackSeed}.com` : '') + const searchTerm = [params.name, params.categoryName].filter(Boolean).join(' ').trim() || params.name + + const rawCandidates: RawCandidate[] = [] + const [websiteCandidates, duckCandidates, braveCandidates] = await Promise.all([ + guessedOrigin ? fetchWebsiteCandidates(guessedOrigin) : Promise.resolve([]), + fetchDuckDuckGoCandidates(`${searchTerm} logo`), + fetchBraveCandidates(`${searchTerm} logo`) + ]) + + if (guessedOrigin) { + const hostname = new URL(guessedOrigin).hostname + websiteCandidates.forEach((item) => makeCandidate(rawCandidates, item.label, item.logoUrl, item.source, item)) + + makeCandidate(rawCandidates, '站点 favicon', `${guessedOrigin}/favicon.ico`, 'favicon', { + websiteUrl: guessedOrigin, + fallback: true + }) + makeCandidate( + rawCandidates, + 'Google Favicon', + `https://www.google.com/s2/favicons?sz=256&domain_url=${encodeURIComponent(guessedOrigin)}`, + 'google-favicon', + { + websiteUrl: guessedOrigin, + fallback: true, + width: 256, + height: 256 + } + ) + makeCandidate(rawCandidates, 'Icon Horse', `https://icon.horse/icon/${hostname}`, 'icon-horse', { + websiteUrl: guessedOrigin, + fallback: true + }) + makeCandidate(rawCandidates, 'Clearbit Logo', `https://logo.clearbit.com/${hostname}`, 'clearbit', { + websiteUrl: guessedOrigin, + fallback: true + }) + } + + duckCandidates.forEach((item) => makeCandidate(rawCandidates, item.label, item.logoUrl, item.source, item)) + braveCandidates.forEach((item) => makeCandidate(rawCandidates, item.label, item.logoUrl, item.source, item)) + + const prepared = rawCandidates + .sort((a, b) => (b.scoreHint ?? 0) - (a.scoreHint ?? 0)) + .slice(0, 40) + + const inspected = await mapWithConcurrency(prepared, 4, async (candidate) => { + const meta = await inspectRemoteImage(candidate.logoUrl) + if (!meta) return null + if (!passesQualityFilter(meta, candidate.source)) return null + + return { + ...candidate, + logoUrl: meta.finalUrl, + width: meta.width ?? candidate.width, + height: meta.height ?? candidate.height, + score: computeScore(candidate, meta) + } + }) + + const result = dedupeBy( + inspected + .filter((item): item is NonNullable => Boolean(item)) + .sort((a, b) => b.score - a.score) + .slice(0, 24), + (item) => item.logoUrl + ).map(({ score, scoreHint, fallback, ...item }) => item) + + searchCache.set(cacheKey, { + expiresAt: Date.now() + SEARCH_CACHE_TTL_MS, + items: result + }) + + return result +} + +async function writeLogoBuffer(buffer: Buffer, contentType: string, logoSource: string) { + await mkdir(logoDir, { recursive: true }) + + const filename = `${Date.now()}-${crypto.randomBytes(6).toString('hex')}${extensionMap[contentType] ?? '.png'}` + const absolutePath = path.join(logoDir, filename) + await writeFile(absolutePath, buffer) + + return { + logoUrl: `/static/logos/${filename}`, + logoSource + } +} + +function isLocalLogoUrl(url?: string | null) { + return Boolean(url && url.startsWith('/static/logos/')) +} + +export async function saveUploadedLogo(input: { filename: string; contentType: string; base64: string }) { + if (!allowedTypes.has(input.contentType)) { + throw new Error('仅支持 PNG、JPG、WEBP、SVG 图片') + } + + const buffer = Buffer.from(input.base64, 'base64') + if (!buffer.length) { + throw new Error('图片内容为空') + } + + return writeLogoBuffer(buffer, input.contentType, 'upload') +} + +export async function importRemoteLogo(input: { logoUrl: string; source?: string }) { + const meta = await inspectRemoteImage(input.logoUrl) + if (!meta) { + throw new Error('远程 Logo 下载失败或图片不可用') + } + + return writeLogoBuffer(meta.buffer, meta.contentType, input.source || 'remote') +} + +export async function normalizeLogoForStorage(input: { logoUrl?: string | null; logoSource?: string | null }) { + if (!input.logoUrl) { + return { + logoUrl: null, + logoSource: input.logoSource ?? null, + logoFetchedAt: null as Date | null + } + } + + if (isLocalLogoUrl(input.logoUrl)) { + return { + logoUrl: input.logoUrl, + logoSource: input.logoSource ?? 'upload', + logoFetchedAt: new Date() + } + } + + if (/^https?:\/\//i.test(input.logoUrl)) { + const imported = await importRemoteLogo({ + logoUrl: input.logoUrl, + source: input.logoSource ?? 'remote' + }) + + return { + logoUrl: imported.logoUrl, + logoSource: imported.logoSource, + logoFetchedAt: new Date() + } + } + + return { + logoUrl: input.logoUrl, + logoSource: input.logoSource ?? null, + logoFetchedAt: new Date() + } +} + +export async function getLocalLogoLibrary() { + await mkdir(logoDir, { recursive: true }) + + const localSubscriptions = await prisma.subscription.findMany({ + where: { + logoUrl: { + startsWith: '/static/logos/' + } + }, + select: { + name: true, + logoUrl: true, + logoSource: true, + updatedAt: true + } + }) + + const map = new Map() + + for (const row of localSubscriptions) { + if (!row.logoUrl) continue + const existing = map.get(row.logoUrl) + const filename = filenameFromLogoUrl(row.logoUrl) + if (!existing) { + map.set(row.logoUrl, { + label: row.name, + logoUrl: row.logoUrl, + source: row.logoSource ?? 'local', + isLocal: true, + filename, + updatedAt: row.updatedAt.toISOString(), + usageCount: 1, + relatedSubscriptionNames: [row.name] + }) + continue + } + + existing.usageCount = (existing.usageCount ?? 0) + 1 + existing.relatedSubscriptionNames = Array.from(new Set([...(existing.relatedSubscriptionNames ?? []), row.name])).slice(0, 3) + if (!existing.updatedAt || existing.updatedAt < row.updatedAt.toISOString()) { + existing.updatedAt = row.updatedAt.toISOString() + existing.label = row.name + } + } + + const files = await readdir(logoDir) + for (const file of files) { + const logoUrl = `/static/logos/${file}` + if (map.has(logoUrl)) continue + + const filePath = path.join(logoDir, file) + const info = await stat(filePath) + map.set(logoUrl, { + label: '未使用 Logo', + logoUrl, + source: 'local-file', + isLocal: true, + filename: file, + updatedAt: info.mtime.toISOString(), + usageCount: 0, + relatedSubscriptionNames: [] + }) + } + + return Array.from(map.values()).sort((a, b) => { + const updatedDiff = new Date(b.updatedAt ?? 0).valueOf() - new Date(a.updatedAt ?? 0).valueOf() + if (updatedDiff !== 0) return updatedDiff + return (b.usageCount ?? 0) - (a.usageCount ?? 0) + }) +} + +export async function deleteLocalLogoFromLibrary(filename: string) { + const safeName = path.basename(filename) + if (!safeName) { + throw new Error('无效的 Logo 文件名') + } + + const logoUrl = `/static/logos/${safeName}` + const usageCount = await prisma.subscription.count({ + where: { + logoUrl + } + }) + + if (usageCount > 0) { + throw new Error('该 Logo 已被订阅使用,不能删除') + } + + const filePath = path.join(logoDir, safeName) + await unlink(filePath) + + return { + filename: safeName, + logoUrl, + deleted: true + } +} diff --git a/apps/api/src/services/notification.service.ts b/apps/api/src/services/notification.service.ts new file mode 100644 index 0000000..9a0b728 --- /dev/null +++ b/apps/api/src/services/notification.service.ts @@ -0,0 +1,83 @@ +import dayjs from 'dayjs' +import { prisma } from '../db' +import { isOverdue, isReminderDue, toIsoDate } from '../utils/date' +import { getLatestSnapshot } from './exchange-rate.service' +import { dispatchNotificationEvent } from './channel-notification.service' + +export async function scanRenewalNotifications() { + const subscriptions = await prisma.subscription.findMany({ + where: { + status: { in: ['active', 'expired'] }, + webhookEnabled: true + }, + include: { + category: true + } + }) + + const today = dayjs().startOf('day').toDate() + + for (const sub of subscriptions) { + const periodKey = toIsoDate(sub.nextRenewalDate) + const resourceKey = `subscription:${sub.id}` + + if (isOverdue(today, sub.nextRenewalDate)) { + await dispatchNotificationEvent({ + eventType: 'subscription.overdue', + resourceKey, + periodKey, + subscriptionId: sub.id, + payload: { + id: sub.id, + name: sub.name, + nextRenewalDate: sub.nextRenewalDate.toISOString(), + amount: sub.amount, + currency: sub.currency, + status: sub.status + } + }) + + await prisma.subscription.update({ + where: { id: sub.id }, + data: { status: 'expired' } + }) + + continue + } + + if (isReminderDue(today, sub.nextRenewalDate, sub.notifyDaysBefore)) { + await dispatchNotificationEvent({ + eventType: 'subscription.reminder_due', + resourceKey, + periodKey, + subscriptionId: sub.id, + payload: { + id: sub.id, + name: sub.name, + nextRenewalDate: sub.nextRenewalDate.toISOString(), + notifyDaysBefore: sub.notifyDaysBefore, + amount: sub.amount, + currency: sub.currency, + category: sub.category?.name ?? null + } + }) + } + } +} + +export async function notifyIfExchangeRateStale() { + const snapshot = await getLatestSnapshot() + if (!snapshot.isStale) return + + await dispatchNotificationEvent({ + eventType: 'exchange-rate.stale', + resourceKey: `exchange-rate:${snapshot.baseCurrency}`, + periodKey: dayjs().format('YYYY-MM-DD'), + payload: { + baseCurrency: snapshot.baseCurrency, + fetchedAt: snapshot.fetchedAt, + provider: snapshot.provider, + isStale: snapshot.isStale + } + }) +} diff --git a/apps/api/src/services/scheduler.service.ts b/apps/api/src/services/scheduler.service.ts new file mode 100644 index 0000000..4c7008e --- /dev/null +++ b/apps/api/src/services/scheduler.service.ts @@ -0,0 +1,25 @@ +import cron from 'node-cron' +import { config } from '../config' +import { refreshExchangeRates } from './exchange-rate.service' +import { notifyIfExchangeRateStale, scanRenewalNotifications } from './notification.service' + +export function startSchedulers() { + cron.schedule(config.cronRefreshRates, async () => { + try { + await refreshExchangeRates() + await notifyIfExchangeRateStale() + console.log('[cron] exchange rates refreshed') + } catch (e) { + console.error('[cron] exchange rate refresh failed', e) + } + }) + + cron.schedule(config.cronScan, async () => { + try { + await scanRenewalNotifications() + console.log('[cron] subscription reminders scanned') + } catch (e) { + console.error('[cron] reminder scan failed', e) + } + }) +} diff --git a/apps/api/src/services/settings.service.ts b/apps/api/src/services/settings.service.ts new file mode 100644 index 0000000..0d4fdae --- /dev/null +++ b/apps/api/src/services/settings.service.ts @@ -0,0 +1,64 @@ +import type { SettingsInput } from '@subtracker/shared' +import { prisma } from '../db' +import { config } from '../config' + +export async function getSetting(key: string, fallback: T): Promise { + const row = await prisma.setting.findUnique({ where: { key } }) + if (!row) return fallback + return row.valueJson as T +} + +export async function setSetting(key: string, value: T): Promise { + await prisma.setting.upsert({ + where: { key }, + update: { valueJson: value as object }, + create: { key, valueJson: value as object } + }) +} + +export async function getAppSettings(): Promise { + const baseCurrency = await getSetting('baseCurrency', config.baseCurrency) + const defaultNotifyDays = await getSetting('defaultNotifyDays', config.defaultNotifyDays) + const monthlyBudgetBase = await getSetting('monthlyBudgetBase', null) + const yearlyBudgetBase = await getSetting('yearlyBudgetBase', null) + const enableCategoryBudgets = await getSetting('enableCategoryBudgets', false) + const categoryBudgets = await getSetting>('categoryBudgets', {}) + const emailNotificationsEnabled = await getSetting('emailNotificationsEnabled', false) + const pushplusNotificationsEnabled = await getSetting('pushplusNotificationsEnabled', false) + const emailConfig = await getSetting('emailConfig', { + host: '', + port: 587, + secure: false, + username: '', + password: '', + from: '', + to: '' + }) + const pushplusConfig = await getSetting('pushplusConfig', { + token: '', + topic: '' + }) + const aiConfig = await getSetting('aiConfig', { + enabled: false, + providerName: 'DeepSeek', + baseUrl: 'https://api.deepseek.com', + apiKey: '', + model: 'deepseek-chat', + timeoutMs: 30000, + promptTemplate: '' + }) + + return { + baseCurrency, + defaultNotifyDays, + monthlyBudgetBase, + yearlyBudgetBase, + enableCategoryBudgets, + categoryBudgets, + emailNotificationsEnabled, + pushplusNotificationsEnabled, + emailConfig, + pushplusConfig, + aiConfig + } +} diff --git a/apps/api/src/services/statistics.service.ts b/apps/api/src/services/statistics.service.ts new file mode 100644 index 0000000..2392f68 --- /dev/null +++ b/apps/api/src/services/statistics.service.ts @@ -0,0 +1,158 @@ +import dayjs from 'dayjs' +import { prisma } from '../db' +import { ensureExchangeRates, getBaseCurrency } from './exchange-rate.service' +import { convertAmount } from '../utils/money' +import { monthKey } from '../utils/date' +import { getAppSettings } from './settings.service' + +function monthlyFactor(unit: string, count: number) { + switch (unit) { + case 'day': + return 30 / count + case 'week': + return 4.345 / count + case 'month': + return 1 / count + case 'quarter': + return 1 / (count * 3) + case 'year': + return 1 / (count * 12) + default: + return 1 + } +} + +export async function getOverviewStatistics() { + const [subscriptions, paymentRecords, appSettings] = await Promise.all([ + prisma.subscription.findMany({ + include: { category: true }, + where: { status: { in: ['active', 'paused', 'expired'] } } + }), + prisma.paymentRecord.findMany({ + orderBy: { paidAt: 'asc' } + }), + getAppSettings() + ]) + + const today = dayjs() + const next7 = today.add(7, 'day') + const next30 = today.add(30, 'day') + + const rates = await ensureExchangeRates() + const baseCurrency = await getBaseCurrency() + + let monthlyEstimatedBase = 0 + let yearlyEstimatedBase = 0 + + const categoryMap = new Map() + const categoryBudgetMap = new Map() + + for (const subscription of subscriptions.filter((item) => item.status === 'active')) { + const baseAmount = convertAmount( + subscription.amount, + subscription.currency, + baseCurrency, + rates.baseCurrency, + rates.rates + ) + const monthly = baseAmount * monthlyFactor(subscription.billingIntervalUnit, subscription.billingIntervalCount) + monthlyEstimatedBase += monthly + yearlyEstimatedBase += monthly * 12 + + const categoryName = subscription.category?.name ?? '未分类' + categoryMap.set(categoryName, (categoryMap.get(categoryName) ?? 0) + monthly) + + if (subscription.categoryId && subscription.category) { + const current = categoryBudgetMap.get(subscription.categoryId) ?? { + name: subscription.category.name, + spent: 0 + } + current.spent += monthly + categoryBudgetMap.set(subscription.categoryId, current) + } + } + + const monthlyTrendMap = new Map() + for (const payment of paymentRecords) { + const key = monthKey(payment.paidAt) + monthlyTrendMap.set(key, (monthlyTrendMap.get(key) ?? 0) + payment.convertedAmount) + } + + const currencyDistributionMap = new Map() + for (const subscription of subscriptions.filter((item) => item.status === 'active')) { + currencyDistributionMap.set( + subscription.currency, + (currencyDistributionMap.get(subscription.currency) ?? 0) + subscription.amount + ) + } + + const upcoming = subscriptions.filter( + (subscription) => + dayjs(subscription.nextRenewalDate).isAfter(today) && dayjs(subscription.nextRenewalDate).isBefore(next30) + ) + + return { + activeSubscriptions: subscriptions.filter((item) => item.status === 'active').length, + upcoming7Days: subscriptions.filter( + (item) => dayjs(item.nextRenewalDate).isAfter(today) && dayjs(item.nextRenewalDate).isBefore(next7) + ).length, + upcoming30Days: upcoming.length, + monthlyEstimatedBase: Number(monthlyEstimatedBase.toFixed(2)), + yearlyEstimatedBase: Number(yearlyEstimatedBase.toFixed(2)), + monthlyBudgetBase: appSettings.monthlyBudgetBase, + yearlyBudgetBase: appSettings.yearlyBudgetBase, + monthlyBudgetUsageRatio: + appSettings.monthlyBudgetBase && appSettings.monthlyBudgetBase > 0 + ? Number((monthlyEstimatedBase / appSettings.monthlyBudgetBase).toFixed(4)) + : null, + yearlyBudgetUsageRatio: + appSettings.yearlyBudgetBase && appSettings.yearlyBudgetBase > 0 + ? Number((yearlyEstimatedBase / appSettings.yearlyBudgetBase).toFixed(4)) + : null, + categorySpend: Array.from(categoryMap.entries()).map(([name, value]) => ({ + name, + value: Number(value.toFixed(2)) + })), + monthlyTrend: Array.from(monthlyTrendMap.entries()).map(([month, amount]) => ({ + month, + amount: Number(amount.toFixed(2)) + })), + currencyDistribution: Array.from(currencyDistributionMap.entries()).map(([currency, amount]) => ({ + currency, + amount: Number(amount.toFixed(2)) + })), + categoryBudgetUsage: appSettings.enableCategoryBudgets + ? Array.from(categoryBudgetMap.entries()).flatMap(([categoryId, item]) => { + const budget = appSettings.categoryBudgets[categoryId] + if (budget === undefined) return [] + + return [ + { + categoryId, + name: item.name, + budget: Number(budget.toFixed(2)), + spent: Number(item.spent.toFixed(2)), + ratio: budget > 0 ? Number((item.spent / budget).toFixed(4)) : 0 + } + ] + }) + : [], + upcomingRenewals: upcoming + .sort((a, b) => a.nextRenewalDate.getTime() - b.nextRenewalDate.getTime()) + .map((item) => ({ + id: item.id, + name: item.name, + nextRenewalDate: item.nextRenewalDate.toISOString(), + amount: item.amount, + currency: item.currency, + convertedAmount: convertAmount( + item.amount, + item.currency, + baseCurrency, + rates.baseCurrency, + rates.rates + ), + status: item.status + })) + } +} diff --git a/apps/api/src/services/subscription-order.service.ts b/apps/api/src/services/subscription-order.service.ts new file mode 100644 index 0000000..b90905a --- /dev/null +++ b/apps/api/src/services/subscription-order.service.ts @@ -0,0 +1,43 @@ +import { getSetting, setSetting } from './settings.service' + +const SUBSCRIPTION_ORDER_KEY = 'subscriptionOrder' + +function uniqueIds(ids: string[]) { + return Array.from(new Set(ids.filter(Boolean))) +} + +export async function getSubscriptionOrder() { + return uniqueIds(await getSetting(SUBSCRIPTION_ORDER_KEY, [])) +} + +export async function setSubscriptionOrder(ids: string[]) { + await setSetting(SUBSCRIPTION_ORDER_KEY, uniqueIds(ids)) +} + +export async function appendSubscriptionOrder(id: string) { + const current = await getSubscriptionOrder() + if (!current.includes(id)) { + current.push(id) + await setSubscriptionOrder(current) + } +} + +export async function removeSubscriptionOrder(id: string) { + const current = await getSubscriptionOrder() + await setSubscriptionOrder(current.filter((item) => item !== id)) +} + +export async function sortSubscriptionsByOrder(rows: T[]) { + const order = await getSubscriptionOrder() + const orderIndex = new Map(order.map((id, index) => [id, index])) + + return [...rows].sort((a, b) => { + const aIndex = orderIndex.get(a.id) + const bIndex = orderIndex.get(b.id) + + if (aIndex === undefined && bIndex === undefined) return 0 + if (aIndex === undefined) return 1 + if (bIndex === undefined) return -1 + return aIndex - bIndex + }) +} diff --git a/apps/api/src/services/subscription.service.ts b/apps/api/src/services/subscription.service.ts new file mode 100644 index 0000000..58e7eab --- /dev/null +++ b/apps/api/src/services/subscription.service.ts @@ -0,0 +1,54 @@ +import { prisma } from '../db' +import { addInterval } from '../utils/date' +import { ensureExchangeRates, getBaseCurrency } from './exchange-rate.service' +import { convertAmount } from '../utils/money' + +export async function renewSubscription(subscriptionId: string, paidAt?: Date, paidAmount?: number, paidCurrency?: string) { + const subscription = await prisma.subscription.findUnique({ where: { id: subscriptionId } }) + if (!subscription) { + throw new Error('Subscription not found') + } + + const amount = paidAmount ?? subscription.amount + const currency = (paidCurrency ?? subscription.currency).toUpperCase() + const baseCurrency = await getBaseCurrency() + const rates = await ensureExchangeRates(baseCurrency) + const convertedAmount = convertAmount(amount, currency, baseCurrency, rates.baseCurrency, rates.rates) + const exchangeRate = Number((convertedAmount / amount).toFixed(8)) + + const periodStart = subscription.nextRenewalDate + const periodEnd = addInterval( + subscription.nextRenewalDate, + subscription.billingIntervalCount, + subscription.billingIntervalUnit + ) + + return prisma.$transaction(async (tx) => { + const payment = await tx.paymentRecord.create({ + data: { + subscriptionId: subscription.id, + amount, + currency, + baseCurrency, + convertedAmount, + exchangeRate, + paidAt: paidAt ?? new Date(), + periodStart, + periodEnd + } + }) + + const updated = await tx.subscription.update({ + where: { id: subscription.id }, + data: { + nextRenewalDate: periodEnd, + status: 'active' + } + }) + + return { + payment, + subscription: updated + } + }) +} diff --git a/apps/api/src/services/webhook.service.ts b/apps/api/src/services/webhook.service.ts new file mode 100644 index 0000000..baf544e --- /dev/null +++ b/apps/api/src/services/webhook.service.ts @@ -0,0 +1,207 @@ +import { Prisma } from '@prisma/client' +import { prisma } from '../db' +import { signWebhookPayload } from '../utils/webhook' +import type { WebhookEventType } from '@subtracker/shared' + +type DeliveryPayload = Record +export type PrimaryWebhookInput = { + url: string + secret: string + enabled: boolean +} + +const ALL_WEBHOOK_EVENTS: WebhookEventType[] = [ + 'subscription.reminder_due', + 'subscription.overdue', + 'subscription.renewed', + 'exchange-rate.stale' +] + +const PRIMARY_WEBHOOK_NAME = 'Default Webhook' + +export async function listWebhookEndpoints() { + return prisma.webhookEndpoint.findMany({ orderBy: { createdAt: 'desc' } }) +} + +export async function getPrimaryWebhookEndpoint() { + return prisma.webhookEndpoint.findFirst({ + orderBy: { createdAt: 'asc' } + }) +} + +export async function listWebhookDeliveries(limit = 100) { + return prisma.webhookDelivery.findMany({ + take: limit, + orderBy: { createdAt: 'desc' }, + include: { + endpoint: { + select: { id: true, name: true, url: true } + } + } + }) +} + +async function postWebhook( + endpoint: { url: string; secret: string }, + payload: { + eventType: WebhookEventType | 'test' + payload: DeliveryPayload + } +) { + const body = JSON.stringify({ + eventType: payload.eventType, + timestamp: new Date().toISOString(), + payload: payload.payload + }) + + const signature = signWebhookPayload(endpoint.secret, body) + + const response = await fetch(endpoint.url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Subtracker-Signature': signature + }, + body + }) + + return { + response, + responseBody: await response.text() + } +} + +export async function upsertPrimaryWebhookEndpoint(input: PrimaryWebhookInput) { + const current = await getPrimaryWebhookEndpoint() + const data = { + name: PRIMARY_WEBHOOK_NAME, + url: input.url.trim(), + secret: input.secret.trim(), + enabled: input.enabled, + eventsJson: ALL_WEBHOOK_EVENTS + } + + if (current) { + return prisma.webhookEndpoint.update({ + where: { id: current.id }, + data + }) + } + + return prisma.webhookEndpoint.create({ + data + }) +} + +export async function sendTestWebhookNotification() { + const endpoint = await getPrimaryWebhookEndpoint() + if (!endpoint || !endpoint.enabled || !endpoint.url || !endpoint.secret) { + throw new Error('Webhook 未启用或配置不完整') + } + + return sendTestWebhookNotificationWithConfig({ + url: endpoint.url, + secret: endpoint.secret, + enabled: endpoint.enabled + }) +} + +export async function sendTestWebhookNotificationWithConfig(input: PrimaryWebhookInput) { + if (!input.enabled || !input.url || !input.secret) { + throw new Error('Webhook 未启用或配置不完整') + } + + const { response, responseBody } = await postWebhook( + { + url: input.url.trim(), + secret: input.secret.trim() + }, + { + eventType: 'test', + payload: { + source: 'SubTracker', + message: '这是一条测试通知', + sentAt: new Date().toISOString() + } + } + ) + + if (!response.ok) { + throw new Error(`Webhook 测试失败:HTTP ${response.status} ${responseBody || ''}`.trim()) + } + + return true +} + +export async function dispatchWebhookEvent(params: { + eventType: WebhookEventType + resourceKey: string + periodKey: string + payload: DeliveryPayload + subscriptionId?: string +}) { + const endpoints = await prisma.webhookEndpoint.findMany({ where: { enabled: true } }) + + for (const endpoint of endpoints) { + const events = endpoint.eventsJson as string[] + if (!events.includes(params.eventType)) continue + + const existing = await prisma.webhookDelivery.findUnique({ + where: { + endpointId_eventType_resourceKey_periodKey: { + endpointId: endpoint.id, + eventType: params.eventType, + resourceKey: params.resourceKey, + periodKey: params.periodKey + } + } + }) + + if (existing?.status === 'success') { + continue + } + + const target = + existing ?? + (await prisma.webhookDelivery.create({ + data: { + endpointId: endpoint.id, + eventType: params.eventType, + resourceKey: params.resourceKey, + periodKey: params.periodKey, + subscriptionId: params.subscriptionId, + payloadJson: params.payload as Prisma.InputJsonValue, + status: 'pending' + } + })) + + try { + const { response, responseBody } = await postWebhook(endpoint, { + eventType: params.eventType, + payload: params.payload + }) + + await prisma.webhookDelivery.update({ + where: { id: target.id }, + data: { + status: response.ok ? 'success' : 'failed', + responseCode: response.status, + responseBody, + attemptCount: { increment: 1 }, + lastAttemptAt: new Date() + } + }) + } catch (error) { + await prisma.webhookDelivery.update({ + where: { id: target.id }, + data: { + status: 'failed', + responseCode: 0, + responseBody: error instanceof Error ? error.message : 'Unknown error', + attemptCount: { increment: 1 }, + lastAttemptAt: new Date() + } + }) + } + } +} diff --git a/apps/api/src/types.ts b/apps/api/src/types.ts new file mode 100644 index 0000000..0dbd542 --- /dev/null +++ b/apps/api/src/types.ts @@ -0,0 +1,12 @@ +export type ApiSuccess = { + data: T + meta?: Record +} + +export type ApiError = { + error: { + code: string + message: string + details?: unknown + } +} diff --git a/apps/api/src/utils/date.ts b/apps/api/src/utils/date.ts new file mode 100644 index 0000000..d66d3d8 --- /dev/null +++ b/apps/api/src/utils/date.ts @@ -0,0 +1,41 @@ +import dayjs from 'dayjs' +import type { BillingIntervalUnit } from '@subtracker/shared' + +export function toIsoDate(date: Date | string): string { + return dayjs(date).format('YYYY-MM-DD') +} + +export function toDate(date: Date | string): Date { + return dayjs(date).startOf('day').toDate() +} + +export function addInterval(date: Date | string, count: number, unit: BillingIntervalUnit): Date { + const d = dayjs(date) + switch (unit) { + case 'day': + return d.add(count, 'day').toDate() + case 'week': + return d.add(count, 'week').toDate() + case 'month': + return d.add(count, 'month').toDate() + case 'quarter': + return d.add(count * 3, 'month').toDate() + case 'year': + return d.add(count, 'year').toDate() + default: + return d.toDate() + } +} + +export function isReminderDue(today: Date, nextRenewalDate: Date, notifyDaysBefore: number): boolean { + const reminderDate = dayjs(nextRenewalDate).subtract(notifyDaysBefore, 'day').startOf('day') + return dayjs(today).isSame(reminderDate) || dayjs(today).isAfter(reminderDate) +} + +export function isOverdue(today: Date, nextRenewalDate: Date): boolean { + return dayjs(today).isAfter(dayjs(nextRenewalDate).endOf('day')) +} + +export function monthKey(date: Date | string): string { + return dayjs(date).format('YYYY-MM') +} diff --git a/apps/api/src/utils/money.ts b/apps/api/src/utils/money.ts new file mode 100644 index 0000000..009a8d5 --- /dev/null +++ b/apps/api/src/utils/money.ts @@ -0,0 +1,33 @@ +export function roundMoney(value: number): number { + return Number(value.toFixed(2)) +} + +export function convertAmount( + amount: number, + fromCurrency: string, + toCurrency: string, + baseCurrency: string, + rates: Record +): number { + const from = fromCurrency.toUpperCase() + const to = toCurrency.toUpperCase() + const base = baseCurrency.toUpperCase() + + if (from === to) return roundMoney(amount) + + const normalizedRates: Record = { + ...rates, + [base]: 1 + } + + const fromRate = normalizedRates[from] + const toRate = normalizedRates[to] + + if (!fromRate || !toRate) { + throw new Error(`Unsupported currency conversion: ${from} -> ${to}`) + } + + const inBase = amount / fromRate + const converted = inBase * toRate + return roundMoney(converted) +} diff --git a/apps/api/src/utils/webhook.ts b/apps/api/src/utils/webhook.ts new file mode 100644 index 0000000..e4359bc --- /dev/null +++ b/apps/api/src/utils/webhook.ts @@ -0,0 +1,5 @@ +import { createHmac } from 'node:crypto' + +export function signWebhookPayload(secret: string, payload: string): string { + return createHmac('sha256', secret).update(payload).digest('hex') +} diff --git a/apps/api/tests/integration/health.test.ts b/apps/api/tests/integration/health.test.ts new file mode 100644 index 0000000..6f886fd --- /dev/null +++ b/apps/api/tests/integration/health.test.ts @@ -0,0 +1,21 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { buildApp } from '../../src/app' + +describe('api health', () => { + let app: Awaited> + + beforeAll(async () => { + app = await buildApp() + }) + + afterAll(async () => { + await app.close() + }) + + it('should return health status', async () => { + const res = await app.inject({ method: 'GET', url: '/health' }) + expect(res.statusCode).toBe(200) + const payload = res.json() + expect(payload.ok).toBe(true) + }) +}) diff --git a/apps/api/tests/unit/date.test.ts b/apps/api/tests/unit/date.test.ts new file mode 100644 index 0000000..25551c7 --- /dev/null +++ b/apps/api/tests/unit/date.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest' +import { addInterval, isOverdue, isReminderDue, toIsoDate } from '../../src/utils/date' + +describe('date utils', () => { + it('should add month interval correctly', () => { + const next = addInterval('2026-04-10', 1, 'month') + expect(toIsoDate(next)).toBe('2026-05-10') + }) + + it('should detect reminder due', () => { + const today = new Date('2026-04-07') + const renewal = new Date('2026-04-10') + expect(isReminderDue(today, renewal, 3)).toBe(true) + }) + + it('should detect overdue', () => { + const today = new Date('2026-04-12') + const renewal = new Date('2026-04-10') + expect(isOverdue(today, renewal)).toBe(true) + }) +}) diff --git a/apps/api/tests/unit/money.test.ts b/apps/api/tests/unit/money.test.ts new file mode 100644 index 0000000..927b157 --- /dev/null +++ b/apps/api/tests/unit/money.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest' +import { convertAmount } from '../../src/utils/money' + +describe('money utils', () => { + it('should convert USD to CNY with base USD rates', () => { + const result = convertAmount(10, 'USD', 'CNY', 'USD', { CNY: 7.2, EUR: 0.92 }) + expect(result).toBe(72) + }) + + it('should convert EUR to CNY with base USD rates', () => { + const result = convertAmount(10, 'EUR', 'CNY', 'USD', { CNY: 7.2, EUR: 0.9 }) + expect(result).toBe(80) + }) +}) diff --git a/apps/api/tests/unit/subscription-order.test.ts b/apps/api/tests/unit/subscription-order.test.ts new file mode 100644 index 0000000..940bf4f --- /dev/null +++ b/apps/api/tests/unit/subscription-order.test.ts @@ -0,0 +1,53 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const store = new Map() + +vi.mock('../../src/services/settings.service', () => ({ + getSetting: vi.fn(async (key: string, fallbackValue: T) => + (store.has(key) ? (store.get(key) as T) : fallbackValue) + ), + setSetting: vi.fn(async (key: string, value: unknown) => { + store.set(key, value) + }) +})) + +import { + appendSubscriptionOrder, + getSubscriptionOrder, + removeSubscriptionOrder, + setSubscriptionOrder, + sortSubscriptionsByOrder +} from '../../src/services/subscription-order.service' + +describe('subscription order service', () => { + beforeEach(() => { + store.clear() + }) + + it('should persist unique ids in order', async () => { + await setSubscriptionOrder(['sub-2', 'sub-1', 'sub-2', 'sub-3']) + + await expect(getSubscriptionOrder()).resolves.toEqual(['sub-2', 'sub-1', 'sub-3']) + }) + + it('should append and remove ids', async () => { + await appendSubscriptionOrder('sub-1') + await appendSubscriptionOrder('sub-2') + await appendSubscriptionOrder('sub-1') + await removeSubscriptionOrder('sub-2') + + await expect(getSubscriptionOrder()).resolves.toEqual(['sub-1']) + }) + + it('should sort subscriptions by stored custom order', async () => { + await setSubscriptionOrder(['sub-3', 'sub-1']) + + const rows = await sortSubscriptionsByOrder([ + { id: 'sub-1', name: 'A' }, + { id: 'sub-2', name: 'B' }, + { id: 'sub-3', name: 'C' } + ]) + + expect(rows.map((row) => row.id)).toEqual(['sub-3', 'sub-1', 'sub-2']) + }) +}) diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json new file mode 100644 index 0000000..3049f93 --- /dev/null +++ b/apps/api/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } + }, + "include": ["src/**/*.ts", "tests/**/*.ts", "prisma/seed.ts"] +} diff --git a/apps/api/vitest.config.ts b/apps/api/vitest.config.ts new file mode 100644 index 0000000..ec9809a --- /dev/null +++ b/apps/api/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + include: ['tests/**/*.test.ts'], + environment: 'node' + } +}) diff --git a/apps/web/index.html b/apps/web/index.html new file mode 100644 index 0000000..0907937 --- /dev/null +++ b/apps/web/index.html @@ -0,0 +1,12 @@ + + + + + + SubTracker + + +
+ + + diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..6c76c18 --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,37 @@ +{ + "name": "@subtracker/web", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vue-tsc --noEmit && vite build", + "preview": "vite preview", + "test": "vitest run", + "lint": "vue-tsc --noEmit" + }, + "dependencies": { + "@subtracker/shared": "0.1.0", + "@tanstack/vue-query": "^5.74.9", + "@vicons/ionicons5": "^0.13.0", + "@vueuse/core": "^13.0.0", + "axios": "^1.8.4", + "dayjs": "^1.11.13", + "echarts": "^5.6.0", + "naive-ui": "^2.41.0", + "pinia": "^3.0.2", + "vue": "^3.5.13", + "vue-echarts": "^7.0.3", + "vue-router": "^4.5.0" + }, + "devDependencies": { + "@types/node": "^22.14.1", + "@vitejs/plugin-vue": "^5.2.3", + "@vue/test-utils": "^2.4.6", + "jsdom": "^26.0.0", + "typescript": "^5.8.3", + "vite": "^6.2.6", + "vitest": "^3.1.1", + "vue-tsc": "^2.2.8" + } +} diff --git a/apps/web/src/App.vue b/apps/web/src/App.vue new file mode 100644 index 0000000..0e1d71c --- /dev/null +++ b/apps/web/src/App.vue @@ -0,0 +1,229 @@ + + + + + diff --git a/apps/web/src/components/CategoryFormModal.vue b/apps/web/src/components/CategoryFormModal.vue new file mode 100644 index 0000000..ae3aeac --- /dev/null +++ b/apps/web/src/components/CategoryFormModal.vue @@ -0,0 +1,277 @@ + + + + + diff --git a/apps/web/src/components/CategoryManageModal.vue b/apps/web/src/components/CategoryManageModal.vue new file mode 100644 index 0000000..0d77ccc --- /dev/null +++ b/apps/web/src/components/CategoryManageModal.vue @@ -0,0 +1,268 @@ + + + diff --git a/apps/web/src/components/ChartView.vue b/apps/web/src/components/ChartView.vue new file mode 100644 index 0000000..c3b992a --- /dev/null +++ b/apps/web/src/components/ChartView.vue @@ -0,0 +1,30 @@ + + + + + diff --git a/apps/web/src/components/PageHeader.vue b/apps/web/src/components/PageHeader.vue new file mode 100644 index 0000000..9ba5bd2 --- /dev/null +++ b/apps/web/src/components/PageHeader.vue @@ -0,0 +1,86 @@ + + + + + diff --git a/apps/web/src/components/StatCard.test.ts b/apps/web/src/components/StatCard.test.ts new file mode 100644 index 0000000..0071b1e --- /dev/null +++ b/apps/web/src/components/StatCard.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest' +import { mount } from '@vue/test-utils' +import StatCard from '@/components/StatCard.vue' + +describe('StatCard', () => { + it('renders value and label', () => { + const wrapper = mount(StatCard, { + props: { + label: '活跃订阅', + value: 12 + } + }) + + expect(wrapper.text()).toContain('活跃订阅') + expect(wrapper.text()).toContain('12') + }) +}) diff --git a/apps/web/src/components/StatCard.vue b/apps/web/src/components/StatCard.vue new file mode 100644 index 0000000..fc6279d --- /dev/null +++ b/apps/web/src/components/StatCard.vue @@ -0,0 +1,74 @@ + + + + + diff --git a/apps/web/src/components/SubscriptionAiModal.vue b/apps/web/src/components/SubscriptionAiModal.vue new file mode 100644 index 0000000..07f99d5 --- /dev/null +++ b/apps/web/src/components/SubscriptionAiModal.vue @@ -0,0 +1,175 @@ + + + + + diff --git a/apps/web/src/components/SubscriptionDetailDrawer.vue b/apps/web/src/components/SubscriptionDetailDrawer.vue new file mode 100644 index 0000000..9ebe74d --- /dev/null +++ b/apps/web/src/components/SubscriptionDetailDrawer.vue @@ -0,0 +1,118 @@ + + + + + diff --git a/apps/web/src/components/SubscriptionFormModal.vue b/apps/web/src/components/SubscriptionFormModal.vue new file mode 100644 index 0000000..3c45dc4 --- /dev/null +++ b/apps/web/src/components/SubscriptionFormModal.vue @@ -0,0 +1,873 @@ + + + + + diff --git a/apps/web/src/components/WebhookFormModal.vue b/apps/web/src/components/WebhookFormModal.vue new file mode 100644 index 0000000..c019b1a --- /dev/null +++ b/apps/web/src/components/WebhookFormModal.vue @@ -0,0 +1,119 @@ + + + diff --git a/apps/web/src/composables/api.ts b/apps/web/src/composables/api.ts new file mode 100644 index 0000000..05f2c86 --- /dev/null +++ b/apps/web/src/composables/api.ts @@ -0,0 +1,278 @@ +import axios from 'axios' +import type { + AiRecognitionResult, + AuthResponse, + AuthUserResponse, + CalendarEvent, + Category, + ChangeCredentialsPayload, + ExchangeRateSnapshot, + LoginPayload, + LogoSearchResult, + NotificationWebhookSettings, + Settings, + StatisticsOverview, + Subscription, + SubscriptionDetail, + WebhookEndpoint +} from '@/types/api' +import { clearAuthSession, getStoredToken } from '@/utils/auth-storage' + +const client = axios.create({ + baseURL: import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:3001/api/v1', + timeout: 30000 +}) + +const LOGO_REQUEST_TIMEOUT_MS = 60000 + +client.interceptors.request.use((request) => { + const token = getStoredToken() + if (token) { + request.headers.Authorization = `Bearer ${token}` + } + return request +}) + +client.interceptors.response.use( + (response) => response, + (error) => { + if (error?.response?.status === 401) { + clearAuthSession() + if (typeof window !== 'undefined' && window.location.pathname !== '/login') { + window.location.href = `/login?redirect=${encodeURIComponent(window.location.pathname)}` + } + } + + const message = error?.response?.data?.error?.message + return Promise.reject(new Error(message || error.message || '请求失败')) + } +) + +type Envelope = { data: T } + +function unwrap(res: { data: Envelope }): T { + return res.data.data +} + +export const api = { + async login(username: string, password: string) { + return unwrap((await client.post('/auth/login', { username, password } satisfies LoginPayload)) as { + data: Envelope + }) + }, + + async getCurrentUser() { + return unwrap((await client.get('/auth/me')) as { data: Envelope }) + }, + + async changeCredentials(payload: ChangeCredentialsPayload) { + return unwrap((await client.post('/auth/change-credentials', payload)) as { + data: Envelope + }) + }, + + async getSubscriptions(params?: { q?: string; status?: string; categoryId?: string }) { + return unwrap((await client.get('/subscriptions', { params })) as { data: Envelope }) + }, + + async getSubscription(id: string) { + return unwrap((await client.get(`/subscriptions/${id}`)) as { data: Envelope }) + }, + + async createSubscription(payload: Record) { + return unwrap((await client.post('/subscriptions', payload)) as { data: Envelope }) + }, + + async searchSubscriptionLogos(payload: { name: string; websiteUrl?: string; categoryName?: string }) { + return unwrap((await client.post('/subscriptions/logo/search', payload, { timeout: LOGO_REQUEST_TIMEOUT_MS })) as { + data: Envelope + }) + }, + + async getSubscriptionLogoLibrary() { + return unwrap((await client.get('/subscriptions/logo/library')) as { + data: Envelope + }) + }, + + async deleteSubscriptionLogoFromLibrary(filename: string) { + return unwrap<{ filename: string; logoUrl: string; deleted: boolean }>( + (await client.delete(`/subscriptions/logo/library/${encodeURIComponent(filename)}`)) as { + data: Envelope<{ filename: string; logoUrl: string; deleted: boolean }> + } + ) + }, + + async uploadSubscriptionLogo(payload: { filename: string; contentType: string; base64: string }) { + return unwrap<{ logoUrl: string; logoSource: string }>((await client.post('/subscriptions/logo/upload', payload)) as { + data: Envelope<{ logoUrl: string; logoSource: string }> + }) + }, + + async importSubscriptionLogo(payload: { logoUrl: string; source?: string }) { + return unwrap<{ logoUrl: string; logoSource: string }>( + (await client.post('/subscriptions/logo/import', payload, { timeout: LOGO_REQUEST_TIMEOUT_MS })) as { + data: Envelope<{ logoUrl: string; logoSource: string }> + } + ) + }, + + async updateSubscription(id: string, payload: Record) { + return unwrap((await client.patch(`/subscriptions/${id}`, payload)) as { data: Envelope }) + }, + + async reorderSubscriptions(ids: string[]) { + return unwrap<{ success: boolean }>((await client.post('/subscriptions/reorder', { ids })) as { + data: Envelope<{ success: boolean }> + }) + }, + + async renewSubscription(id: string, payload: Record = {}) { + return unwrap<{ subscription: Subscription }>( + (await client.post(`/subscriptions/${id}/renew`, payload)) as { data: Envelope<{ subscription: Subscription }> } + ) + }, + + async pauseSubscription(id: string) { + return unwrap((await client.post(`/subscriptions/${id}/pause`)) as { data: Envelope }) + }, + + async cancelSubscription(id: string) { + return unwrap((await client.post(`/subscriptions/${id}/cancel`)) as { data: Envelope }) + }, + + async deleteSubscription(id: string) { + return unwrap<{ id: string; deleted: boolean }>( + (await client.delete(`/subscriptions/${id}`)) as { data: Envelope<{ id: string; deleted: boolean }> } + ) + }, + + async recognizeSubscriptionByAi(payload: { + text?: string + imageBase64?: string + filename?: string + mimeType?: string + }) { + return unwrap((await client.post('/ai/recognize-subscription', payload)) as { + data: Envelope + }) + }, + + async testAiConfiguration() { + return unwrap<{ success: boolean; providerName: string; model: string; response: string }>( + (await client.post('/ai/test')) as { + data: Envelope<{ success: boolean; providerName: string; model: string; response: string }> + } + ) + }, + + async testAiConfigurationWithPayload(payload: Settings['aiConfig']) { + return unwrap<{ success: boolean; providerName: string; model: string; response: string }>( + (await client.post('/ai/test', payload)) as { + data: Envelope<{ success: boolean; providerName: string; model: string; response: string }> + } + ) + }, + + async getCategories() { + return unwrap((await client.get('/categories')) as { data: Envelope }) + }, + + async createCategory(payload: Record) { + return unwrap((await client.post('/categories', payload)) as { data: Envelope }) + }, + + async updateCategory(id: string, payload: Record) { + return unwrap((await client.patch(`/categories/${id}`, payload)) as { data: Envelope }) + }, + + async deleteCategory(id: string) { + return unwrap<{ id: string; deleted: boolean }>( + (await client.delete(`/categories/${id}`)) as { data: Envelope<{ id: string; deleted: boolean }> } + ) + }, + + async getStatisticsOverview() { + return unwrap((await client.get('/statistics/overview')) as { data: Envelope }) + }, + + async getCalendarEvents(params?: { start?: string; end?: string }) { + return unwrap((await client.get('/calendar/events', { params })) as { data: Envelope }) + }, + + async getSettings() { + return unwrap((await client.get('/settings')) as { data: Envelope }) + }, + + async updateSettings(payload: Partial) { + return unwrap((await client.patch('/settings', payload)) as { data: Envelope }) + }, + + async getExchangeRateSnapshot() { + return unwrap((await client.get('/exchange-rates/latest')) as { data: Envelope }) + }, + + async refreshExchangeRates() { + return unwrap((await client.post('/exchange-rates/refresh')) as { data: Envelope }) + }, + + async testEmailNotification() { + return unwrap<{ success: boolean }>((await client.post('/notifications/test/email')) as { + data: Envelope<{ success: boolean }> + }) + }, + + async testEmailNotificationWithPayload(payload: Settings['emailConfig']) { + return unwrap<{ success: boolean }>((await client.post('/notifications/test/email', payload)) as { + data: Envelope<{ success: boolean }> + }) + }, + + async testPushplusNotification() { + return unwrap<{ success: boolean }>((await client.post('/notifications/test/pushplus')) as { + data: Envelope<{ success: boolean }> + }) + }, + + async testPushplusNotificationWithPayload(payload: Settings['pushplusConfig']) { + return unwrap<{ success: boolean }>((await client.post('/notifications/test/pushplus', payload)) as { + data: Envelope<{ success: boolean }> + }) + }, + + async getNotificationWebhook() { + return unwrap((await client.get('/notifications/webhook')) as { + data: Envelope + }) + }, + + async updateNotificationWebhook(payload: { url: string; secret: string; enabled: boolean }) { + return unwrap((await client.put('/notifications/webhook', payload)) as { + data: Envelope + }) + }, + + async testWebhookNotification() { + return unwrap<{ success: boolean }>((await client.post('/notifications/test/webhook')) as { + data: Envelope<{ success: boolean }> + }) + }, + + async testWebhookNotificationWithPayload(payload: { url: string; secret: string; enabled: boolean }) { + return unwrap<{ success: boolean }>((await client.post('/notifications/test/webhook', payload)) as { + data: Envelope<{ success: boolean }> + }) + }, + + async getWebhookEndpoints() { + return unwrap((await client.get('/webhooks')) as { data: Envelope }) + }, + + async createWebhookEndpoint(payload: Record) { + return unwrap((await client.post('/webhooks', payload)) as { data: Envelope }) + }, + + async updateWebhookEndpoint(id: string, payload: Record) { + return unwrap((await client.patch(`/webhooks/${id}`, payload)) as { data: Envelope }) + } +} diff --git a/apps/web/src/data/currency-names.zh-CN.json b/apps/web/src/data/currency-names.zh-CN.json new file mode 100644 index 0000000..cd66dd9 --- /dev/null +++ b/apps/web/src/data/currency-names.zh-CN.json @@ -0,0 +1,168 @@ +{ + "AED": "阿联酋迪拉姆", + "AFN": "阿富汗尼", + "ALL": "阿尔巴尼亚列克", + "AMD": "亚美尼亚德拉姆", + "ANG": "荷属安的列斯盾", + "AOA": "安哥拉宽扎", + "ARS": "阿根廷比索", + "AUD": "澳大利亚元", + "AWG": "阿鲁巴弗罗林", + "AZN": "阿塞拜疆马纳特", + "BAM": "波黑可兑换马克", + "BBD": "巴巴多斯元", + "BDT": "孟加拉塔卡", + "BGN": "保加利亚列弗", + "BHD": "巴林第纳尔", + "BIF": "布隆迪法郎", + "BMD": "百慕大元", + "BND": "文莱元", + "BOB": "玻利维亚诺", + "BRL": "巴西雷亚尔", + "BSD": "巴哈马元", + "BTN": "不丹努尔特鲁姆", + "BWP": "博茨瓦纳普拉", + "BYN": "白俄罗斯卢布", + "BZD": "伯利兹元", + "CAD": "加拿大元", + "CDF": "刚果法郎", + "CHF": "瑞士法郎", + "CLF": "智利记账单位UF", + "CLP": "智利比索", + "CNH": "离岸人民币", + "CNY": "人民币", + "COP": "哥伦比亚比索", + "CRC": "哥斯达黎加科朗", + "CUP": "古巴比索", + "CVE": "佛得角埃斯库多", + "CZK": "捷克克朗", + "DJF": "吉布提法郎", + "DKK": "丹麦克朗", + "DOP": "多米尼加比索", + "DZD": "阿尔及利亚第纳尔", + "EGP": "埃及镑", + "ERN": "厄立特里亚纳克法", + "ETB": "埃塞俄比亚比尔", + "EUR": "欧元", + "FJD": "斐济元", + "FKP": "福克兰群岛镑", + "FOK": "法罗克朗", + "GBP": "英镑", + "GEL": "格鲁吉亚拉里", + "GGP": "根西镑", + "GHS": "加纳塞地", + "GIP": "直布罗陀镑", + "GMD": "冈比亚达拉西", + "GNF": "几内亚法郎", + "GTQ": "危地马拉格查尔", + "GYD": "圭亚那元", + "HKD": "港币", + "HNL": "洪都拉斯伦皮拉", + "HRK": "克罗地亚库纳", + "HTG": "海地古德", + "HUF": "匈牙利福林", + "IDR": "印尼盾", + "ILS": "以色列新谢克尔", + "IMP": "马恩岛镑", + "INR": "印度卢比", + "IQD": "伊拉克第纳尔", + "IRR": "伊朗里亚尔", + "ISK": "冰岛克朗", + "JEP": "泽西镑", + "JMD": "牙买加元", + "JOD": "约旦第纳尔", + "JPY": "日元", + "KES": "肯尼亚先令", + "KGS": "吉尔吉斯斯坦索姆", + "KHR": "柬埔寨瑞尔", + "KID": "基里巴斯元", + "KMF": "科摩罗法郎", + "KRW": "韩元", + "KWD": "科威特第纳尔", + "KYD": "开曼群岛元", + "KZT": "哈萨克斯坦坚戈", + "LAK": "老挝基普", + "LBP": "黎巴嫩镑", + "LKR": "斯里兰卡卢比", + "LRD": "利比里亚元", + "LSL": "莱索托洛蒂", + "LYD": "利比亚第纳尔", + "MAD": "摩洛哥迪拉姆", + "MDL": "摩尔多瓦列伊", + "MGA": "马达加斯加阿里亚里", + "MKD": "北马其顿第纳尔", + "MMK": "缅甸缅元", + "MNT": "蒙古图格里克", + "MOP": "澳门元", + "MRU": "毛里塔尼亚乌吉亚", + "MUR": "毛里求斯卢比", + "MVR": "马尔代夫拉菲亚", + "MWK": "马拉维克瓦查", + "MXN": "墨西哥比索", + "MYR": "马来西亚林吉特", + "MZN": "莫桑比克梅蒂卡尔", + "NAD": "纳米比亚元", + "NGN": "尼日利亚奈拉", + "NIO": "尼加拉瓜科多巴", + "NOK": "挪威克朗", + "NPR": "尼泊尔卢比", + "NZD": "新西兰元", + "OMR": "阿曼里亚尔", + "PAB": "巴拿马巴波亚", + "PEN": "秘鲁索尔", + "PGK": "巴布亚新几内亚基那", + "PHP": "菲律宾比索", + "PKR": "巴基斯坦卢比", + "PLN": "波兰兹罗提", + "PYG": "巴拉圭瓜拉尼", + "QAR": "卡塔尔里亚尔", + "RON": "罗马尼亚列伊", + "RSD": "塞尔维亚第纳尔", + "RUB": "俄罗斯卢布", + "RWF": "卢旺达法郎", + "SAR": "沙特里亚尔", + "SBD": "所罗门群岛元", + "SCR": "塞舌尔卢比", + "SDG": "苏丹镑", + "SEK": "瑞典克朗", + "SGD": "新加坡元", + "SHP": "圣赫勒拿镑", + "SLE": "塞拉利昂新利昂", + "SLL": "塞拉利昂利昂", + "SOS": "索马里先令", + "SRD": "苏里南元", + "SSP": "南苏丹镑", + "STN": "圣多美和普林西比多布拉", + "SYP": "叙利亚镑", + "SZL": "斯威士兰里兰吉尼", + "THB": "泰铢", + "TJS": "塔吉克斯坦索莫尼", + "TMT": "土库曼斯坦马纳特", + "TND": "突尼斯第纳尔", + "TOP": "汤加潘加", + "TRY": "土耳其里拉", + "TTD": "特立尼达和多巴哥元", + "TVD": "图瓦卢元", + "TWD": "新台币", + "TZS": "坦桑尼亚先令", + "UAH": "乌克兰格里夫纳", + "UGX": "乌干达先令", + "USD": "美元", + "UYU": "乌拉圭比索", + "UZS": "乌兹别克斯坦苏姆", + "VES": "委内瑞拉玻利瓦尔", + "VND": "越南盾", + "VUV": "瓦努阿图瓦图", + "WST": "萨摩亚塔拉", + "XAF": "中非法郎", + "XCD": "东加勒比元", + "XCG": "荷属加勒比盾", + "XDR": "特别提款权", + "XOF": "西非法郎", + "XPF": "太平洋法郎", + "YER": "也门里亚尔", + "ZAR": "南非兰特", + "ZMW": "赞比亚克瓦查", + "ZWG": "津巴布韦金本位货币", + "ZWL": "津巴布韦元" +} diff --git a/apps/web/src/env.d.ts b/apps/web/src/env.d.ts new file mode 100644 index 0000000..5006088 --- /dev/null +++ b/apps/web/src/env.d.ts @@ -0,0 +1,5 @@ +declare module '*.vue' { + import type { DefineComponent } from 'vue' + const component: DefineComponent, Record, unknown> + export default component +} diff --git a/apps/web/src/main.ts b/apps/web/src/main.ts new file mode 100644 index 0000000..ed8c9bf --- /dev/null +++ b/apps/web/src/main.ts @@ -0,0 +1,20 @@ +import { createApp } from 'vue' +import { VueQueryPlugin, QueryClient } from '@tanstack/vue-query' +import { createPinia } from 'pinia' +import { create, NConfigProvider, NMessageProvider } from 'naive-ui' +import App from './App.vue' +import { router } from './router' +import './style.css' + +const naive = create({ + components: [NConfigProvider, NMessageProvider] +}) + +const app = createApp(App) +const queryClient = new QueryClient() + +app.use(createPinia()) +app.use(router) +app.use(naive) +app.use(VueQueryPlugin, { queryClient }) +app.mount('#app') diff --git a/apps/web/src/pages/CalendarPage.vue b/apps/web/src/pages/CalendarPage.vue new file mode 100644 index 0000000..31f504a --- /dev/null +++ b/apps/web/src/pages/CalendarPage.vue @@ -0,0 +1,347 @@ + + + + + diff --git a/apps/web/src/pages/DashboardPage.vue b/apps/web/src/pages/DashboardPage.vue new file mode 100644 index 0000000..f289c96 --- /dev/null +++ b/apps/web/src/pages/DashboardPage.vue @@ -0,0 +1,218 @@ + + + + + diff --git a/apps/web/src/pages/LoginPage.vue b/apps/web/src/pages/LoginPage.vue new file mode 100644 index 0000000..888c095 --- /dev/null +++ b/apps/web/src/pages/LoginPage.vue @@ -0,0 +1,115 @@ + + + + + diff --git a/apps/web/src/pages/SettingsPage.vue b/apps/web/src/pages/SettingsPage.vue new file mode 100644 index 0000000..6c841ca --- /dev/null +++ b/apps/web/src/pages/SettingsPage.vue @@ -0,0 +1,655 @@ + + + + + diff --git a/apps/web/src/pages/StatisticsPage.vue b/apps/web/src/pages/StatisticsPage.vue new file mode 100644 index 0000000..c181ca9 --- /dev/null +++ b/apps/web/src/pages/StatisticsPage.vue @@ -0,0 +1,128 @@ + + + diff --git a/apps/web/src/pages/SubscriptionsPage.vue b/apps/web/src/pages/SubscriptionsPage.vue new file mode 100644 index 0000000..7dff95c --- /dev/null +++ b/apps/web/src/pages/SubscriptionsPage.vue @@ -0,0 +1,1087 @@ + + + + + diff --git a/apps/web/src/router/index.ts b/apps/web/src/router/index.ts new file mode 100644 index 0000000..4d998cd --- /dev/null +++ b/apps/web/src/router/index.ts @@ -0,0 +1,67 @@ +import { createRouter, createWebHistory } from 'vue-router' +import { getStoredToken } from '@/utils/auth-storage' + +export const routes = [ + { path: '/', redirect: '/dashboard' }, + { + path: '/login', + name: 'login', + component: () => import('@/pages/LoginPage.vue'), + meta: { public: true, label: '登录' } + }, + { + path: '/dashboard', + name: 'dashboard', + component: () => import('@/pages/DashboardPage.vue'), + meta: { label: '仪表盘' } + }, + { + path: '/subscriptions', + name: 'subscriptions', + component: () => import('@/pages/SubscriptionsPage.vue'), + meta: { label: '订阅管理' } + }, + { + path: '/calendar', + name: 'calendar', + component: () => import('@/pages/CalendarPage.vue'), + meta: { label: '订阅日历' } + }, + { + path: '/statistics', + name: 'statistics', + component: () => import('@/pages/StatisticsPage.vue'), + meta: { label: '费用统计' } + }, + { + path: '/settings', + name: 'settings', + component: () => import('@/pages/SettingsPage.vue'), + meta: { label: '系统设置' } + } +] + +export const router = createRouter({ + history: createWebHistory(), + routes +}) + +router.beforeEach((to) => { + const token = getStoredToken() + + if (to.meta.public) { + if (to.path === '/login' && token) { + return '/dashboard' + } + return true + } + + if (!token) { + return { + path: '/login', + query: { redirect: to.fullPath } + } + } + + return true +}) diff --git a/apps/web/src/stores/app.ts b/apps/web/src/stores/app.ts new file mode 100644 index 0000000..103c0f3 --- /dev/null +++ b/apps/web/src/stores/app.ts @@ -0,0 +1,48 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { api } from '@/composables/api' +import type { Settings } from '@/types/api' + +export const useAppStore = defineStore('app', () => { + const settings = ref({ + baseCurrency: 'CNY', + defaultNotifyDays: 3, + monthlyBudgetBase: null, + yearlyBudgetBase: null, + enableCategoryBudgets: false, + categoryBudgets: {}, + emailNotificationsEnabled: false, + pushplusNotificationsEnabled: false, + emailConfig: { + host: '', + port: 587, + secure: false, + username: '', + password: '', + from: '', + to: '' + }, + pushplusConfig: { + token: '', + topic: '' + }, + aiConfig: { + enabled: false, + providerName: 'DeepSeek', + baseUrl: 'https://api.deepseek.com', + apiKey: '', + model: 'deepseek-chat', + timeoutMs: 30000, + promptTemplate: '' + } + }) + + async function refreshSettings() { + settings.value = await api.getSettings() + } + + return { + settings, + refreshSettings + } +}) diff --git a/apps/web/src/stores/auth.ts b/apps/web/src/stores/auth.ts new file mode 100644 index 0000000..338749f --- /dev/null +++ b/apps/web/src/stores/auth.ts @@ -0,0 +1,30 @@ +import { defineStore } from 'pinia' +import { api } from '@/composables/api' +import { clearAuthSession, getStoredToken, getStoredUsername, saveAuthSession } from '@/utils/auth-storage' + +export const useAuthStore = defineStore('auth', { + state: () => ({ + token: getStoredToken() ?? '', + username: getStoredUsername() ?? '' + }), + getters: { + isAuthenticated: (state) => Boolean(state.token) + }, + actions: { + setSession(token: string, username: string) { + this.token = token + this.username = username + saveAuthSession(token, username) + }, + clearSession() { + this.token = '' + this.username = '' + clearAuthSession() + }, + async login(username: string, password: string) { + const result = await api.login(username, password) + this.setSession(result.token, result.user.username) + return result + } + } +}) diff --git a/apps/web/src/style.css b/apps/web/src/style.css new file mode 100644 index 0000000..826cab9 --- /dev/null +++ b/apps/web/src/style.css @@ -0,0 +1,34 @@ +:root { + font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif; + color: #111827; + background: #f3f4f6; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + background: #f3f4f6; +} + +#app { + min-height: 100vh; +} + +.page-title { + margin: 0; + font-size: 22px; + font-weight: 700; +} + +.page-subtitle { + color: #6b7280; + margin-top: 4px; + margin-bottom: 18px; +} + +.card-muted { + color: #6b7280; +} diff --git a/apps/web/src/types/api.ts b/apps/web/src/types/api.ts new file mode 100644 index 0000000..44c249d --- /dev/null +++ b/apps/web/src/types/api.ts @@ -0,0 +1,217 @@ +export type SubscriptionStatus = 'active' | 'paused' | 'cancelled' | 'expired' + +export interface AuthUser { + username: string +} + +export interface AuthResponse { + token: string + user: AuthUser +} + +export interface AuthUserResponse { + user: AuthUser +} + +export interface LoginPayload { + username: string + password: string +} + +export interface ChangeCredentialsPayload { + oldUsername: string + oldPassword: string + newUsername: string + newPassword: string +} + +export interface Category { + id: string + name: string + color: string + icon: string + sortOrder: number +} + +export interface Subscription { + id: string + name: string + categoryId?: string | null + category?: Category | null + description: string + websiteUrl?: string | null + logoUrl?: string | null + logoSource?: string | null + logoFetchedAt?: string | null + status: SubscriptionStatus + amount: number + currency: string + billingIntervalCount: number + billingIntervalUnit: 'day' | 'week' | 'month' | 'quarter' | 'year' + startDate: string + nextRenewalDate: string + notifyDaysBefore: number + webhookEnabled: boolean + notes: string + createdAt: string + updatedAt: string +} + +export interface SubscriptionDetail extends Subscription {} + +export interface CategoryBudgetUsage { + categoryId: string + name: string + budget: number + spent: number + ratio: number +} + +export interface StatisticsOverview { + activeSubscriptions: number + upcoming7Days: number + upcoming30Days: number + monthlyEstimatedBase: number + yearlyEstimatedBase: number + monthlyBudgetBase?: number | null + yearlyBudgetBase?: number | null + monthlyBudgetUsageRatio?: number | null + yearlyBudgetUsageRatio?: number | null + categorySpend: Array<{ name: string; value: number }> + monthlyTrend: Array<{ month: string; amount: number }> + categoryBudgetUsage?: CategoryBudgetUsage[] + currencyDistribution: Array<{ currency: string; amount: number }> + upcomingRenewals: Array<{ + id: string + name: string + nextRenewalDate: string + amount: number + currency: string + convertedAmount: number + status: SubscriptionStatus + }> +} + +export interface CalendarEvent { + id: string + title: string + date: string + currency: string + amount: number + convertedAmount: number + status: SubscriptionStatus +} + +export interface EmailConfig { + host: string + port: number + secure: boolean + username: string + password: string + from: string + to: string +} + +export interface PushplusConfig { + token: string + topic: string +} + +export interface AiConfig { + enabled: boolean + providerName: string + baseUrl: string + apiKey: string + model: string + timeoutMs: number + promptTemplate: string +} + +export interface Settings { + baseCurrency: string + defaultNotifyDays: number + monthlyBudgetBase?: number | null + yearlyBudgetBase?: number | null + enableCategoryBudgets: boolean + categoryBudgets: Record + emailNotificationsEnabled: boolean + pushplusNotificationsEnabled: boolean + emailConfig: EmailConfig + pushplusConfig: PushplusConfig + aiConfig: AiConfig +} + +export interface NotificationWebhookSettings { + id: string + url: string + secret: string + enabled: boolean +} + +export interface WebhookEndpoint { + id: string + name: string + url: string + secret: string + enabled: boolean + eventsJson: string[] + createdAt: string + updatedAt: string +} + +export interface ExchangeRateSnapshot { + baseCurrency: string + rates: Record + fetchedAt: string + provider: string + isStale: boolean +} + +export interface WebhookDelivery { + id: string + endpointId: string + eventType: string + resourceKey: string + periodKey: string + status: 'pending' | 'success' | 'failed' + responseCode?: number + responseBody?: string + attemptCount: number + lastAttemptAt?: string + createdAt: string + endpoint?: { + id: string + name: string + url: string + } +} + +export interface LogoSearchResult { + label: string + logoUrl: string + source: string + websiteUrl?: string + width?: number + height?: number + isLocal?: boolean + usageCount?: number + filename?: string + updatedAt?: string + relatedSubscriptionNames?: string[] +} + +export interface AiRecognitionResult { + name?: string + description?: string + amount?: number + currency?: string + billingIntervalCount?: number + billingIntervalUnit?: 'day' | 'week' | 'month' | 'quarter' | 'year' + startDate?: string + nextRenewalDate?: string + notifyDaysBefore?: number + websiteUrl?: string + notes?: string + confidence?: number + rawText?: string +} diff --git a/apps/web/src/utils/auth-storage.ts b/apps/web/src/utils/auth-storage.ts new file mode 100644 index 0000000..f52c4f7 --- /dev/null +++ b/apps/web/src/utils/auth-storage.ts @@ -0,0 +1,20 @@ +const TOKEN_KEY = 'subtracker.auth.token' +const USERNAME_KEY = 'subtracker.auth.username' + +export function getStoredToken() { + return window.localStorage.getItem(TOKEN_KEY) +} + +export function getStoredUsername() { + return window.localStorage.getItem(USERNAME_KEY) +} + +export function saveAuthSession(token: string, username: string) { + window.localStorage.setItem(TOKEN_KEY, token) + window.localStorage.setItem(USERNAME_KEY, username) +} + +export function clearAuthSession() { + window.localStorage.removeItem(TOKEN_KEY) + window.localStorage.removeItem(USERNAME_KEY) +} diff --git a/apps/web/src/utils/currency.ts b/apps/web/src/utils/currency.ts new file mode 100644 index 0000000..cfe3f98 --- /dev/null +++ b/apps/web/src/utils/currency.ts @@ -0,0 +1,14 @@ +import currencyNameMap from '@/data/currency-names.zh-CN.json' + +export function getCurrencyLabel(code: string) { + const upper = code.toUpperCase() + const name = (currencyNameMap as Record)[upper] + return name ? `${name} (${upper})` : upper +} + +export function buildCurrencyOptions(currencies: string[]) { + return currencies.map((currency) => ({ + label: getCurrencyLabel(currency), + value: currency + })) +} diff --git a/apps/web/src/utils/logo.ts b/apps/web/src/utils/logo.ts new file mode 100644 index 0000000..1584cae --- /dev/null +++ b/apps/web/src/utils/logo.ts @@ -0,0 +1,7 @@ +export function resolveLogoUrl(url?: string | null) { + if (!url) return '' + if (/^https?:\/\//i.test(url)) return url + + const base = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:3001/api/v1' + return new URL(url, base.replace(/\/api\/v1$/, '')).toString() +} diff --git a/apps/web/src/utils/subscription-status.ts b/apps/web/src/utils/subscription-status.ts new file mode 100644 index 0000000..59c7e23 --- /dev/null +++ b/apps/web/src/utils/subscription-status.ts @@ -0,0 +1,30 @@ +import type { SubscriptionStatus } from '@/types/api' + +export function getSubscriptionStatusText(status: SubscriptionStatus | string) { + switch (status) { + case 'active': + return '正常' + case 'paused': + return '暂停' + case 'cancelled': + return '停用' + case 'expired': + return '过期' + default: + return status + } +} + +export function getSubscriptionStatusTagType(status: SubscriptionStatus | string): 'default' | 'success' | 'warning' | 'error' { + switch (status) { + case 'active': + return 'success' + case 'paused': + return 'warning' + case 'cancelled': + case 'expired': + return 'error' + default: + return 'default' + } +} diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json new file mode 100644 index 0000000..9b13c1d --- /dev/null +++ b/apps/web/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + }, + "types": ["vite/client", "node"] + }, + "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"] +} diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts new file mode 100644 index 0000000..ecd9040 --- /dev/null +++ b/apps/web/vite.config.ts @@ -0,0 +1,57 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import path from 'node:path' + +export default defineConfig({ + plugins: [vue()], + resolve: { + alias: { + '@': path.resolve(__dirname, './src') + } + }, + server: { + port: 5173 + }, + build: { + rollupOptions: { + output: { + manualChunks(id) { + if (!id.includes('node_modules')) return + + if (id.includes('vue-router') || id.includes('/vue/') || id.includes('pinia')) { + return 'vendor-vue' + } + + if (id.includes('naive-ui')) { + if (id.includes('/data-table') || id.includes('/pagination')) { + return 'vendor-naive-table' + } + if (id.includes('/date-picker') || id.includes('/select') || id.includes('/input') || id.includes('/form')) { + return 'vendor-naive-form' + } + if (id.includes('/layout') || id.includes('/drawer') || id.includes('/menu') || id.includes('/tabs')) { + return 'vendor-naive-layout' + } + return 'vendor-naive-core' + } + + if (id.includes('@vicons')) { + return 'vendor-icons' + } + + if (id.includes('vue-echarts')) { + return 'vendor-vcharts' + } + + if (id.includes('echarts') || id.includes('zrender')) { + return 'vendor-echarts' + } + + if (id.includes('dayjs') || id.includes('axios') || id.includes('@tanstack') || id.includes('@vueuse')) { + return 'vendor-utils' + } + } + } + } + } +}) diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts new file mode 100644 index 0000000..a475685 --- /dev/null +++ b/apps/web/vitest.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from 'vitest/config' +import vue from '@vitejs/plugin-vue' +import path from 'node:path' + +export default defineConfig({ + plugins: [vue()], + resolve: { + alias: { + '@': path.resolve(__dirname, './src') + } + }, + test: { + include: ['src/**/*.test.ts'], + environment: 'jsdom' + } +}) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..06d27bd --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,16 @@ +services: + api: + image: ${SUBTRACKER_API_IMAGE:-ghcr.io/smile-qwq/subtracker-api:latest} + container_name: subtracker-api + restart: unless-stopped + environment: + PORT: ${PORT:-3001} + HOST: ${HOST:-0.0.0.0} + DATABASE_URL: ${DATABASE_URL:-file:/app/data/subtracker.db} + WEB_ORIGIN: ${WEB_ORIGIN:-https://subtracker.example.com} + LOG_LEVEL: ${LOG_LEVEL:-warn} + volumes: + - ./data:/app/data + - ./data/logos:/app/apps/api/storage/logos + ports: + - '${PORT:-3001}:${PORT:-3001}' diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..cc09c4e --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6331 @@ +{ + "name": "subtracker", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "subtracker", + "version": "0.1.0", + "workspaces": [ + "apps/*", + "packages/*" + ], + "devDependencies": { + "concurrently": "^9.1.2", + "typescript": "^5.8.3" + } + }, + "apps/api": { + "name": "@subtracker/api", + "version": "0.1.0", + "dependencies": { + "@fastify/cors": "^10.0.2", + "@prisma/client": "^6.6.0", + "@subtracker/shared": "0.1.0", + "dayjs": "^1.11.13", + "fastify": "^5.2.2", + "node-cron": "^4.0.5", + "nodemailer": "^8.0.5", + "tesseract.js": "^7.0.0", + "zod": "^3.24.3" + }, + "devDependencies": { + "@types/node": "^22.14.1", + "@types/nodemailer": "^8.0.0", + "prisma": "^6.6.0", + "tsup": "^8.4.0", + "tsx": "^4.19.3", + "typescript": "^5.8.3", + "vitest": "^3.1.1" + } + }, + "apps/web": { + "name": "@subtracker/web", + "version": "0.1.0", + "dependencies": { + "@subtracker/shared": "0.1.0", + "@tanstack/vue-query": "^5.74.9", + "@vicons/ionicons5": "^0.13.0", + "@vueuse/core": "^13.0.0", + "axios": "^1.8.4", + "dayjs": "^1.11.13", + "echarts": "^5.6.0", + "naive-ui": "^2.41.0", + "pinia": "^3.0.2", + "vue": "^3.5.13", + "vue-echarts": "^7.0.3", + "vue-router": "^4.5.0" + }, + "devDependencies": { + "@types/node": "^22.14.1", + "@vitejs/plugin-vue": "^5.2.3", + "@vue/test-utils": "^2.4.6", + "jsdom": "^26.0.0", + "typescript": "^5.8.3", + "vite": "^6.2.6", + "vitest": "^3.1.1", + "vue-tsc": "^2.2.8" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@css-render/plugin-bem": { + "version": "0.15.14", + "resolved": "https://registry.npmjs.org/@css-render/plugin-bem/-/plugin-bem-0.15.14.tgz", + "integrity": "sha512-QK513CJ7yEQxm/P3EwsI+d+ha8kSOcjGvD6SevM41neEMxdULE+18iuQK6tEChAWMOQNQPLG/Rw3Khb69r5neg==", + "license": "MIT", + "peerDependencies": { + "css-render": "~0.15.14" + } + }, + "node_modules/@css-render/vue3-ssr": { + "version": "0.15.14", + "resolved": "https://registry.npmjs.org/@css-render/vue3-ssr/-/vue3-ssr-0.15.14.tgz", + "integrity": "sha512-//8027GSbxE9n3QlD73xFY6z4ZbHbvrOVB7AO6hsmrEzGbg+h2A09HboUyDgu+xsmj7JnvJD39Irt+2D0+iV8g==", + "license": "MIT", + "peerDependencies": { + "vue": "^3.0.11" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@emotion/hash": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz", + "integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==", + "license": "MIT" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@fastify/ajv-compiler": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz", + "integrity": "sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^3.0.0" + } + }, + "node_modules/@fastify/cors": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-10.1.0.tgz", + "integrity": "sha512-MZyBCBJtII60CU9Xme/iE4aEy8G7QpzGR8zkdXZkDFt7ElEMachbE61tfhAG/bvSaULlqlf0huMT12T7iqEmdQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fastify-plugin": "^5.0.0", + "mnemonist": "0.40.0" + } + }, + "node_modules/@fastify/error": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz", + "integrity": "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/fast-json-stringify-compiler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-5.0.3.tgz", + "integrity": "sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fast-json-stringify": "^6.0.0" + } + }, + "node_modules/@fastify/forwarded": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@fastify/forwarded/-/forwarded-3.0.1.tgz", + "integrity": "sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/merge-json-schemas": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz", + "integrity": "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@fastify/proxy-addr": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz", + "integrity": "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/forwarded": "^3.0.0", + "ipaddr.js": "^2.1.0" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@juggle/resize-observer": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@juggle/resize-observer/-/resize-observer-3.4.0.tgz", + "integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==", + "license": "Apache-2.0" + }, + "node_modules/@one-ini/wasm": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.1.1.tgz", + "integrity": "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@prisma/client": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.3.tgz", + "integrity": "sha512-mKq3jQFhjvko5LTJFHGilsuQs+W+T3Gm451NzuTDGQxwCzwXHYnIu2zGkRoW+Exq3Rob7yp2MfzSrdIiZVhrBg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "peerDependencies": { + "prisma": "*", + "typescript": ">=5.1.0" + }, + "peerDependenciesMeta": { + "prisma": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@prisma/config": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.19.3.tgz", + "integrity": "sha512-CBPT44BjlQxEt8kiMEauji2WHTDoVBOKl7UlewXmUgBPnr/oPRZC3psci5chJnYmH0ivEIog2OU9PGWoki3DLQ==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "c12": "3.1.0", + "deepmerge-ts": "7.1.5", + "effect": "3.21.0", + "empathic": "2.0.0" + } + }, + "node_modules/@prisma/debug": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.19.3.tgz", + "integrity": "sha512-ljkJ+SgpXNktLG0Q/n4JGYCkKf0f8oYLyjImS2I8e2q2WCfdRRtWER062ZV/ixaNP2M2VKlWXVJiGzZaUgbKZw==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/engines": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.19.3.tgz", + "integrity": "sha512-RSYxtlYFl5pJ8ZePgMv0lZ9IzVCOdTPOegrs2qcbAEFrBI1G33h6wyC9kjQvo0DnYEhEVY0X4LsuFHXLKQk88g==", + "devOptional": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "6.19.3", + "@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", + "@prisma/fetch-engine": "6.19.3", + "@prisma/get-platform": "6.19.3" + } + }, + "node_modules/@prisma/engines-version": { + "version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7.tgz", + "integrity": "sha512-03bgb1VD5gvuumNf+7fVGBzfpJPjmqV423l/WxsWk2cNQ42JD0/SsFBPhN6z8iAvdHs07/7ei77SKu7aZfq8bA==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/fetch-engine": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.19.3.tgz", + "integrity": "sha512-tKtl/qco9Nt7LU5iKhpultD8O4vMCZcU2CHjNTnRrL1QvSUr5W/GcyFPjNL87GtRrwBc7ubXXD9xy4EvLvt8JA==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "6.19.3", + "@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", + "@prisma/get-platform": "6.19.3" + } + }, + "node_modules/@prisma/get-platform": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.19.3.tgz", + "integrity": "sha512-xFj1VcJ1N3MKooOQAGO0W5tsd0W2QzIvW7DD7c/8H14Zmp4jseeWAITm+w2LLoLrlhoHdPPh0NMZ8mfL6puoHA==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "6.19.3" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", + "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", + "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", + "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", + "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", + "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", + "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", + "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", + "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", + "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", + "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", + "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", + "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", + "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", + "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", + "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", + "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", + "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", + "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", + "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", + "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", + "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", + "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", + "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", + "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", + "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@subtracker/api": { + "resolved": "apps/api", + "link": true + }, + "node_modules/@subtracker/shared": { + "resolved": "packages/shared", + "link": true + }, + "node_modules/@subtracker/web": { + "resolved": "apps/web", + "link": true + }, + "node_modules/@tanstack/match-sorter-utils": { + "version": "8.19.4", + "resolved": "https://registry.npmjs.org/@tanstack/match-sorter-utils/-/match-sorter-utils-8.19.4.tgz", + "integrity": "sha512-Wo1iKt2b9OT7d+YGhvEPD3DXvPv2etTusIMhMUoG7fbhmxcXCtIjJDEygy91Y2JFlwGyjqiBPRozme7UD8hoqg==", + "license": "MIT", + "dependencies": { + "remove-accents": "0.5.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/query-core": { + "version": "5.96.2", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.96.2.tgz", + "integrity": "sha512-hzI6cTVh4KNRk8UtoIBS7Lv9g6BnJPXvBKsvYH1aGWvv0347jT3BnSvztOE+kD76XGvZnRC/t6qdW1CaIfwCeA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/vue-query": { + "version": "5.96.2", + "resolved": "https://registry.npmjs.org/@tanstack/vue-query/-/vue-query-5.96.2.tgz", + "integrity": "sha512-mDJoLG1kElNu0vFf8m+sdzka15lWXUZ3CXYVOuivHVdrbjjs5fI0o8i1iqAtIGGL5q+sek4XTbPeWW327zOgtQ==", + "license": "MIT", + "dependencies": { + "@tanstack/match-sorter-utils": "^8.19.4", + "@tanstack/query-core": "5.96.2", + "@vue/devtools-api": "^6.6.3", + "vue-demi": "^0.14.10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@vue/composition-api": "^1.1.2", + "vue": "^2.6.0 || ^3.3.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/lodash": { + "version": "4.17.24", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz", + "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==", + "license": "MIT" + }, + "node_modules/@types/lodash-es": { + "version": "4.17.12", + "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz", + "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", + "license": "MIT", + "dependencies": { + "@types/lodash": "*" + } + }, + "node_modules/@types/node": { + "version": "22.19.17", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", + "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/nodemailer": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.0.tgz", + "integrity": "sha512-fyf8jWULsCo0d0BuoQ75i6IeoHs47qcqxWc7yUdUcV0pOZGjUTTOvwdG1PRXUDqN/8A64yQdQdnA2pZgcdi+cA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.21", + "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", + "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==", + "license": "MIT" + }, + "node_modules/@vicons/ionicons5": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@vicons/ionicons5/-/ionicons5-0.13.0.tgz", + "integrity": "sha512-zvZKBPjEXKN7AXNo2Na2uy+nvuv6SP4KAMQxpKL2vfHMj0fSvuw7JZcOPCjQC3e7ayssKnaoFVAhbYcW6v41qQ==", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.15.tgz", + "integrity": "sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.15" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.15.tgz", + "integrity": "sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.15.tgz", + "integrity": "sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.32.tgz", + "integrity": "sha512-4x74Tbtqnda8s/NSD6e1Dr5p1c8HdMU5RWSjMSUzb8RTcUQqevDCxVAitcLBKT+ie3o0Dl9crc/S/opJM7qBGQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.2", + "@vue/shared": "3.5.32", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-core/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/@vue/compiler-core/node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.32.tgz", + "integrity": "sha512-ybHAu70NtiEI1fvAUz3oXZqkUYEe5J98GjMDpTGl5iHb0T15wQYLR4wE3h9xfuTNA+Cm2f4czfe8B4s+CCH57Q==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.32", + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.32.tgz", + "integrity": "sha512-8UYUYo71cP/0YHMO814TRZlPuUUw3oifHuMR7Wp9SNoRSrxRQnhMLNlCeaODNn6kNTJsjFoQ/kqIj4qGvya4Xg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.2", + "@vue/compiler-core": "3.5.32", + "@vue/compiler-dom": "3.5.32", + "@vue/compiler-ssr": "3.5.32", + "@vue/shared": "3.5.32", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.8", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-sfc/node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.32.tgz", + "integrity": "sha512-Gp4gTs22T3DgRotZ8aA/6m2jMR+GMztvBXUBEUOYOcST+giyGWJ4WvFd7QLHBkzTxkfOt8IELKNdpzITLbA2rw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.32", + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/compiler-vue2": { + "version": "2.7.16", + "resolved": "https://registry.npmjs.org/@vue/compiler-vue2/-/compiler-vue2-2.7.16.tgz", + "integrity": "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==", + "dev": true, + "license": "MIT", + "dependencies": { + "de-indent": "^1.0.2", + "he": "^1.2.0" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/@vue/devtools-kit": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.9.tgz", + "integrity": "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==", + "license": "MIT", + "dependencies": { + "@vue/devtools-shared": "^7.7.9", + "birpc": "^2.3.0", + "hookable": "^5.5.3", + "mitt": "^3.0.1", + "perfect-debounce": "^1.0.0", + "speakingurl": "^14.0.1", + "superjson": "^2.2.2" + } + }, + "node_modules/@vue/devtools-shared": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.9.tgz", + "integrity": "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==", + "license": "MIT", + "dependencies": { + "rfdc": "^1.4.1" + } + }, + "node_modules/@vue/language-core": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-2.2.12.tgz", + "integrity": "sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "@vue/compiler-dom": "^3.5.0", + "@vue/compiler-vue2": "^2.7.16", + "@vue/shared": "^3.5.0", + "alien-signals": "^1.0.3", + "minimatch": "^9.0.3", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.32.tgz", + "integrity": "sha512-/ORasxSGvZ6MN5gc+uE364SxFdJ0+WqVG0CENXaGW58TOCdrAW76WWaplDtECeS1qphvtBZtR+3/o1g1zL4xPQ==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.32.tgz", + "integrity": "sha512-pDrXCejn4UpFDFmMd27AcJEbHaLemaE5o4pbb7sLk79SRIhc6/t34BQA7SGNgYtbMnvbF/HHOftYBgFJtUoJUQ==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.32", + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.32.tgz", + "integrity": "sha512-1CDVv7tv/IV13V8Nip1k/aaObVbWqRlVCVezTwx3K07p7Vxossp5JU1dcPNhJk3w347gonIUT9jQOGutyJrSVQ==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.32", + "@vue/runtime-core": "3.5.32", + "@vue/shared": "3.5.32", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.32.tgz", + "integrity": "sha512-IOjm2+JQwRFS7W28HNuJeXQle9KdZbODFY7hFGVtnnghF51ta20EWAZJHX+zLGtsHhaU6uC9BGPV52KVpYryMQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.32", + "@vue/shared": "3.5.32" + }, + "peerDependencies": { + "vue": "3.5.32" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.32.tgz", + "integrity": "sha512-ksNyrmRQzWJJ8n3cRDuSF7zNNontuJg1YHnmWRJd2AMu8Ij2bqwiiri2lH5rHtYPZjj4STkNcgcmiQqlOjiYGg==", + "license": "MIT" + }, + "node_modules/@vue/test-utils": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@vue/test-utils/-/test-utils-2.4.6.tgz", + "integrity": "sha512-FMxEjOpYNYiFe0GkaHsnJPXFHxQ6m4t8vI/ElPGpMWxZKpmRvQ33OIrvRXemy6yha03RxhOlQuy+gZMC3CQSow==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-beautify": "^1.14.9", + "vue-component-type-helpers": "^2.0.0" + } + }, + "node_modules/@vueuse/core": { + "version": "13.9.0", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-13.9.0.tgz", + "integrity": "sha512-ts3regBQyURfCE2BcytLqzm8+MmLlo5Ln/KLoxDVcsZ2gzIwVNnQpQOL/UKV8alUqjSZOlpFZcRNsLRqj+OzyA==", + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.21", + "@vueuse/metadata": "13.9.0", + "@vueuse/shared": "13.9.0" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/@vueuse/metadata": { + "version": "13.9.0", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-13.9.0.tgz", + "integrity": "sha512-1AFRvuiGphfF7yWixZa0KwjYH8ulyjDCC0aFgrGRz8+P4kvDFSdXLVfTk5xAN9wEuD1J6z4/myMoYbnHoX07zg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "13.9.0", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-13.9.0.tgz", + "integrity": "sha512-e89uuTLMh0U5cZ9iDpEI2senqPGfbPRTHM/0AaQkcxnpqjkZqDYP8rpfm7edOz8s+pOCOROEy1PIveSW8+fL5g==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/abbrev": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz", + "integrity": "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/abstract-logging": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", + "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/alien-signals": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-1.0.13.tgz", + "integrity": "sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/async-validator": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz", + "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/avvio": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/avvio/-/avvio-9.2.0.tgz", + "integrity": "sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/error": "^4.0.0", + "fastq": "^1.17.1" + } + }, + "node_modules/axios": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.14.0.tgz", + "integrity": "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/birpc": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz", + "integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/bmp-js": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/bmp-js/-/bmp-js-0.1.0.tgz", + "integrity": "sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw==", + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/bundle-require": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-5.1.0.tgz", + "integrity": "sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "load-tsconfig": "^0.2.3" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "peerDependencies": { + "esbuild": ">=0.18" + } + }, + "node_modules/c12": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz", + "integrity": "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "chokidar": "^4.0.3", + "confbox": "^0.2.2", + "defu": "^6.1.4", + "dotenv": "^16.6.1", + "exsolve": "^1.0.7", + "giget": "^2.0.0", + "jiti": "^2.4.2", + "ohash": "^2.0.11", + "pathe": "^2.0.3", + "perfect-debounce": "^1.0.0", + "pkg-types": "^2.2.0", + "rc9": "^2.1.2" + }, + "peerDependencies": { + "magicast": "^0.3.5" + }, + "peerDependenciesMeta": { + "magicast": { + "optional": true + } + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/citty": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz", + "integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "consola": "^3.2.3" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", + "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/concurrently": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz", + "integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "4.1.2", + "rxjs": "7.8.2", + "shell-quote": "1.8.3", + "supports-color": "8.1.1", + "tree-kill": "1.2.2", + "yargs": "17.7.2" + }, + "bin": { + "conc": "dist/bin/concurrently.js", + "concurrently": "dist/bin/concurrently.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" + } + }, + "node_modules/confbox": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", + "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/config-chain": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", + "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ini": "^1.3.4", + "proto-list": "~1.2.1" + } + }, + "node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/copy-anything": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz", + "integrity": "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==", + "license": "MIT", + "dependencies": { + "is-what": "^5.2.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-render": { + "version": "0.15.14", + "resolved": "https://registry.npmjs.org/css-render/-/css-render-0.15.14.tgz", + "integrity": "sha512-9nF4PdUle+5ta4W5SyZdLCCmFd37uVimSjg1evcTqKJCyvCEEj12WKzOSBNak6r4im4J4iYXKH1OWpUV5LBYFg==", + "license": "MIT", + "dependencies": { + "@emotion/hash": "~0.8.0", + "csstype": "~3.0.5" + } + }, + "node_modules/css-render/node_modules/csstype": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.11.tgz", + "integrity": "sha512-sa6P2wJ+CAbgyy4KFssIb/JNMLxFvKF1pCYCSXS8ZMuqZnMsrxqI2E5sPyoTpxoPU/gVZMzr2zjOfg8GIZOMsw==", + "license": "MIT" + }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "node_modules/date-fns-tz": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/date-fns-tz/-/date-fns-tz-3.2.0.tgz", + "integrity": "sha512-sg8HqoTEulcbbbVXeg84u5UnlsQa8GS5QXMqjjYIhS4abEVVKIUwe0/l/UhrZdKaL/W5eWZNlbTeEIiOXTcsBQ==", + "license": "MIT", + "peerDependencies": { + "date-fns": "^3.0.0 || ^4.0.0" + } + }, + "node_modules/dayjs": { + "version": "1.11.20", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz", + "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", + "license": "MIT" + }, + "node_modules/de-indent": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz", + "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/deepmerge-ts": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", + "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==", + "devOptional": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/defu": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", + "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/destr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", + "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "devOptional": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/echarts": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/echarts/-/echarts-5.6.0.tgz", + "integrity": "sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "2.3.0", + "zrender": "5.6.1" + } + }, + "node_modules/echarts/node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" + }, + "node_modules/editorconfig": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.7.tgz", + "integrity": "sha512-e0GOtq/aTQhVdNyDU9e02+wz9oDDM+SIOQxWME2QRjzRX5yyLAuHDE+0aE8vHb9XRC8XD37eO2u57+F09JqFhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@one-ini/wasm": "0.1.1", + "commander": "^10.0.0", + "minimatch": "^9.0.1", + "semver": "^7.5.3" + }, + "bin": { + "editorconfig": "bin/editorconfig" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/effect": { + "version": "3.21.0", + "resolved": "https://registry.npmjs.org/effect/-/effect-3.21.0.tgz", + "integrity": "sha512-PPN80qRokCd1f015IANNhrwOnLO7GrrMQfk4/lnZRE/8j7UPWrNNjPV0uBrZutI/nHzernbW+J0hdqQysHiSnQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "fast-check": "^3.23.1" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/empathic": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz", + "integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/evtd": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/evtd/-/evtd-0.2.4.tgz", + "integrity": "sha512-qaeGN5bx63s/AXgQo8gj6fBkxge+OoLddLniox5qtLAEY5HSnuSlISXVPxnSae1dWblvTh4/HoMIB+mbMsvZzw==", + "license": "MIT" + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/exsolve": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", + "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/fast-check": { + "version": "3.23.2", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", + "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==", + "devOptional": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^6.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/fast-decode-uri-component": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", + "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-json-stringify": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-6.3.0.tgz", + "integrity": "sha512-oRCntNDY/329HJPlmdNLIdogNtt6Vyjb1WuT01Soss3slIdyUp8kAcDU3saQTOquEK8KFVfwIIF7FebxUAu+yA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/merge-json-schemas": "^0.2.0", + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^3.0.0", + "json-schema-ref-resolver": "^3.0.0", + "rfdc": "^1.2.0" + } + }, + "node_modules/fast-querystring": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", + "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==", + "license": "MIT", + "dependencies": { + "fast-decode-uri-component": "^1.0.1" + } + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastify": { + "version": "5.8.4", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.8.4.tgz", + "integrity": "sha512-sa42J1xylbBAYUWALSBoyXKPDUvM3OoNOibIefA+Oha57FryXKKCZarA1iDntOCWp3O35voZLuDg2mdODXtPzQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/ajv-compiler": "^4.0.5", + "@fastify/error": "^4.0.0", + "@fastify/fast-json-stringify-compiler": "^5.0.0", + "@fastify/proxy-addr": "^5.0.0", + "abstract-logging": "^2.0.1", + "avvio": "^9.0.0", + "fast-json-stringify": "^6.0.0", + "find-my-way": "^9.0.0", + "light-my-request": "^6.0.0", + "pino": "^9.14.0 || ^10.1.0", + "process-warning": "^5.0.0", + "rfdc": "^1.3.1", + "secure-json-parse": "^4.0.0", + "semver": "^7.6.0", + "toad-cache": "^3.7.0" + } + }, + "node_modules/fastify-plugin": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.1.0.tgz", + "integrity": "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/find-my-way": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.5.0.tgz", + "integrity": "sha512-VW2RfnmscZO5KgBY5XVyKREMW5nMZcxDy+buTOsL+zIPnBlbKm+00sgzoQzq1EVh4aALZLfKdwv6atBGcjvjrQ==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-querystring": "^1.0.0", + "safe-regex2": "^5.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/fix-dts-default-cjs-exports": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fix-dts-default-cjs-exports/-/fix-dts-default-cjs-exports-1.0.1.tgz", + "integrity": "sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.17", + "mlly": "^1.7.4", + "rollup": "^4.34.8" + } + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.7", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz", + "integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/giget": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz", + "integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "citty": "^0.1.6", + "consola": "^3.4.0", + "defu": "^6.1.4", + "node-fetch-native": "^1.6.6", + "nypm": "^0.6.0", + "pathe": "^2.0.3" + }, + "bin": { + "giget": "dist/cli.mjs" + } + }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/highlight.js": { + "version": "11.11.1", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", + "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/hookable": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", + "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", + "license": "MIT" + }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/idb-keyval": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.2.tgz", + "integrity": "sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg==", + "license": "Apache-2.0" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz", + "integrity": "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-url": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz", + "integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==", + "license": "MIT" + }, + "node_modules/is-what": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz", + "integrity": "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "devOptional": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/joycon": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", + "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/js-beautify": { + "version": "1.15.4", + "resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.15.4.tgz", + "integrity": "sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "config-chain": "^1.1.13", + "editorconfig": "^1.0.4", + "glob": "^10.4.2", + "js-cookie": "^3.0.5", + "nopt": "^7.2.1" + }, + "bin": { + "css-beautify": "js/bin/css-beautify.js", + "html-beautify": "js/bin/html-beautify.js", + "js-beautify": "js/bin/js-beautify.js" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/js-cookie": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", + "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/json-schema-ref-resolver": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz", + "integrity": "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/light-my-request": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz", + "integrity": "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause", + "dependencies": { + "cookie": "^1.0.1", + "process-warning": "^4.0.0", + "set-cookie-parser": "^2.6.0" + } + }, + "node_modules/light-my-request/node_modules/process-warning": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.1.tgz", + "integrity": "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/load-tsconfig": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/load-tsconfig/-/load-tsconfig-0.2.5.tgz", + "integrity": "sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "license": "MIT" + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "license": "MIT" + }, + "node_modules/mlly": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", + "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" + } + }, + "node_modules/mlly/node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/mlly/node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/mnemonist": { + "version": "0.40.0", + "resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.40.0.tgz", + "integrity": "sha512-kdd8AFNig2AD5Rkih7EPCXhu/iMvwevQFX/uEiGhZyPZi7fHqOoF4V4kHLpCfysxXMgQ4B52kdPMCwARshKvEg==", + "license": "MIT", + "dependencies": { + "obliterator": "^2.0.4" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/naive-ui": { + "version": "2.44.1", + "resolved": "https://registry.npmjs.org/naive-ui/-/naive-ui-2.44.1.tgz", + "integrity": "sha512-reo8Esw0p58liZwbUutC7meW24Xbn3EwNv91zReWKm2W4JPu+zfgJRn/F7aO0BFmvN+h2brA2M5lRvYqLq4kuA==", + "license": "MIT", + "dependencies": { + "@css-render/plugin-bem": "^0.15.14", + "@css-render/vue3-ssr": "^0.15.14", + "@types/lodash": "^4.17.20", + "@types/lodash-es": "^4.17.12", + "async-validator": "^4.2.5", + "css-render": "^0.15.14", + "csstype": "^3.1.3", + "date-fns": "^4.1.0", + "date-fns-tz": "^3.2.0", + "evtd": "^0.2.4", + "highlight.js": "^11.8.0", + "lodash": "^4.17.21", + "lodash-es": "^4.17.21", + "seemly": "^0.3.10", + "treemate": "^0.3.11", + "vdirs": "^0.1.8", + "vooks": "^0.2.12", + "vueuc": "^0.4.65" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "vue": "^3.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-cron": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz", + "integrity": "sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==", + "license": "ISC", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-fetch-native": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", + "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/node-fetch/node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/node-fetch/node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/node-fetch/node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/nodemailer": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.5.tgz", + "integrity": "sha512-0PF8Yb1yZuQfQbq+5/pZJrtF6WQcjTd5/S4JOHs9PGFxuTqoB/icwuB44pOdURHJbRKX1PPoJZtY7R4VUoCC8w==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/nopt": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-7.2.1.tgz", + "integrity": "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^2.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/nwsapi": { + "version": "2.2.23", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz", + "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/nypm": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.5.tgz", + "integrity": "sha512-K6AJy1GMVyfyMXRVB88700BJqNUkByijGJM8kEHpLdcAt+vSQAVfkWWHYzuRXHSY6xA2sNc5RjTj0p9rE2izVQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "citty": "^0.2.0", + "pathe": "^2.0.3", + "tinyexec": "^1.0.2" + }, + "bin": { + "nypm": "dist/cli.mjs" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/nypm/node_modules/citty": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/citty/-/citty-0.2.2.tgz", + "integrity": "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/obliterator": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.5.tgz", + "integrity": "sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==", + "license": "MIT" + }, + "node_modules/ohash": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", + "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/opencollective-postinstall": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/opencollective-postinstall/-/opencollective-postinstall-2.0.3.tgz", + "integrity": "sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q==", + "license": "MIT", + "bin": { + "opencollective-postinstall": "index.js" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/perfect-debounce": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pinia": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz", + "integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^7.7.7" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.5.0", + "vue": "^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/pinia/node_modules/@vue/devtools-api": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.9.tgz", + "integrity": "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==", + "license": "MIT", + "dependencies": { + "@vue/devtools-kit": "^7.7.9" + } + }, + "node_modules/pino": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", + "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-types": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz", + "integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.2.2", + "exsolve": "^1.0.7", + "pathe": "^2.0.3" + } + }, + "node_modules/postcss": { + "version": "8.5.9", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz", + "integrity": "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/prisma": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.3.tgz", + "integrity": "sha512-++ZJ0ijLrDJF6hNB4t4uxg2br3fC4H9Yc9tcbjr2fcNFP3rh/SBNrAgjhsqBU4Ght8JPrVofG/ZkXfnSfnYsFg==", + "devOptional": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/config": "6.19.3", + "@prisma/engines": "6.19.3" + }, + "bin": { + "prisma": "build/index.js" + }, + "engines": { + "node": ">=18.18" + }, + "peerDependencies": { + "typescript": ">=5.1.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/proto-list": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", + "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==", + "dev": true, + "license": "ISC" + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/pure-rand": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "devOptional": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, + "node_modules/rc9": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz", + "integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "defu": "^6.1.4", + "destr": "^2.0.3" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "license": "MIT" + }, + "node_modules/remove-accents": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/remove-accents/-/remove-accents-0.5.0.tgz", + "integrity": "sha512-8g3/Otx1eJaVD12e31UbJj1YzdtVvzH85HV7t+9MJYk/u3XmkOUJ5Ys9wQrf9PCPK8+xn4ymzqYCiZl6QWKn+A==", + "license": "MIT" + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/ret": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", + "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", + "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.1", + "@rollup/rollup-android-arm64": "4.60.1", + "@rollup/rollup-darwin-arm64": "4.60.1", + "@rollup/rollup-darwin-x64": "4.60.1", + "@rollup/rollup-freebsd-arm64": "4.60.1", + "@rollup/rollup-freebsd-x64": "4.60.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", + "@rollup/rollup-linux-arm-musleabihf": "4.60.1", + "@rollup/rollup-linux-arm64-gnu": "4.60.1", + "@rollup/rollup-linux-arm64-musl": "4.60.1", + "@rollup/rollup-linux-loong64-gnu": "4.60.1", + "@rollup/rollup-linux-loong64-musl": "4.60.1", + "@rollup/rollup-linux-ppc64-gnu": "4.60.1", + "@rollup/rollup-linux-ppc64-musl": "4.60.1", + "@rollup/rollup-linux-riscv64-gnu": "4.60.1", + "@rollup/rollup-linux-riscv64-musl": "4.60.1", + "@rollup/rollup-linux-s390x-gnu": "4.60.1", + "@rollup/rollup-linux-x64-gnu": "4.60.1", + "@rollup/rollup-linux-x64-musl": "4.60.1", + "@rollup/rollup-openbsd-x64": "4.60.1", + "@rollup/rollup-openharmony-arm64": "4.60.1", + "@rollup/rollup-win32-arm64-msvc": "4.60.1", + "@rollup/rollup-win32-ia32-msvc": "4.60.1", + "@rollup/rollup-win32-x64-gnu": "4.60.1", + "@rollup/rollup-win32-x64-msvc": "4.60.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/safe-regex2": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.0.tgz", + "integrity": "sha512-pNHAuBW7TrcleFHsxBr5QMi/Iyp0ENjUKz7GCcX1UO7cMh+NmVK6HxQckNL1tJp1XAJVjG6B8OKIPqodqj9rtw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ret": "~0.5.0" + }, + "bin": { + "safe-regex2": "bin/safe-regex2.js" + } + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/secure-json-parse": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", + "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/seemly": { + "version": "0.3.10", + "resolved": "https://registry.npmjs.org/seemly/-/seemly-0.3.10.tgz", + "integrity": "sha512-2+SMxtG1PcsL0uyhkumlOU6Qo9TAQ/WyH7tthnPIOQB05/12jz9naq6GZ6iZ6ApVsO3rr2gsnTf3++OV63kE1Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/shell-quote": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", + "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/speakingurl": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz", + "integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/sucrase/node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/superjson": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz", + "integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==", + "license": "MIT", + "dependencies": { + "copy-anything": "^4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tesseract.js": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/tesseract.js/-/tesseract.js-7.0.0.tgz", + "integrity": "sha512-exPBkd+z+wM1BuMkx/Bjv43OeLBxhL5kKWsz/9JY+DXcXdiBjiAch0V49QR3oAJqCaL5qURE0vx9Eo+G5YE7mA==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "bmp-js": "^0.1.0", + "idb-keyval": "^6.2.0", + "is-url": "^1.2.4", + "node-fetch": "^2.6.9", + "opencollective-postinstall": "^2.0.3", + "regenerator-runtime": "^0.13.3", + "tesseract.js-core": "^7.0.0", + "wasm-feature-detect": "^1.8.0", + "zlibjs": "^0.3.1" + } + }, + "node_modules/tesseract.js-core": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/tesseract.js-core/-/tesseract.js-core-7.0.0.tgz", + "integrity": "sha512-WnNH518NzmbSq9zgTPeoF8c+xmilS8rFIl1YKbk/ptuuc7p6cLNELNuPAzcmsYw450ca6bLa8j3t0VAtq435Vw==", + "license": "Apache-2.0" + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/thread-stream": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz", + "integrity": "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==", + "license": "MIT", + "dependencies": { + "real-require": "^0.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz", + "integrity": "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/toad-cache": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.0.tgz", + "integrity": "sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/treemate": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/treemate/-/treemate-0.3.11.tgz", + "integrity": "sha512-M8RGFoKtZ8dF+iwJfAJTOH/SM4KluKOKRJpjCMhI8bG3qB74zrFoArKZ62ll0Fr3mqkMJiQOmWYkdYgDeITYQg==", + "license": "MIT" + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/tsup": { + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/tsup/-/tsup-8.5.1.tgz", + "integrity": "sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==", + "dev": true, + "license": "MIT", + "dependencies": { + "bundle-require": "^5.1.0", + "cac": "^6.7.14", + "chokidar": "^4.0.3", + "consola": "^3.4.0", + "debug": "^4.4.0", + "esbuild": "^0.27.0", + "fix-dts-default-cjs-exports": "^1.0.0", + "joycon": "^3.1.1", + "picocolors": "^1.1.1", + "postcss-load-config": "^6.0.1", + "resolve-from": "^5.0.0", + "rollup": "^4.34.8", + "source-map": "^0.7.6", + "sucrase": "^3.35.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.11", + "tree-kill": "^1.2.2" + }, + "bin": { + "tsup": "dist/cli-default.js", + "tsup-node": "dist/cli-node.js" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@microsoft/api-extractor": "^7.36.0", + "@swc/core": "^1", + "postcss": "^8.4.12", + "typescript": ">=4.5.0" + }, + "peerDependenciesMeta": { + "@microsoft/api-extractor": { + "optional": true + }, + "@swc/core": { + "optional": true + }, + "postcss": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/tsup/node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ufo": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz", + "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vdirs": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/vdirs/-/vdirs-0.1.8.tgz", + "integrity": "sha512-H9V1zGRLQZg9b+GdMk8MXDN2Lva0zx72MPahDKc30v+DtwKjfyOSXWRIX4t2mhDubM1H09gPhWeth/BJWPHGUw==", + "license": "MIT", + "dependencies": { + "evtd": "^0.2.2" + }, + "peerDependencies": { + "vue": "^3.0.11" + } + }, + "node_modules/vite": { + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", + "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/vitest": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/vooks": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/vooks/-/vooks-0.2.12.tgz", + "integrity": "sha512-iox0I3RZzxtKlcgYaStQYKEzWWGAduMmq+jS7OrNdQo1FgGfPMubGL3uGHOU9n97NIvfFDBGnpSvkWyb/NSn/Q==", + "license": "MIT", + "dependencies": { + "evtd": "^0.2.2" + }, + "peerDependencies": { + "vue": "^3.0.0" + } + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.32.tgz", + "integrity": "sha512-vM4z4Q9tTafVfMAK7IVzmxg34rSzTFMyIe0UUEijUCkn9+23lj0WRfA83dg7eQZIUlgOSGrkViIaCfqSAUXsMw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.32", + "@vue/compiler-sfc": "3.5.32", + "@vue/runtime-dom": "3.5.32", + "@vue/server-renderer": "3.5.32", + "@vue/shared": "3.5.32" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-component-type-helpers": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-2.2.12.tgz", + "integrity": "sha512-YbGqHZ5/eW4SnkPNR44mKVc6ZKQoRs/Rux1sxC6rdwXb4qpbOSYfDr9DsTHolOTGmIKgM9j141mZbBeg05R1pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/vue-echarts": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/vue-echarts/-/vue-echarts-7.0.3.tgz", + "integrity": "sha512-/jSxNwOsw5+dYAUcwSfkLwKPuzTQ0Cepz1LxCOpj2QcHrrmUa/Ql0eQqMmc1rTPQVrh2JQ29n2dhq75ZcHvRDw==", + "license": "MIT", + "dependencies": { + "vue-demi": "^0.13.11" + }, + "peerDependencies": { + "@vue/runtime-core": "^3.0.0", + "echarts": "^5.5.1", + "vue": "^2.7.0 || ^3.1.1" + }, + "peerDependenciesMeta": { + "@vue/runtime-core": { + "optional": true + } + } + }, + "node_modules/vue-echarts/node_modules/vue-demi": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.13.11.tgz", + "integrity": "sha512-IR8HoEEGM65YY3ZJYAjMlKygDQn25D5ajNFNoKh9RSDMQtlzCxtfQjdQgv9jjK+m3377SsJXY8ysq8kLCZL25A==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/vue-tsc": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-2.2.12.tgz", + "integrity": "sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "2.4.15", + "@vue/language-core": "2.2.12" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + }, + "node_modules/vueuc": { + "version": "0.4.65", + "resolved": "https://registry.npmjs.org/vueuc/-/vueuc-0.4.65.tgz", + "integrity": "sha512-lXuMl+8gsBmruudfxnMF9HW4be8rFziylXFu1VHVNbLVhRTXXV4njvpRuJapD/8q+oFEMSfQMH16E/85VoWRyQ==", + "license": "MIT", + "dependencies": { + "@css-render/vue3-ssr": "^0.15.10", + "@juggle/resize-observer": "^3.3.1", + "css-render": "^0.15.10", + "evtd": "^0.2.4", + "seemly": "^0.3.6", + "vdirs": "^0.1.4", + "vooks": "^0.2.4" + }, + "peerDependencies": { + "vue": "^3.0.11" + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/wasm-feature-detect": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/wasm-feature-detect/-/wasm-feature-detect-1.8.0.tgz", + "integrity": "sha512-zksaLKM2fVlnB5jQQDqKXXwYHLQUVH9es+5TOOHwGOVJOCeRBCiPjwSg+3tN2AdTCzjgli4jijCH290kXb/zWQ==", + "license": "Apache-2.0" + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/zlibjs": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/zlibjs/-/zlibjs-0.3.1.tgz", + "integrity": "sha512-+J9RrgTKOmlxFSDHo0pI1xM6BLVUv+o0ZT9ANtCxGkjIVCCUdx9alUF8Gm+dGLKbkkkidWIHFDZHDMpfITt4+w==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zrender": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/zrender/-/zrender-5.6.1.tgz", + "integrity": "sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==", + "license": "BSD-3-Clause", + "dependencies": { + "tslib": "2.3.0" + } + }, + "node_modules/zrender/node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" + }, + "packages/shared": { + "name": "@subtracker/shared", + "version": "0.1.0", + "dependencies": { + "zod": "^3.24.3" + }, + "devDependencies": { + "tsup": "^8.4.0", + "vitest": "^3.1.1" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..876867f --- /dev/null +++ b/package.json @@ -0,0 +1,24 @@ +{ + "name": "subtracker", + "version": "0.1.0", + "private": true, + "description": "Subscription tracker with currency conversion, reminder webhooks, statistics and calendar.", + "workspaces": [ + "apps/*", + "packages/*" + ], + "scripts": { + "dev": "node scripts/dev.mjs", + "build": "npm run build -w packages/shared && npm run build -w apps/api && npm run build -w apps/web", + "test": "npm run test -w packages/shared && npm run test -w apps/api && npm run test -w apps/web", + "lint": "npm run lint -w apps/api && npm run lint -w apps/web", + "prisma:generate": "npm run prisma:generate -w apps/api", + "prisma:migrate": "npm run prisma:migrate -w apps/api", + "prisma:push": "npm run prisma:push -w apps/api", + "prisma:seed": "npm run prisma:seed -w apps/api" + }, + "devDependencies": { + "concurrently": "^9.1.2", + "typescript": "^5.8.3" + } +} diff --git a/packages/shared/package.json b/packages/shared/package.json new file mode 100644 index 0000000..ece6f09 --- /dev/null +++ b/packages/shared/package.json @@ -0,0 +1,24 @@ +{ + "name": "@subtracker/shared", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "src/index.ts", + "module": "src/index.ts", + "types": "src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "build": "tsup src/index.ts --format esm,cjs --dts", + "test": "vitest run", + "lint": "tsc --noEmit" + }, + "dependencies": { + "zod": "^3.24.3" + }, + "devDependencies": { + "tsup": "^8.4.0", + "vitest": "^3.1.1" + } +} diff --git a/packages/shared/src/index.test.ts b/packages/shared/src/index.test.ts new file mode 100644 index 0000000..b8741a2 --- /dev/null +++ b/packages/shared/src/index.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest' +import { CreateSubscriptionSchema } from '../src/index' + +describe('shared schema', () => { + it('should validate create subscription payload', () => { + const parsed = CreateSubscriptionSchema.parse({ + name: 'GitHub', + amount: 10, + currency: 'usd', + billingIntervalUnit: 'month', + startDate: '2026-04-01', + nextRenewalDate: '2026-05-01' + }) + + expect(parsed.currency).toBe('USD') + expect(parsed.billingIntervalCount).toBe(1) + }) +}) diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts new file mode 100644 index 0000000..d992492 --- /dev/null +++ b/packages/shared/src/index.ts @@ -0,0 +1,219 @@ +import { z } from 'zod' + +export const SubscriptionStatusSchema = z.enum(['active', 'paused', 'cancelled', 'expired']) +export const BillingIntervalUnitSchema = z.enum(['day', 'week', 'month', 'quarter', 'year']) +export const WebhookEventTypeSchema = z.enum([ + 'subscription.reminder_due', + 'subscription.overdue', + 'subscription.renewed', + 'exchange-rate.stale' +]) + +export const CategorySchema = z.object({ + id: z.string().cuid().optional(), + name: z.string().min(1).max(100), + color: z.string().min(4).max(20).default('#3b82f6'), + icon: z.string().max(50).default('apps-outline'), + sortOrder: z.number().int().default(0) +}) + +const OptionalMoneySchema = z.number().nonnegative().nullable().optional() + +export const SubscriptionLogoSchema = z.object({ + websiteUrl: z.string().url().nullable().optional(), + logoUrl: z.string().max(500).nullable().optional(), + logoSource: z.string().max(100).nullable().optional() +}) + +export const CreateSubscriptionSchema = z + .object({ + name: z.string().min(1).max(150), + categoryId: z.string().cuid().nullable().optional(), + description: z.string().max(500).default(''), + amount: z.number().positive(), + currency: z.string().length(3).transform((v) => v.toUpperCase()), + billingIntervalCount: z.number().int().positive().default(1), + billingIntervalUnit: BillingIntervalUnitSchema, + startDate: z.string().date(), + nextRenewalDate: z.string().date(), + notifyDaysBefore: z.number().int().min(0).max(365).default(3), + webhookEnabled: z.boolean().default(true), + notes: z.string().max(1000).default('') + }) + .merge(SubscriptionLogoSchema) + +export const UpdateSubscriptionSchema = CreateSubscriptionSchema.partial().extend({ + status: SubscriptionStatusSchema.optional() +}) + +export const RenewSubscriptionSchema = z.object({ + paidAt: z.string().date().optional(), + amount: z.number().positive().optional(), + currency: z.string().length(3).optional() +}) + +export const CreateWebhookEndpointSchema = z.object({ + name: z.string().min(1).max(100), + url: z.string().url(), + secret: z.string().min(3).max(200), + enabled: z.boolean().default(true), + events: z.array(WebhookEventTypeSchema).nonempty() +}) + +export const UpdateWebhookEndpointSchema = CreateWebhookEndpointSchema.partial() + +export const EmailConfigSchema = z.object({ + host: z.string().max(200).default(''), + port: z.number().int().min(1).max(65535).default(587), + secure: z.boolean().default(false), + username: z.string().max(200).default(''), + password: z.string().max(500).default(''), + from: z.string().max(200).default(''), + to: z.string().max(500).default('') +}) + +export const PushPlusConfigSchema = z.object({ + token: z.string().max(200).default(''), + topic: z.string().max(100).default('') +}) + +export const AiConfigSchema = z.object({ + enabled: z.boolean().default(false), + providerName: z.string().max(100).default('DeepSeek'), + baseUrl: z.string().url().default('https://api.deepseek.com'), + apiKey: z.string().max(500).default(''), + model: z.string().max(100).default('deepseek-chat'), + timeoutMs: z.number().int().min(5000).max(120000).default(30000), + promptTemplate: z.string().max(5000).default('') +}) + +export const SettingsSchema = z.object({ + baseCurrency: z.string().length(3).default('CNY').transform((v) => v.toUpperCase()), + defaultNotifyDays: z.number().int().min(0).max(365).default(3), + monthlyBudgetBase: OptionalMoneySchema, + yearlyBudgetBase: OptionalMoneySchema, + enableCategoryBudgets: z.boolean().default(false), + categoryBudgets: z.record(z.string(), z.number().nonnegative()).default({}), + emailNotificationsEnabled: z.boolean().default(false), + pushplusNotificationsEnabled: z.boolean().default(false), + emailConfig: EmailConfigSchema.default({}), + pushplusConfig: PushPlusConfigSchema.default({}), + aiConfig: AiConfigSchema.default({}) +}) + +export const LoginSchema = z.object({ + username: z.string().min(1).max(100), + password: z.string().min(1).max(200) +}) + +export const ChangeCredentialsSchema = z.object({ + oldUsername: z.string().min(1).max(100), + oldPassword: z.string().min(1).max(200), + newUsername: z.string().min(1).max(100), + newPassword: z.string().min(4).max(200) +}) + +export const LogoSearchSchema = z.object({ + name: z.string().min(1).max(150), + websiteUrl: z.string().url().optional(), + categoryName: z.string().max(100).optional() +}) + +export const LogoUploadSchema = z.object({ + filename: z.string().min(1).max(200), + contentType: z.string().min(1).max(100), + base64: z.string().min(1) +}) + +export const AiRecognizeSubscriptionSchema = z.object({ + text: z.string().max(8000).optional(), + imageBase64: z.string().max(10_000_000).optional(), + filename: z.string().max(200).optional(), + mimeType: z.string().max(100).optional() +}) + +export type SubscriptionStatus = z.infer +export type BillingIntervalUnit = z.infer +export type WebhookEventType = z.infer +export type CreateSubscriptionInput = z.infer +export type UpdateSubscriptionInput = z.infer +export type RenewSubscriptionInput = z.infer +export type CreateWebhookEndpointInput = z.infer +export type UpdateWebhookEndpointInput = z.infer +export type SettingsInput = z.infer +export type LoginInput = z.infer +export type ChangeCredentialsInput = z.infer +export type EmailConfigInput = z.infer +export type PushPlusConfigInput = z.infer +export type AiConfigInput = z.infer +export type LogoSearchInput = z.infer +export type LogoUploadInput = z.infer +export type AiRecognizeSubscriptionInput = z.infer + +export interface MoneyDto { + amount: number + currency: string +} + +export interface ExchangeRateSnapshotDto { + baseCurrency: string + rates: Record + fetchedAt: string + provider: string + isStale: boolean +} + +export interface LogoSearchResultDto { + label: string + logoUrl: string + source: string + websiteUrl?: string + width?: number + height?: number + isLocal?: boolean + usageCount?: number + filename?: string + updatedAt?: string + relatedSubscriptionNames?: string[] +} + +export interface AiRecognitionResultDto { + name?: string + description?: string + amount?: number + currency?: string + billingIntervalCount?: number + billingIntervalUnit?: BillingIntervalUnit + startDate?: string + nextRenewalDate?: string + notifyDaysBefore?: number + websiteUrl?: string + notes?: string + confidence?: number + rawText?: string +} + +export interface DashboardOverview { + activeSubscriptions: number + upcoming7Days: number + upcoming30Days: number + monthlyEstimatedBase: number + yearlyEstimatedBase: number + monthlyBudgetBase?: number | null + yearlyBudgetBase?: number | null + monthlyBudgetUsageRatio?: number | null + yearlyBudgetUsageRatio?: number | null + categorySpend: Array<{ name: string; value: number }> + monthlyTrend: Array<{ month: string; amount: number }> + categoryBudgetUsage?: Array<{ categoryId: string; name: string; budget: number; spent: number; ratio: number }> +} + +export interface CalendarEventDto { + id: string + title: string + date: string + currency: string + amount: number + convertedAmount: number + status: SubscriptionStatus +} diff --git a/packages/shared/tsconfig.json b/packages/shared/tsconfig.json new file mode 100644 index 0000000..cc8959e --- /dev/null +++ b/packages/shared/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "declaration": true, + "declarationMap": true + }, + "include": ["src/**/*.ts"] +} diff --git a/scripts/dev.mjs b/scripts/dev.mjs new file mode 100644 index 0000000..c78e5d3 --- /dev/null +++ b/scripts/dev.mjs @@ -0,0 +1,78 @@ +import { spawn } from 'node:child_process' + +const npmExecPath = process.env.npm_execpath + +if (!npmExecPath) { + console.error('未找到 npm_execpath,无法启动 workspace dev 脚本。') + process.exit(1) +} + +const children = [] +let shuttingDown = false + +function startWorkspace(name, color, args) { + const child = spawn(process.execPath, [npmExecPath, ...args], { + cwd: process.cwd(), + stdio: ['inherit', 'pipe', 'pipe'], + env: { + ...process.env, + FORCE_COLOR: '1' + } + }) + + child.stdout?.on('data', (chunk) => { + process.stdout.write(`${color}[${name}]\x1b[0m ${chunk}`) + }) + + child.stderr?.on('data', (chunk) => { + process.stderr.write(`${color}[${name}]\x1b[0m ${chunk}`) + }) + + child.on('exit', (code, signal) => { + if (!shuttingDown) { + if (signal) { + console.log(`[${name}] 已退出,signal=${signal}`) + } else { + console.log(`[${name}] 已退出,code=${code}`) + } + shutdown(code ?? 0) + } + }) + + children.push(child) +} + +function shutdown(exitCode = 0) { + if (shuttingDown) return + shuttingDown = true + + for (const child of children) { + if (!child.killed) { + child.kill('SIGINT') + } + } + + setTimeout(() => { + for (const child of children) { + if (!child.killed) { + child.kill('SIGTERM') + } + } + }, 1200) + + setTimeout(() => { + process.exit(exitCode) + }, 1800) +} + +process.on('SIGINT', () => { + console.log('\n正在关闭开发服务...') + shutdown(0) +}) + +process.on('SIGTERM', () => { + shutdown(0) +}) + +startWorkspace('api', '\x1b[36m', ['run', 'dev', '-w', 'apps/api']) +startWorkspace('web', '\x1b[35m', ['run', 'dev', '-w', 'apps/web']) diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 0000000..d39eb8d --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true + } +} +