From a98e12f2867a5bd172ea9501a022bc519b629276 Mon Sep 17 00:00:00 2001 From: Michael Dong Date: Thu, 5 Feb 2026 11:24:40 +0800 Subject: [PATCH] first commit --- .env.example | 11 + .gitignore | 12 + Makefile | 76 + backend/.dockerignore | 8 + backend/.gitignore | 8 + backend/Dockerfile | 44 + backend/package-lock.json | 1821 +++++++ backend/package.json | 33 + .../20260119102154_init/migration.sql | 203 + .../migration.sql | 2 + backend/prisma/migrations/migration_lock.toml | 3 + backend/prisma/schema.prisma | 176 + backend/src/app.ts | 39 + backend/src/db.ts | 3 + backend/src/index.ts | 11 + backend/src/middleware/auth.ts | 21 + backend/src/routes/auth.ts | 93 + backend/src/routes/invites.ts | 55 + backend/src/routes/me.ts | 53 + backend/src/routes/notifications.ts | 40 + backend/src/routes/reminderTasks.ts | 213 + backend/src/routes/todos.ts | 196 + backend/src/routes/users.ts | 24 + backend/src/services/bark.ts | 23 + backend/src/services/recurrence.ts | 51 + backend/src/worker.ts | 255 + backend/tsconfig.json | 12 + backend_rust/.gitignore | 1 + backend_rust/Cargo.lock | 4444 ++++++++++++++++ backend_rust/Cargo.toml | 34 + backend_rust/Dockerfile | 46 + backend_rust/README.md | 0 backend_rust/migration/Cargo.toml | 22 + backend_rust/migration/README.md | 47 + backend_rust/migration/src/lib.rs | 42 + .../src/m20220101_000001_create_user.rs | 74 + .../src/m20220101_000002_create_enums.rs | 137 + .../src/m20220101_000003_create_invite.rs | 102 + ...m20220101_000004_create_recurrence_rule.rs | 75 + .../src/m20220101_000005_create_todo.rs | 102 + .../m20220101_000006_create_reminder_task.rs | 111 + ...1_000007_create_reminder_task_recipient.rs | 87 + ...m20220101_000008_create_reminder_offset.rs | 87 + .../m20220101_000009_create_notification.rs | 141 + .../m20220101_000010_create_delivery_log.rs | 94 + .../src/m20260128_000011_modify_todo.rs | 38 + .../src/m20260129_000012_add_bark_params.rs | 49 + ...60129_000013_add_notification_offset_id.rs | 37 + ...00014_convert_timestamps_to_timestamptz.rs | 143 + .../m20260129_000015_add_user_invite_id.rs | 86 + backend_rust/migration/src/main.rs | 6 + backend_rust/src/api/auth.rs | 161 + backend_rust/src/api/health.rs | 10 + backend_rust/src/api/invites.rs | 167 + backend_rust/src/api/me.rs | 216 + backend_rust/src/api/mod.rs | 8 + backend_rust/src/api/notifications.rs | 95 + backend_rust/src/api/reminder_tasks.rs | 451 ++ backend_rust/src/api/todos.rs | 432 ++ backend_rust/src/api/users.rs | 64 + backend_rust/src/app_data.rs | 71 + backend_rust/src/entity/delivery_log.rs | 41 + backend_rust/src/entity/invite.rs | 40 + backend_rust/src/entity/mod.rs | 14 + backend_rust/src/entity/notification.rs | 61 + backend_rust/src/entity/prelude.rs | 11 + backend_rust/src/entity/recurrence_rule.rs | 43 + backend_rust/src/entity/reminder_offset.rs | 31 + backend_rust/src/entity/reminder_task.rs | 68 + .../src/entity/reminder_task_recipient.rs | 49 + .../src/entity/sea_orm_active_enums.rs | 51 + backend_rust/src/entity/todo.rs | 56 + backend_rust/src/entity/user.rs | 79 + backend_rust/src/error.rs | 69 + backend_rust/src/lib.rs | 6 + backend_rust/src/main.rs | 64 + backend_rust/src/middleware/auth.rs | 76 + backend_rust/src/middleware/mod.rs | 1 + backend_rust/src/timer/mod.rs | 7 + backend_rust/src/timer/recurrence.rs | 207 + backend_rust/src/timer/time_wheel.rs | 395 ++ backend_rust/src/timer/worker.rs | 842 +++ .../6718b99c-eb5f-49dd-ae71-2ac763000eed.png | Bin 0 -> 13476 bytes docker-compose.prod.yml | 60 + docker-compose.yml | 54 + docs/spec.md | 151 + frontend/.dockerignore | 8 + frontend/.gitignore | 1 + frontend/Dockerfile | 45 + frontend/components.json | 22 + frontend/next-env.d.ts | 5 + frontend/next.config.js | 7 + frontend/package-lock.json | 2917 +++++++++++ frontend/package.json | 45 + frontend/postcss.config.js | 6 + frontend/src/app/globals.css | 182 + frontend/src/app/invites/page.tsx | 301 ++ frontend/src/app/layout.tsx | 20 + frontend/src/app/login/page.tsx | 81 + frontend/src/app/notifications/page.tsx | 94 + frontend/src/app/page.tsx | 23 + frontend/src/app/register/page.tsx | 91 + frontend/src/app/reminders/page.tsx | 467 ++ frontend/src/app/settings/page.tsx | 14 + frontend/src/app/todos/page.tsx | 301 ++ frontend/src/components/AppShell.tsx | 55 + frontend/src/components/AppSidebar.tsx | 105 + frontend/src/components/LanguageSwitcher.tsx | 57 + frontend/src/components/SettingsPanel.tsx | 204 + frontend/src/components/ui/alert-dialog.tsx | 117 + frontend/src/components/ui/avatar.tsx | 81 + frontend/src/components/ui/button.tsx | 57 + frontend/src/components/ui/calendar.tsx | 89 + frontend/src/components/ui/card.tsx | 52 + frontend/src/components/ui/checkbox.tsx | 26 + .../src/components/ui/datetime-picker.tsx | 161 + frontend/src/components/ui/dialog.tsx | 104 + frontend/src/components/ui/field.tsx | 244 + frontend/src/components/ui/form.tsx | 178 + frontend/src/components/ui/input.tsx | 22 + frontend/src/components/ui/label.tsx | 26 + frontend/src/components/ui/popover.tsx | 33 + .../src/components/ui/recurrence-picker.tsx | 318 ++ frontend/src/components/ui/scroll-area.tsx | 48 + frontend/src/components/ui/select.tsx | 159 + frontend/src/components/ui/separator.tsx | 31 + frontend/src/components/ui/sidebar.tsx | 222 + frontend/src/components/ui/sonner.tsx | 31 + frontend/src/components/ui/users/page.tsx | 64 + frontend/src/lib/api.ts | 77 + frontend/src/lib/auth.ts | 19 + frontend/src/lib/i18n/context.tsx | 77 + frontend/src/lib/i18n/index.ts | 2 + frontend/src/lib/i18n/translations.ts | 431 ++ frontend/src/lib/notification-context.tsx | 46 + frontend/src/lib/user-context.tsx | 61 + frontend/src/lib/utils.ts | 6 + frontend/tailwind.config.ts | 68 + frontend/tsconfig.json | 40 + nginx/README.md | 141 + nginx/notify.conf | 81 + package-lock.json | 4628 +++++++++++++++++ package.json | 5 + texput.log | 21 + 144 files changed, 26459 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 backend/.dockerignore create mode 100644 backend/.gitignore create mode 100644 backend/Dockerfile create mode 100644 backend/package-lock.json create mode 100644 backend/package.json create mode 100644 backend/prisma/migrations/20260119102154_init/migration.sql create mode 100644 backend/prisma/migrations/20260122092137_add_user_avatar/migration.sql create mode 100644 backend/prisma/migrations/migration_lock.toml create mode 100644 backend/prisma/schema.prisma create mode 100644 backend/src/app.ts create mode 100644 backend/src/db.ts create mode 100644 backend/src/index.ts create mode 100644 backend/src/middleware/auth.ts create mode 100644 backend/src/routes/auth.ts create mode 100644 backend/src/routes/invites.ts create mode 100644 backend/src/routes/me.ts create mode 100644 backend/src/routes/notifications.ts create mode 100644 backend/src/routes/reminderTasks.ts create mode 100644 backend/src/routes/todos.ts create mode 100644 backend/src/routes/users.ts create mode 100644 backend/src/services/bark.ts create mode 100644 backend/src/services/recurrence.ts create mode 100644 backend/src/worker.ts create mode 100644 backend/tsconfig.json create mode 100644 backend_rust/.gitignore create mode 100644 backend_rust/Cargo.lock create mode 100644 backend_rust/Cargo.toml create mode 100644 backend_rust/Dockerfile create mode 100644 backend_rust/README.md create mode 100644 backend_rust/migration/Cargo.toml create mode 100644 backend_rust/migration/README.md create mode 100644 backend_rust/migration/src/lib.rs create mode 100644 backend_rust/migration/src/m20220101_000001_create_user.rs create mode 100644 backend_rust/migration/src/m20220101_000002_create_enums.rs create mode 100644 backend_rust/migration/src/m20220101_000003_create_invite.rs create mode 100644 backend_rust/migration/src/m20220101_000004_create_recurrence_rule.rs create mode 100644 backend_rust/migration/src/m20220101_000005_create_todo.rs create mode 100644 backend_rust/migration/src/m20220101_000006_create_reminder_task.rs create mode 100644 backend_rust/migration/src/m20220101_000007_create_reminder_task_recipient.rs create mode 100644 backend_rust/migration/src/m20220101_000008_create_reminder_offset.rs create mode 100644 backend_rust/migration/src/m20220101_000009_create_notification.rs create mode 100644 backend_rust/migration/src/m20220101_000010_create_delivery_log.rs create mode 100644 backend_rust/migration/src/m20260128_000011_modify_todo.rs create mode 100644 backend_rust/migration/src/m20260129_000012_add_bark_params.rs create mode 100644 backend_rust/migration/src/m20260129_000013_add_notification_offset_id.rs create mode 100644 backend_rust/migration/src/m20260129_000014_convert_timestamps_to_timestamptz.rs create mode 100644 backend_rust/migration/src/m20260129_000015_add_user_invite_id.rs create mode 100644 backend_rust/migration/src/main.rs create mode 100644 backend_rust/src/api/auth.rs create mode 100644 backend_rust/src/api/health.rs create mode 100644 backend_rust/src/api/invites.rs create mode 100644 backend_rust/src/api/me.rs create mode 100644 backend_rust/src/api/mod.rs create mode 100644 backend_rust/src/api/notifications.rs create mode 100644 backend_rust/src/api/reminder_tasks.rs create mode 100644 backend_rust/src/api/todos.rs create mode 100644 backend_rust/src/api/users.rs create mode 100644 backend_rust/src/app_data.rs create mode 100644 backend_rust/src/entity/delivery_log.rs create mode 100644 backend_rust/src/entity/invite.rs create mode 100644 backend_rust/src/entity/mod.rs create mode 100644 backend_rust/src/entity/notification.rs create mode 100644 backend_rust/src/entity/prelude.rs create mode 100644 backend_rust/src/entity/recurrence_rule.rs create mode 100644 backend_rust/src/entity/reminder_offset.rs create mode 100644 backend_rust/src/entity/reminder_task.rs create mode 100644 backend_rust/src/entity/reminder_task_recipient.rs create mode 100644 backend_rust/src/entity/sea_orm_active_enums.rs create mode 100644 backend_rust/src/entity/todo.rs create mode 100644 backend_rust/src/entity/user.rs create mode 100644 backend_rust/src/error.rs create mode 100644 backend_rust/src/lib.rs create mode 100644 backend_rust/src/main.rs create mode 100644 backend_rust/src/middleware/auth.rs create mode 100644 backend_rust/src/middleware/mod.rs create mode 100644 backend_rust/src/timer/mod.rs create mode 100644 backend_rust/src/timer/recurrence.rs create mode 100644 backend_rust/src/timer/time_wheel.rs create mode 100644 backend_rust/src/timer/worker.rs create mode 100644 backend_rust/uploads/avatars/6718b99c-eb5f-49dd-ae71-2ac763000eed.png create mode 100644 docker-compose.prod.yml create mode 100644 docker-compose.yml create mode 100644 docs/spec.md create mode 100644 frontend/.dockerignore create mode 100644 frontend/.gitignore create mode 100644 frontend/Dockerfile create mode 100644 frontend/components.json create mode 100644 frontend/next-env.d.ts create mode 100644 frontend/next.config.js create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/postcss.config.js create mode 100644 frontend/src/app/globals.css create mode 100644 frontend/src/app/invites/page.tsx create mode 100644 frontend/src/app/layout.tsx create mode 100644 frontend/src/app/login/page.tsx create mode 100644 frontend/src/app/notifications/page.tsx create mode 100644 frontend/src/app/page.tsx create mode 100644 frontend/src/app/register/page.tsx create mode 100644 frontend/src/app/reminders/page.tsx create mode 100644 frontend/src/app/settings/page.tsx create mode 100644 frontend/src/app/todos/page.tsx create mode 100644 frontend/src/components/AppShell.tsx create mode 100644 frontend/src/components/AppSidebar.tsx create mode 100644 frontend/src/components/LanguageSwitcher.tsx create mode 100644 frontend/src/components/SettingsPanel.tsx create mode 100644 frontend/src/components/ui/alert-dialog.tsx create mode 100644 frontend/src/components/ui/avatar.tsx create mode 100644 frontend/src/components/ui/button.tsx create mode 100644 frontend/src/components/ui/calendar.tsx create mode 100644 frontend/src/components/ui/card.tsx create mode 100644 frontend/src/components/ui/checkbox.tsx create mode 100644 frontend/src/components/ui/datetime-picker.tsx create mode 100644 frontend/src/components/ui/dialog.tsx create mode 100644 frontend/src/components/ui/field.tsx create mode 100644 frontend/src/components/ui/form.tsx create mode 100644 frontend/src/components/ui/input.tsx create mode 100644 frontend/src/components/ui/label.tsx create mode 100644 frontend/src/components/ui/popover.tsx create mode 100644 frontend/src/components/ui/recurrence-picker.tsx create mode 100644 frontend/src/components/ui/scroll-area.tsx create mode 100644 frontend/src/components/ui/select.tsx create mode 100644 frontend/src/components/ui/separator.tsx create mode 100644 frontend/src/components/ui/sidebar.tsx create mode 100644 frontend/src/components/ui/sonner.tsx create mode 100644 frontend/src/components/ui/users/page.tsx create mode 100644 frontend/src/lib/api.ts create mode 100644 frontend/src/lib/auth.ts create mode 100644 frontend/src/lib/i18n/context.tsx create mode 100644 frontend/src/lib/i18n/index.ts create mode 100644 frontend/src/lib/i18n/translations.ts create mode 100644 frontend/src/lib/notification-context.tsx create mode 100644 frontend/src/lib/user-context.tsx create mode 100644 frontend/src/lib/utils.ts create mode 100644 frontend/tailwind.config.ts create mode 100644 frontend/tsconfig.json create mode 100644 nginx/README.md create mode 100644 nginx/notify.conf create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 texput.log diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c0db7f1 --- /dev/null +++ b/.env.example @@ -0,0 +1,11 @@ +# 数据库配置 +POSTGRES_USER=notify +POSTGRES_PASSWORD=your_secure_password_here +POSTGRES_DB=notify + +# JWT 密钥(建议使用: openssl rand -base64 32) +JWT_SECRET=your_jwt_secret_here + +# 域名配置 +BASE_URL=https://notify.example.com +NEXT_PUBLIC_API_URL=https://notify.example.com diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7e0f6e2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +.cursor +.vscode +.idea +.DS_Store +.env +.env.local +.env.development.local +.env.test.local +.env.production.local +node_modules +dist +build \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..38dab24 --- /dev/null +++ b/Makefile @@ -0,0 +1,76 @@ +.PHONY: dev stop migrate migrate-deploy db-push db-studio dev-node + +ifneq (,$(wildcard .env)) +include .env +export +endif + +# Rust backend (default) +dev: + @echo "Starting Notify dev services (Rust backend)..." + @nohup sh -c 'cd backend_rust && DATABASE_URL="$${DATABASE_URL}" JWT_SECRET="$${JWT_SECRET:-dev-secret}" RUST_LOG="info" cargo run' > /tmp/notify-api.log 2>&1 & + @nohup sh -c 'cd frontend && PORT="$${FRONTEND_PORT:-3000}" NEXT_PUBLIC_API_BASE="$${NEXT_PUBLIC_API_BASE:-http://localhost:4000}" npm run dev' > /tmp/notify-web.log 2>&1 & + @echo "API log: /tmp/notify-api.log" + @echo "Web log: /tmp/notify-web.log" + +# Node.js backend (legacy) +dev-node: + @echo "Starting Notify dev services (Node.js backend)..." + @nohup sh -c 'cd backend && DATABASE_URL="$${DATABASE_URL}" JWT_SECRET="$${JWT_SECRET:-dev-secret}" PORT="$${PORT:-4000}" npm run dev' > /tmp/notify-api.log 2>&1 & + @nohup sh -c 'cd backend && DATABASE_URL="$${DATABASE_URL}" JWT_SECRET="$${JWT_SECRET:-dev-secret}" npm run worker' > /tmp/notify-worker.log 2>&1 & + @nohup sh -c 'cd frontend && PORT="$${FRONTEND_PORT:-3000}" NEXT_PUBLIC_API_BASE="$${NEXT_PUBLIC_API_BASE:-http://localhost:4000}" npm run dev' > /tmp/notify-web.log 2>&1 & + @echo "API log: /tmp/notify-api.log" + @echo "Worker log: /tmp/notify-worker.log" + @echo "Web log: /tmp/notify-web.log" + +stop: + @echo "Stopping Notify dev services..." + @pkill -f "target/debug/backend_rust" || true + @pkill -f "tsx watch src/index.ts" || true + @pkill -f "tsx src/worker.ts" || true + @pkill -f "next dev" || true + @echo "Stopped." + +# SeaORM migrations (Rust) +migrate: + @echo "Running SeaORM migration..." + cd backend_rust && DATABASE_URL="$${DATABASE_URL}" cargo run --package migration -- up + +migrate-down: + @echo "Rolling back last migration..." + cd backend_rust && DATABASE_URL="$${DATABASE_URL}" cargo run --package migration -- down + +migrate-fresh: + @echo "Resetting database and running all migrations..." + cd backend_rust && DATABASE_URL="$${DATABASE_URL}" cargo run --package migration -- fresh + +migrate-status: + @echo "Checking migration status..." + cd backend_rust && DATABASE_URL="$${DATABASE_URL}" cargo run --package migration -- status + +# Prisma migrations (Node.js legacy) +migrate-prisma: + @echo "Running Prisma migration (dev)..." + cd backend && npx prisma migrate dev + +migrate-prisma-deploy: + @echo "Running Prisma migration (deploy)..." + cd backend && npx prisma migrate deploy + +db-push: + @echo "Pushing schema to database..." + cd backend && npx prisma db push + +db-studio: + @echo "Opening Prisma Studio..." + cd backend && npx prisma studio + +# Build +build: + @echo "Building Rust backend..." + cd backend_rust && cargo build --release + +# Generate SeaORM entities from database +generate-entities: + @echo "Generating SeaORM entities..." + cd backend_rust && sea-orm-cli generate entity -o src/entity --with-serde both diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..497e54c --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,8 @@ +node_modules +dist +.env +.env.* +*.log +.git +.gitignore +README.md diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..47c29bd --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,8 @@ +node_modules +dist +build +.env +.env.local +.env.development.local +.env.test.local +.env.production.local \ No newline at end of file diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..1a4bfcd --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,44 @@ +# Build stage +FROM node:20-alpine AS builder + +WORKDIR /app + +# Copy package files +COPY package*.json ./ +COPY prisma ./prisma/ + +# Install dependencies +RUN npm ci + +# Generate Prisma client +RUN npx prisma generate + +# Copy source code +COPY . . + +# Build TypeScript +RUN npm run build + +# Production stage +FROM node:20-alpine AS runner + +WORKDIR /app + +ENV NODE_ENV=production + +# Copy package files and install production dependencies only +COPY package*.json ./ +COPY prisma ./prisma/ + +RUN npm ci --omit=dev + +# Generate Prisma client in production +RUN npx prisma generate + +# Copy built files from builder +COPY --from=builder /app/dist ./dist + +# Run migrations and start the app +EXPOSE 4000 + +CMD ["sh", "-c", "npx prisma migrate deploy && node dist/index.js"] diff --git a/backend/package-lock.json b/backend/package-lock.json new file mode 100644 index 0000000..9aa4c7b --- /dev/null +++ b/backend/package-lock.json @@ -0,0 +1,1821 @@ +{ + "name": "notify-backend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "notify-backend", + "version": "0.1.0", + "dependencies": { + "@prisma/client": "^5.13.0", + "bcryptjs": "^2.4.3", + "cors": "^2.8.5", + "dotenv": "^16.4.5", + "express": "^4.19.2", + "express-rate-limit": "^7.3.1", + "helmet": "^7.1.0", + "jsonwebtoken": "^9.0.2", + "zod": "^3.23.8" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/jsonwebtoken": "^9.0.6", + "@types/node": "^20.14.2", + "prisma": "^5.13.0", + "tsx": "^4.16.0", + "typescript": "^5.5.2" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", + "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.27.2.tgz", + "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", + "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.27.2.tgz", + "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", + "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", + "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", + "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", + "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", + "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", + "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", + "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", + "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", + "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", + "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", + "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", + "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", + "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", + "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", + "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", + "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", + "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", + "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", + "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", + "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", + "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", + "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@prisma/client": { + "version": "5.22.0", + "resolved": "https://registry.npmmirror.com/@prisma/client/-/client-5.22.0.tgz", + "integrity": "sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==", + "hasInstallScript": true, + "license": "Apache-2.0", + "engines": { + "node": ">=16.13" + }, + "peerDependencies": { + "prisma": "*" + }, + "peerDependenciesMeta": { + "prisma": { + "optional": true + } + } + }, + "node_modules/@prisma/debug": { + "version": "5.22.0", + "resolved": "https://registry.npmmirror.com/@prisma/debug/-/debug-5.22.0.tgz", + "integrity": "sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/engines": { + "version": "5.22.0", + "resolved": "https://registry.npmmirror.com/@prisma/engines/-/engines-5.22.0.tgz", + "integrity": "sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA==", + "devOptional": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "5.22.0", + "@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", + "@prisma/fetch-engine": "5.22.0", + "@prisma/get-platform": "5.22.0" + } + }, + "node_modules/@prisma/engines-version": { + "version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", + "resolved": "https://registry.npmmirror.com/@prisma/engines-version/-/engines-version-5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2.tgz", + "integrity": "sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/fetch-engine": { + "version": "5.22.0", + "resolved": "https://registry.npmmirror.com/@prisma/fetch-engine/-/fetch-engine-5.22.0.tgz", + "integrity": "sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "5.22.0", + "@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", + "@prisma/get-platform": "5.22.0" + } + }, + "node_modules/@prisma/get-platform": { + "version": "5.22.0", + "resolved": "https://registry.npmmirror.com/@prisma/get-platform/-/get-platform-5.22.0.tgz", + "integrity": "sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "5.22.0" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmmirror.com/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmmirror.com/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/express": { + "version": "4.17.25", + "resolved": "https://registry.npmmirror.com/@types/express/-/express-4.17.25.tgz", + "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "^1" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.19.8", + "resolved": "https://registry.npmmirror.com/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz", + "integrity": "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmmirror.com/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmmirror.com/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmmirror.com/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.30", + "resolved": "https://registry.npmmirror.com/@types/node/-/node-20.19.30.tgz", + "integrity": "sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmmirror.com/@types/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmmirror.com/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.10", + "resolved": "https://registry.npmmirror.com/@types/serve-static/-/serve-static-1.15.10.tgz", + "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "<1" + } + }, + "node_modules/@types/serve-static/node_modules/@types/send": { + "version": "0.17.6", + "resolved": "https://registry.npmmirror.com/@types/send/-/send-0.17.6.tgz", + "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmmirror.com/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/bcryptjs": { + "version": "2.4.3", + "resolved": "https://registry.npmmirror.com/bcryptjs/-/bcryptjs-2.4.3.tgz", + "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "1.20.4", + "resolved": "https://registry.npmmirror.com/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmmirror.com/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmmirror.com/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmmirror.com/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmmirror.com/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmmirror.com/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmmirror.com/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmmirror.com/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmmirror.com/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmmirror.com/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/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.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/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/esbuild": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.27.2.tgz", + "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.2", + "@esbuild/android-arm": "0.27.2", + "@esbuild/android-arm64": "0.27.2", + "@esbuild/android-x64": "0.27.2", + "@esbuild/darwin-arm64": "0.27.2", + "@esbuild/darwin-x64": "0.27.2", + "@esbuild/freebsd-arm64": "0.27.2", + "@esbuild/freebsd-x64": "0.27.2", + "@esbuild/linux-arm": "0.27.2", + "@esbuild/linux-arm64": "0.27.2", + "@esbuild/linux-ia32": "0.27.2", + "@esbuild/linux-loong64": "0.27.2", + "@esbuild/linux-mips64el": "0.27.2", + "@esbuild/linux-ppc64": "0.27.2", + "@esbuild/linux-riscv64": "0.27.2", + "@esbuild/linux-s390x": "0.27.2", + "@esbuild/linux-x64": "0.27.2", + "@esbuild/netbsd-arm64": "0.27.2", + "@esbuild/netbsd-x64": "0.27.2", + "@esbuild/openbsd-arm64": "0.27.2", + "@esbuild/openbsd-x64": "0.27.2", + "@esbuild/openharmony-arm64": "0.27.2", + "@esbuild/sunos-x64": "0.27.2", + "@esbuild/win32-arm64": "0.27.2", + "@esbuild/win32-ia32": "0.27.2", + "@esbuild/win32-x64": "0.27.2" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmmirror.com/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmmirror.com/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "7.5.1", + "resolved": "https://registry.npmmirror.com/express-rate-limit/-/express-rate-limit-7.5.1.tgz", + "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmmirror.com/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmmirror.com/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmmirror.com/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/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.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/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.npmmirror.com/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.0", + "resolved": "https://registry.npmmirror.com/get-tsconfig/-/get-tsconfig-4.13.0.tgz", + "integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/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-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/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/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/helmet": { + "version": "7.2.0", + "resolved": "https://registry.npmmirror.com/helmet/-/helmet-7.2.0.tgz", + "integrity": "sha512-ZRiwvN089JfMXokizgqEPXsl2Guk094yExfoDXR0cBYWxtBbaSww/w+vT4WEJsBW2iTUi1GgZ6swmoug3Oy4Xw==", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmmirror.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmmirror.com/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmmirror.com/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmmirror.com/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmmirror.com/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmmirror.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmmirror.com/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmmirror.com/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmmirror.com/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmmirror.com/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmmirror.com/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.npmmirror.com/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/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmmirror.com/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmmirror.com/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmmirror.com/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmmirror.com/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmmirror.com/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmmirror.com/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "license": "MIT" + }, + "node_modules/prisma": { + "version": "5.22.0", + "resolved": "https://registry.npmmirror.com/prisma/-/prisma-5.22.0.tgz", + "integrity": "sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==", + "devOptional": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/engines": "5.22.0" + }, + "bin": { + "prisma": "build/index.js" + }, + "engines": { + "node": ">=16.13" + }, + "optionalDependencies": { + "fsevents": "2.3.3" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmmirror.com/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.14.1", + "resolved": "https://registry.npmmirror.com/qs/-/qs-6.14.1.tgz", + "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmmirror.com/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/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/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmmirror.com/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmmirror.com/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmmirror.com/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/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmmirror.com/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmmirror.com/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..f469dbf --- /dev/null +++ b/backend/package.json @@ -0,0 +1,33 @@ +{ + "name": "notify-backend", + "version": "0.1.0", + "private": true, + "main": "dist/index.js", + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc -p tsconfig.json", + "start": "node dist/index.js", + "worker": "tsx src/worker.ts", + "prisma:generate": "prisma generate", + "prisma:migrate": "prisma migrate dev" + }, + "dependencies": { + "@prisma/client": "^5.13.0", + "bcryptjs": "^2.4.3", + "cors": "^2.8.5", + "dotenv": "^16.4.5", + "express": "^4.19.2", + "express-rate-limit": "^7.3.1", + "helmet": "^7.1.0", + "jsonwebtoken": "^9.0.2", + "zod": "^3.23.8" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/jsonwebtoken": "^9.0.6", + "@types/node": "^20.14.2", + "prisma": "^5.13.0", + "tsx": "^4.16.0", + "typescript": "^5.5.2" + } +} diff --git a/backend/prisma/migrations/20260119102154_init/migration.sql b/backend/prisma/migrations/20260119102154_init/migration.sql new file mode 100644 index 0000000..4462b22 --- /dev/null +++ b/backend/prisma/migrations/20260119102154_init/migration.sql @@ -0,0 +1,203 @@ +-- CreateEnum +CREATE TYPE "RecurrenceType" AS ENUM ('hourly', 'daily', 'weekly', 'monthly', 'yearly'); + +-- CreateEnum +CREATE TYPE "TargetType" AS ENUM ('todo', 'reminder_task'); + +-- CreateEnum +CREATE TYPE "ChannelType" AS ENUM ('inapp', 'bark'); + +-- CreateEnum +CREATE TYPE "NotificationStatus" AS ENUM ('pending', 'queued', 'sent', 'failed'); + +-- CreateTable +CREATE TABLE "User" ( + "id" TEXT NOT NULL, + "username" TEXT NOT NULL, + "passwordHash" TEXT NOT NULL, + "timezone" TEXT NOT NULL DEFAULT 'Asia/Shanghai', + "barkUrl" TEXT, + "inappEnabled" BOOLEAN NOT NULL DEFAULT true, + "barkEnabled" BOOLEAN NOT NULL DEFAULT false, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "User_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Invite" ( + "id" TEXT NOT NULL, + "code" TEXT NOT NULL, + "creatorId" TEXT NOT NULL, + "maxUses" INTEGER NOT NULL DEFAULT 5, + "usedCount" INTEGER NOT NULL DEFAULT 0, + "expiresAt" TIMESTAMP(3) NOT NULL, + "revokedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Invite_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "RecurrenceRule" ( + "id" TEXT NOT NULL, + "type" "RecurrenceType" NOT NULL, + "interval" INTEGER NOT NULL DEFAULT 1, + "byWeekday" INTEGER, + "byMonthday" INTEGER, + "timezone" TEXT NOT NULL DEFAULT 'Asia/Shanghai', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "RecurrenceRule_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Todo" ( + "id" TEXT NOT NULL, + "ownerId" TEXT NOT NULL, + "title" TEXT NOT NULL, + "description" TEXT, + "dueAt" TIMESTAMP(3) NOT NULL, + "recurrenceRuleId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Todo_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ReminderTask" ( + "id" TEXT NOT NULL, + "creatorId" TEXT NOT NULL, + "title" TEXT NOT NULL, + "description" TEXT, + "dueAt" TIMESTAMP(3) NOT NULL, + "recurrenceRuleId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ReminderTask_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ReminderTaskRecipient" ( + "taskId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + + CONSTRAINT "ReminderTaskRecipient_pkey" PRIMARY KEY ("taskId","userId") +); + +-- CreateTable +CREATE TABLE "ReminderOffset" ( + "id" TEXT NOT NULL, + "targetType" "TargetType" NOT NULL, + "targetId" TEXT NOT NULL, + "offsetMinutes" INTEGER NOT NULL, + "channelInapp" BOOLEAN NOT NULL DEFAULT true, + "channelBark" BOOLEAN NOT NULL DEFAULT false, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "ReminderOffset_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Notification" ( + "id" TEXT NOT NULL, + "recipientId" TEXT NOT NULL, + "targetType" "TargetType" NOT NULL, + "targetId" TEXT NOT NULL, + "triggerAt" TIMESTAMP(3) NOT NULL, + "channel" "ChannelType" NOT NULL, + "status" "NotificationStatus" NOT NULL DEFAULT 'pending', + "lockedAt" TIMESTAMP(3), + "sentAt" TIMESTAMP(3), + "readAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Notification_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "DeliveryLog" ( + "id" TEXT NOT NULL, + "notificationId" TEXT NOT NULL, + "attemptNo" INTEGER NOT NULL, + "channel" "ChannelType" NOT NULL, + "status" "NotificationStatus" NOT NULL, + "responseMeta" JSONB, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "DeliveryLog_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "User_username_key" ON "User"("username"); + +-- CreateIndex +CREATE UNIQUE INDEX "Invite_code_key" ON "Invite"("code"); + +-- CreateIndex +CREATE INDEX "Invite_creatorId_idx" ON "Invite"("creatorId"); + +-- CreateIndex +CREATE INDEX "Invite_expiresAt_idx" ON "Invite"("expiresAt"); + +-- CreateIndex +CREATE INDEX "Todo_ownerId_dueAt_idx" ON "Todo"("ownerId", "dueAt"); + +-- CreateIndex +CREATE INDEX "Todo_recurrenceRuleId_idx" ON "Todo"("recurrenceRuleId"); + +-- CreateIndex +CREATE INDEX "ReminderTask_creatorId_dueAt_idx" ON "ReminderTask"("creatorId", "dueAt"); + +-- CreateIndex +CREATE INDEX "ReminderTask_recurrenceRuleId_idx" ON "ReminderTask"("recurrenceRuleId"); + +-- CreateIndex +CREATE INDEX "ReminderTaskRecipient_userId_idx" ON "ReminderTaskRecipient"("userId"); + +-- CreateIndex +CREATE INDEX "ReminderOffset_targetType_targetId_idx" ON "ReminderOffset"("targetType", "targetId"); + +-- CreateIndex +CREATE INDEX "Notification_status_triggerAt_idx" ON "Notification"("status", "triggerAt"); + +-- CreateIndex +CREATE INDEX "Notification_recipientId_readAt_idx" ON "Notification"("recipientId", "readAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "Notification_recipientId_targetType_targetId_triggerAt_chan_key" ON "Notification"("recipientId", "targetType", "targetId", "triggerAt", "channel"); + +-- CreateIndex +CREATE INDEX "DeliveryLog_notificationId_idx" ON "DeliveryLog"("notificationId"); + +-- AddForeignKey +ALTER TABLE "Invite" ADD CONSTRAINT "Invite_creatorId_fkey" FOREIGN KEY ("creatorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Todo" ADD CONSTRAINT "Todo_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Todo" ADD CONSTRAINT "Todo_recurrenceRuleId_fkey" FOREIGN KEY ("recurrenceRuleId") REFERENCES "RecurrenceRule"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ReminderTask" ADD CONSTRAINT "ReminderTask_creatorId_fkey" FOREIGN KEY ("creatorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ReminderTask" ADD CONSTRAINT "ReminderTask_recurrenceRuleId_fkey" FOREIGN KEY ("recurrenceRuleId") REFERENCES "RecurrenceRule"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ReminderTaskRecipient" ADD CONSTRAINT "ReminderTaskRecipient_taskId_fkey" FOREIGN KEY ("taskId") REFERENCES "ReminderTask"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ReminderTaskRecipient" ADD CONSTRAINT "ReminderTaskRecipient_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Notification" ADD CONSTRAINT "Notification_recipientId_fkey" FOREIGN KEY ("recipientId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "DeliveryLog" ADD CONSTRAINT "DeliveryLog_notificationId_fkey" FOREIGN KEY ("notificationId") REFERENCES "Notification"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/backend/prisma/migrations/20260122092137_add_user_avatar/migration.sql b/backend/prisma/migrations/20260122092137_add_user_avatar/migration.sql new file mode 100644 index 0000000..3766c23 --- /dev/null +++ b/backend/prisma/migrations/20260122092137_add_user_avatar/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "avatar" TEXT; diff --git a/backend/prisma/migrations/migration_lock.toml b/backend/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..fbffa92 --- /dev/null +++ b/backend/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (i.e. Git) +provider = "postgresql" \ No newline at end of file diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma new file mode 100644 index 0000000..b678b0e --- /dev/null +++ b/backend/prisma/schema.prisma @@ -0,0 +1,176 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +enum RecurrenceType { + hourly + daily + weekly + monthly + yearly +} + +enum TargetType { + todo + reminder_task +} + +enum ChannelType { + inapp + bark +} + +enum NotificationStatus { + pending + queued + sent + failed +} + +model User { + id String @id @default(cuid()) + username String @unique + passwordHash String + avatar String? + timezone String @default("Asia/Shanghai") + barkUrl String? + inappEnabled Boolean @default(true) + barkEnabled Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + invites Invite[] @relation("InviteCreator") + todos Todo[] @relation("TodoOwner") + createdTasks ReminderTask[] @relation("TaskCreator") + taskRecipients ReminderTaskRecipient[] + notifications Notification[] @relation("NotificationRecipient") +} + +model Invite { + id String @id @default(cuid()) + code String @unique + creatorId String + maxUses Int @default(5) + usedCount Int @default(0) + expiresAt DateTime + revokedAt DateTime? + createdAt DateTime @default(now()) + + creator User @relation("InviteCreator", fields: [creatorId], references: [id]) + + @@index([creatorId]) + @@index([expiresAt]) +} + +model RecurrenceRule { + id String @id @default(cuid()) + type RecurrenceType + interval Int @default(1) + byWeekday Int? + byMonthday Int? + timezone String @default("Asia/Shanghai") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + todos Todo[] + tasks ReminderTask[] +} + +model Todo { + id String @id @default(cuid()) + ownerId String + title String + description String? + dueAt DateTime + recurrenceRuleId String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + owner User @relation("TodoOwner", fields: [ownerId], references: [id]) + recurrenceRule RecurrenceRule? @relation(fields: [recurrenceRuleId], references: [id]) + + @@index([ownerId, dueAt]) + @@index([recurrenceRuleId]) +} + +model ReminderTask { + id String @id @default(cuid()) + creatorId String + title String + description String? + dueAt DateTime + recurrenceRuleId String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + creator User @relation("TaskCreator", fields: [creatorId], references: [id]) + recurrenceRule RecurrenceRule? @relation(fields: [recurrenceRuleId], references: [id]) + recipients ReminderTaskRecipient[] + + @@index([creatorId, dueAt]) + @@index([recurrenceRuleId]) +} + +model ReminderTaskRecipient { + taskId String + userId String + + task ReminderTask @relation(fields: [taskId], references: [id]) + user User @relation(fields: [userId], references: [id]) + + @@id([taskId, userId]) + @@index([userId]) +} + +model ReminderOffset { + id String @id @default(cuid()) + targetType TargetType + targetId String + offsetMinutes Int + channelInapp Boolean @default(true) + channelBark Boolean @default(false) + createdAt DateTime @default(now()) + + @@index([targetType, targetId]) +} + +model Notification { + id String @id @default(cuid()) + recipientId String + targetType TargetType + targetId String + triggerAt DateTime + channel ChannelType + status NotificationStatus @default(pending) + lockedAt DateTime? + sentAt DateTime? + readAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + recipient User @relation("NotificationRecipient", fields: [recipientId], references: [id]) + logs DeliveryLog[] + + @@unique([recipientId, targetType, targetId, triggerAt, channel]) + @@index([status, triggerAt]) + @@index([recipientId, readAt]) +} + +model DeliveryLog { + id String @id @default(cuid()) + notificationId String + attemptNo Int + channel ChannelType + status NotificationStatus + responseMeta Json? + createdAt DateTime @default(now()) + + notification Notification @relation(fields: [notificationId], references: [id]) + + @@index([notificationId]) +} diff --git a/backend/src/app.ts b/backend/src/app.ts new file mode 100644 index 0000000..15a6736 --- /dev/null +++ b/backend/src/app.ts @@ -0,0 +1,39 @@ +import cors from "cors"; +import express from "express"; +import rateLimit from "express-rate-limit"; +import helmet from "helmet"; + +import { authRouter } from "./routes/auth"; +import { inviteRouter } from "./routes/invites"; +import { meRouter } from "./routes/me"; +import { notificationRouter } from "./routes/notifications"; +import { reminderTaskRouter } from "./routes/reminderTasks"; +import { todoRouter } from "./routes/todos"; +import { userRouter } from "./routes/users"; + +export const createApp = () => { + const app = express(); + app.use(helmet()); + app.use(cors()); + app.use(express.json()); + app.use( + rateLimit({ + windowMs: 60 * 1000, + max: 120, + standardHeaders: true, + legacyHeaders: false, + }) + ); + + app.get("/health", (_req, res) => res.json({ ok: true })); + + app.use("/api/auth", authRouter); + app.use("/api/invites", inviteRouter); + app.use("/api/me", meRouter); + app.use("/api/notifications", notificationRouter); + app.use("/api/reminder-tasks", reminderTaskRouter); + app.use("/api/todos", todoRouter); + app.use("/api/users", userRouter); + + return app; +}; diff --git a/backend/src/db.ts b/backend/src/db.ts new file mode 100644 index 0000000..901f3a0 --- /dev/null +++ b/backend/src/db.ts @@ -0,0 +1,3 @@ +import { PrismaClient } from "@prisma/client"; + +export const prisma = new PrismaClient(); diff --git a/backend/src/index.ts b/backend/src/index.ts new file mode 100644 index 0000000..97304d2 --- /dev/null +++ b/backend/src/index.ts @@ -0,0 +1,11 @@ +import "dotenv/config"; + +import { createApp } from "./app"; + +const port = Number(process.env.PORT || 4000); + +const app = createApp(); +app.listen(port, () => { + // eslint-disable-next-line no-console + console.log(`Notify API running on :${port}`); +}); diff --git a/backend/src/middleware/auth.ts b/backend/src/middleware/auth.ts new file mode 100644 index 0000000..d1250c8 --- /dev/null +++ b/backend/src/middleware/auth.ts @@ -0,0 +1,21 @@ +import type { Request, Response, NextFunction } from "express"; +import jwt from "jsonwebtoken"; + +export type AuthRequest = Request & { userId?: string }; + +export const requireAuth = (req: AuthRequest, res: Response, next: NextFunction) => { + const header = req.headers.authorization; + if (!header?.startsWith("Bearer ")) { + return res.status(401).json({ error: "Unauthorized" }); + } + const token = header.slice("Bearer ".length); + try { + const payload = jwt.verify(token, process.env.JWT_SECRET || "dev-secret") as { + userId: string; + }; + req.userId = payload.userId; + return next(); + } catch { + return res.status(401).json({ error: "Unauthorized" }); + } +}; diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts new file mode 100644 index 0000000..4868490 --- /dev/null +++ b/backend/src/routes/auth.ts @@ -0,0 +1,93 @@ +import { Router } from "express"; +import bcrypt from "bcryptjs"; +import jwt from "jsonwebtoken"; +import { z } from "zod"; + +import { prisma } from "../db"; + +export const authRouter = Router(); + +const registerSchema = z.object({ + username: z.string().min(3), + password: z.string().min(6), + inviteCode: z.string().min(4), +}); + +authRouter.post("/register", async (req, res) => { + const parsed = registerSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ error: "Invalid payload" }); + } + + const { username, password, inviteCode } = parsed.data; + const now = new Date(); + + try { + const result = await prisma.$transaction(async (tx) => { + const invite = await tx.invite.findFirst({ + where: { + code: inviteCode, + revokedAt: null, + expiresAt: { gt: now }, + }, + }); + if (!invite || invite.usedCount >= invite.maxUses) { + throw new Error("Invalid invite"); + } + + const existing = await tx.user.findUnique({ where: { username } }); + if (existing) { + throw new Error("Username taken"); + } + + const passwordHash = await bcrypt.hash(password, 10); + const user = await tx.user.create({ + data: { username, passwordHash }, + }); + + await tx.invite.update({ + where: { id: invite.id }, + data: { usedCount: invite.usedCount + 1 }, + }); + + return user; + }); + + const token = jwt.sign({ userId: result.id }, process.env.JWT_SECRET || "dev-secret", { + expiresIn: "7d", + }); + return res.json({ token, user: { id: result.id, username: result.username } }); + } catch (err) { + const message = err instanceof Error ? err.message : "Register failed"; + const status = message === "Invalid invite" ? 400 : 409; + return res.status(status).json({ error: message }); + } +}); + +const loginSchema = z.object({ + username: z.string().min(3), + password: z.string().min(6), +}); + +authRouter.post("/login", async (req, res) => { + const parsed = loginSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ error: "Invalid payload" }); + } + + const { username, password } = parsed.data; + const user = await prisma.user.findUnique({ where: { username } }); + if (!user) { + return res.status(401).json({ error: "Invalid credentials" }); + } + + const ok = await bcrypt.compare(password, user.passwordHash); + if (!ok) { + return res.status(401).json({ error: "Invalid credentials" }); + } + + const token = jwt.sign({ userId: user.id }, process.env.JWT_SECRET || "dev-secret", { + expiresIn: "7d", + }); + return res.json({ token, user: { id: user.id, username: user.username } }); +}); diff --git a/backend/src/routes/invites.ts b/backend/src/routes/invites.ts new file mode 100644 index 0000000..4bbc561 --- /dev/null +++ b/backend/src/routes/invites.ts @@ -0,0 +1,55 @@ +import { Router } from "express"; +import { z } from "zod"; + +import { prisma } from "../db"; +import { requireAuth, type AuthRequest } from "../middleware/auth"; + +export const inviteRouter = Router(); + +inviteRouter.use(requireAuth); + +const createSchema = z.object({ + maxUses: z.number().int().min(1).max(20).optional(), + expiresInDays: z.number().int().min(1).max(30).optional(), +}); + +inviteRouter.post("/", async (req: AuthRequest, res) => { + const parsed = createSchema.safeParse(req.body ?? {}); + if (!parsed.success) { + return res.status(400).json({ error: "Invalid payload" }); + } + + const maxUses = parsed.data.maxUses ?? 5; + const expiresInDays = parsed.data.expiresInDays ?? 7; + const expiresAt = new Date(Date.now() + expiresInDays * 24 * 60 * 60 * 1000); + const code = `INV-${Math.random().toString(36).slice(2, 8).toUpperCase()}`; + + const invite = await prisma.invite.create({ + data: { + code, + creatorId: req.userId!, + maxUses, + expiresAt, + }, + }); + return res.json(invite); +}); + +inviteRouter.get("/", async (req: AuthRequest, res) => { + const invites = await prisma.invite.findMany({ + where: { creatorId: req.userId! }, + orderBy: { createdAt: "desc" }, + }); + return res.json(invites); +}); + +inviteRouter.post("/:id/revoke", async (req: AuthRequest, res) => { + const invite = await prisma.invite.updateMany({ + where: { id: req.params.id, creatorId: req.userId! }, + data: { revokedAt: new Date() }, + }); + if (invite.count === 0) { + return res.status(404).json({ error: "Invite not found" }); + } + return res.json({ ok: true }); +}); diff --git a/backend/src/routes/me.ts b/backend/src/routes/me.ts new file mode 100644 index 0000000..e01599f --- /dev/null +++ b/backend/src/routes/me.ts @@ -0,0 +1,53 @@ +import { Router } from "express"; +import { z } from "zod"; + +import { prisma } from "../db"; +import { requireAuth, type AuthRequest } from "../middleware/auth"; + +export const meRouter = Router(); +meRouter.use(requireAuth); + +meRouter.get("/", async (req: AuthRequest, res) => { + const user = await prisma.user.findUnique({ + where: { id: req.userId! }, + select: { + id: true, + username: true, + avatar: true, + timezone: true, + barkUrl: true, + inappEnabled: true, + barkEnabled: true, + }, + }); + return res.json(user); +}); + +const settingsSchema = z.object({ + avatar: z.string().url().optional().nullable(), + timezone: z.string().optional(), + barkUrl: z.string().url().optional().nullable(), + inappEnabled: z.boolean().optional(), + barkEnabled: z.boolean().optional(), +}); + +meRouter.put("/settings", async (req: AuthRequest, res) => { + const parsed = settingsSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ error: "Invalid payload" }); + } + const user = await prisma.user.update({ + where: { id: req.userId! }, + data: parsed.data, + select: { + id: true, + username: true, + avatar: true, + timezone: true, + barkUrl: true, + inappEnabled: true, + barkEnabled: true, + }, + }); + return res.json(user); +}); diff --git a/backend/src/routes/notifications.ts b/backend/src/routes/notifications.ts new file mode 100644 index 0000000..2e191c2 --- /dev/null +++ b/backend/src/routes/notifications.ts @@ -0,0 +1,40 @@ +import { Router } from "express"; + +import { prisma } from "../db"; +import { requireAuth, type AuthRequest } from "../middleware/auth"; + +export const notificationRouter = Router(); +notificationRouter.use(requireAuth); + +notificationRouter.get("/", async (req: AuthRequest, res) => { + const status = (req.query.status as string | undefined) ?? "all"; + const where = + status === "unread" + ? { recipientId: req.userId!, readAt: null } + : { recipientId: req.userId! }; + + const notifications = await prisma.notification.findMany({ + where, + orderBy: { triggerAt: "desc" }, + }); + return res.json(notifications); +}); + +notificationRouter.post("/:id/read", async (req: AuthRequest, res) => { + const updated = await prisma.notification.updateMany({ + where: { id: req.params.id, recipientId: req.userId! }, + data: { readAt: new Date() }, + }); + if (updated.count === 0) { + return res.status(404).json({ error: "Not found" }); + } + return res.json({ ok: true }); +}); + +notificationRouter.post("/read-all", async (req: AuthRequest, res) => { + await prisma.notification.updateMany({ + where: { recipientId: req.userId!, readAt: null }, + data: { readAt: new Date() }, + }); + return res.json({ ok: true }); +}); diff --git a/backend/src/routes/reminderTasks.ts b/backend/src/routes/reminderTasks.ts new file mode 100644 index 0000000..46a7702 --- /dev/null +++ b/backend/src/routes/reminderTasks.ts @@ -0,0 +1,213 @@ +import { Router } from "express"; +import { z } from "zod"; + +import { prisma } from "../db"; +import { requireAuth, type AuthRequest } from "../middleware/auth"; + +export const reminderTaskRouter = Router(); +reminderTaskRouter.use(requireAuth); + +const recurrenceSchema = z.object({ + type: z.enum(["hourly", "daily", "weekly", "monthly", "yearly"]), + interval: z.number().int().min(1).optional(), + byWeekday: z.number().int().min(0).max(6).optional(), + byMonthday: z.number().int().min(1).max(31).optional(), + timezone: z.string().optional(), +}); + +const offsetSchema = z.object({ + offsetMinutes: z.number().int().min(0), + channelInapp: z.boolean().optional(), + channelBark: z.boolean().optional(), +}); + +const taskSchema = z.object({ + title: z.string().min(1), + description: z.string().optional(), + dueAt: z.string().datetime(), + recipientIds: z.array(z.string().min(1)), + recurrenceRule: recurrenceSchema.optional(), + offsets: z.array(offsetSchema).optional(), +}); + +reminderTaskRouter.get("/", async (req: AuthRequest, res) => { + const items = await prisma.reminderTask.findMany({ + where: { creatorId: req.userId! }, + include: { recipients: true, recurrenceRule: true }, + orderBy: { dueAt: "asc" }, + }); + const offsets = await prisma.reminderOffset.findMany({ + where: { targetType: "reminder_task", targetId: { in: items.map((item) => item.id) } }, + }); + const offsetsById = offsets.reduce>((acc, offset) => { + acc[offset.targetId] = acc[offset.targetId] ?? []; + acc[offset.targetId].push(offset); + return acc; + }, {}); + const withOffsets = items.map((item) => ({ + ...item, + offsets: offsetsById[item.id] ?? [], + })); + return res.json(withOffsets); +}); + +reminderTaskRouter.post("/", async (req: AuthRequest, res) => { + const parsed = taskSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ error: "Invalid payload" }); + } + const { recurrenceRule, offsets = [], recipientIds, ...data } = parsed.data; + + const task = await prisma.$transaction(async (tx) => { + const rule = recurrenceRule + ? await tx.recurrenceRule.create({ + data: { + type: recurrenceRule.type, + interval: recurrenceRule.interval ?? 1, + byWeekday: recurrenceRule.byWeekday, + byMonthday: recurrenceRule.byMonthday, + timezone: recurrenceRule.timezone ?? "Asia/Shanghai", + }, + }) + : null; + + const created = await tx.reminderTask.create({ + data: { + creatorId: req.userId!, + title: data.title, + description: data.description, + dueAt: new Date(data.dueAt), + recurrenceRuleId: rule?.id, + }, + }); + + await tx.reminderTaskRecipient.createMany({ + data: recipientIds.map((userId) => ({ taskId: created.id, userId })), + skipDuplicates: true, + }); + + if (offsets.length > 0) { + await tx.reminderOffset.createMany({ + data: offsets.map((offset) => ({ + targetType: "reminder_task", + targetId: created.id, + offsetMinutes: offset.offsetMinutes, + channelInapp: offset.channelInapp ?? true, + channelBark: offset.channelBark ?? false, + })), + }); + } + + return created; + }); + + return res.json(task); +}); + +reminderTaskRouter.get("/:id", async (req: AuthRequest, res) => { + const task = await prisma.reminderTask.findFirst({ + where: { id: req.params.id, creatorId: req.userId! }, + include: { recipients: true, recurrenceRule: true }, + }); + if (!task) { + return res.status(404).json({ error: "Not found" }); + } + const offsets = await prisma.reminderOffset.findMany({ + where: { targetType: "reminder_task", targetId: task.id }, + }); + return res.json({ ...task, offsets }); +}); + +reminderTaskRouter.put("/:id", async (req: AuthRequest, res) => { + const parsed = taskSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ error: "Invalid payload" }); + } + const { recurrenceRule, offsets = [], recipientIds, ...data } = parsed.data; + + const updated = await prisma.$transaction(async (tx) => { + const existing = await tx.reminderTask.findFirst({ + where: { id: req.params.id, creatorId: req.userId! }, + }); + if (!existing) { + throw new Error("Not found"); + } + + let recurrenceRuleId = existing.recurrenceRuleId; + if (recurrenceRule) { + if (recurrenceRuleId) { + await tx.recurrenceRule.update({ + where: { id: recurrenceRuleId }, + data: { + type: recurrenceRule.type, + interval: recurrenceRule.interval ?? 1, + byWeekday: recurrenceRule.byWeekday, + byMonthday: recurrenceRule.byMonthday, + timezone: recurrenceRule.timezone ?? "Asia/Shanghai", + }, + }); + } else { + const created = await tx.recurrenceRule.create({ + data: { + type: recurrenceRule.type, + interval: recurrenceRule.interval ?? 1, + byWeekday: recurrenceRule.byWeekday, + byMonthday: recurrenceRule.byMonthday, + timezone: recurrenceRule.timezone ?? "Asia/Shanghai", + }, + }); + recurrenceRuleId = created.id; + } + } else if (recurrenceRuleId) { + await tx.recurrenceRule.delete({ where: { id: recurrenceRuleId } }); + recurrenceRuleId = null; + } + + await tx.reminderTaskRecipient.deleteMany({ where: { taskId: existing.id } }); + await tx.reminderTaskRecipient.createMany({ + data: recipientIds.map((userId) => ({ taskId: existing.id, userId })), + skipDuplicates: true, + }); + + await tx.reminderOffset.deleteMany({ + where: { targetType: "reminder_task", targetId: existing.id }, + }); + if (offsets.length > 0) { + await tx.reminderOffset.createMany({ + data: offsets.map((offset) => ({ + targetType: "reminder_task", + targetId: existing.id, + offsetMinutes: offset.offsetMinutes, + channelInapp: offset.channelInapp ?? true, + channelBark: offset.channelBark ?? false, + })), + }); + } + + return tx.reminderTask.update({ + where: { id: existing.id }, + data: { + title: data.title, + description: data.description, + dueAt: new Date(data.dueAt), + recurrenceRuleId, + }, + }); + }); + + return res.json(updated); +}); + +reminderTaskRouter.delete("/:id", async (req: AuthRequest, res) => { + const deleted = await prisma.reminderTask.deleteMany({ + where: { id: req.params.id, creatorId: req.userId! }, + }); + if (deleted.count === 0) { + return res.status(404).json({ error: "Not found" }); + } + await prisma.reminderOffset.deleteMany({ + where: { targetType: "reminder_task", targetId: req.params.id }, + }); + await prisma.reminderTaskRecipient.deleteMany({ where: { taskId: req.params.id } }); + return res.json({ ok: true }); +}); diff --git a/backend/src/routes/todos.ts b/backend/src/routes/todos.ts new file mode 100644 index 0000000..a60fa64 --- /dev/null +++ b/backend/src/routes/todos.ts @@ -0,0 +1,196 @@ +import { Router } from "express"; +import { z } from "zod"; + +import { prisma } from "../db"; +import { requireAuth, type AuthRequest } from "../middleware/auth"; + +export const todoRouter = Router(); +todoRouter.use(requireAuth); + +const recurrenceSchema = z.object({ + type: z.enum(["hourly", "daily", "weekly", "monthly", "yearly"]), + interval: z.number().int().min(1).optional(), + byWeekday: z.number().int().min(0).max(6).optional(), + byMonthday: z.number().int().min(1).max(31).optional(), + timezone: z.string().optional(), +}); + +const offsetSchema = z.object({ + offsetMinutes: z.number().int().min(0), + channelInapp: z.boolean().optional(), + channelBark: z.boolean().optional(), +}); + +const todoSchema = z.object({ + title: z.string().min(1), + description: z.string().optional(), + dueAt: z.string().datetime(), + recurrenceRule: recurrenceSchema.optional(), + offsets: z.array(offsetSchema).optional(), +}); + +todoRouter.get("/", async (req: AuthRequest, res) => { + const items = await prisma.todo.findMany({ + where: { ownerId: req.userId! }, + include: { recurrenceRule: true }, + orderBy: { dueAt: "asc" }, + }); + const offsets = await prisma.reminderOffset.findMany({ + where: { targetType: "todo", targetId: { in: items.map((item) => item.id) } }, + }); + const offsetsById = offsets.reduce>((acc, offset) => { + acc[offset.targetId] = acc[offset.targetId] ?? []; + acc[offset.targetId].push(offset); + return acc; + }, {}); + const withOffsets = items.map((item) => ({ + ...item, + offsets: offsetsById[item.id] ?? [], + })); + return res.json(withOffsets); +}); + +todoRouter.post("/", async (req: AuthRequest, res) => { + const parsed = todoSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ error: "Invalid payload" }); + } + const { recurrenceRule, offsets = [], ...data } = parsed.data; + + const todo = await prisma.$transaction(async (tx) => { + const rule = recurrenceRule + ? await tx.recurrenceRule.create({ + data: { + type: recurrenceRule.type, + interval: recurrenceRule.interval ?? 1, + byWeekday: recurrenceRule.byWeekday, + byMonthday: recurrenceRule.byMonthday, + timezone: recurrenceRule.timezone ?? "Asia/Shanghai", + }, + }) + : null; + + const created = await tx.todo.create({ + data: { + ownerId: req.userId!, + title: data.title, + description: data.description, + dueAt: new Date(data.dueAt), + recurrenceRuleId: rule?.id, + }, + }); + + if (offsets.length > 0) { + await tx.reminderOffset.createMany({ + data: offsets.map((offset) => ({ + targetType: "todo", + targetId: created.id, + offsetMinutes: offset.offsetMinutes, + channelInapp: offset.channelInapp ?? true, + channelBark: offset.channelBark ?? false, + })), + }); + } + + return created; + }); + + return res.json(todo); +}); + +todoRouter.get("/:id", async (req: AuthRequest, res) => { + const todo = await prisma.todo.findFirst({ + where: { id: req.params.id, ownerId: req.userId! }, + include: { recurrenceRule: true }, + }); + if (!todo) { + return res.status(404).json({ error: "Not found" }); + } + const offsets = await prisma.reminderOffset.findMany({ + where: { targetType: "todo", targetId: todo.id }, + }); + return res.json({ ...todo, offsets }); +}); + +todoRouter.put("/:id", async (req: AuthRequest, res) => { + const parsed = todoSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ error: "Invalid payload" }); + } + const { recurrenceRule, offsets = [], ...data } = parsed.data; + + const updated = await prisma.$transaction(async (tx) => { + const existing = await tx.todo.findFirst({ + where: { id: req.params.id, ownerId: req.userId! }, + }); + if (!existing) { + throw new Error("Not found"); + } + + let recurrenceRuleId = existing.recurrenceRuleId; + if (recurrenceRule) { + if (recurrenceRuleId) { + await tx.recurrenceRule.update({ + where: { id: recurrenceRuleId }, + data: { + type: recurrenceRule.type, + interval: recurrenceRule.interval ?? 1, + byWeekday: recurrenceRule.byWeekday, + byMonthday: recurrenceRule.byMonthday, + timezone: recurrenceRule.timezone ?? "Asia/Shanghai", + }, + }); + } else { + const created = await tx.recurrenceRule.create({ + data: { + type: recurrenceRule.type, + interval: recurrenceRule.interval ?? 1, + byWeekday: recurrenceRule.byWeekday, + byMonthday: recurrenceRule.byMonthday, + timezone: recurrenceRule.timezone ?? "Asia/Shanghai", + }, + }); + recurrenceRuleId = created.id; + } + } else if (recurrenceRuleId) { + await tx.recurrenceRule.delete({ where: { id: recurrenceRuleId } }); + recurrenceRuleId = null; + } + + await tx.reminderOffset.deleteMany({ where: { targetType: "todo", targetId: existing.id } }); + if (offsets.length > 0) { + await tx.reminderOffset.createMany({ + data: offsets.map((offset) => ({ + targetType: "todo", + targetId: existing.id, + offsetMinutes: offset.offsetMinutes, + channelInapp: offset.channelInapp ?? true, + channelBark: offset.channelBark ?? false, + })), + }); + } + + return tx.todo.update({ + where: { id: existing.id }, + data: { + title: data.title, + description: data.description, + dueAt: new Date(data.dueAt), + recurrenceRuleId, + }, + }); + }); + + return res.json(updated); +}); + +todoRouter.delete("/:id", async (req: AuthRequest, res) => { + const deleted = await prisma.todo.deleteMany({ + where: { id: req.params.id, ownerId: req.userId! }, + }); + if (deleted.count === 0) { + return res.status(404).json({ error: "Not found" }); + } + await prisma.reminderOffset.deleteMany({ where: { targetType: "todo", targetId: req.params.id } }); + return res.json({ ok: true }); +}); diff --git a/backend/src/routes/users.ts b/backend/src/routes/users.ts new file mode 100644 index 0000000..138a4bf --- /dev/null +++ b/backend/src/routes/users.ts @@ -0,0 +1,24 @@ +import { Router } from "express"; + +import { prisma } from "../db"; +import { requireAuth } from "../middleware/auth"; + +export const userRouter = Router(); +userRouter.use(requireAuth); + +userRouter.get("/", async (req, res) => { + const query = (req.query.query as string | undefined)?.trim(); + const users = await prisma.user.findMany({ + where: query + ? { + username: { + contains: query, + mode: "insensitive", + }, + } + : undefined, + select: { id: true, username: true, avatar: true }, + orderBy: { username: "asc" }, + }); + return res.json(users); +}); diff --git a/backend/src/services/bark.ts b/backend/src/services/bark.ts new file mode 100644 index 0000000..376067c --- /dev/null +++ b/backend/src/services/bark.ts @@ -0,0 +1,23 @@ +type BarkPayload = { + title: string; + body: string; + group?: string; + url?: string; + sound?: string; + icon?: string; +}; + +export const sendBarkPush = async (baseUrl: string, payload: BarkPayload) => { + const response = await fetch(baseUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + const data = await response.json().catch(() => ({})); + if (!response.ok) { + const error = new Error(`Bark error: ${response.status}`); + (error as Error & { response?: unknown }).response = data; + throw error; + } + return data; +}; diff --git a/backend/src/services/recurrence.ts b/backend/src/services/recurrence.ts new file mode 100644 index 0000000..d9ea222 --- /dev/null +++ b/backend/src/services/recurrence.ts @@ -0,0 +1,51 @@ +import type { RecurrenceRule } from "@prisma/client"; + +const clampDay = (year: number, monthIndex: number, day: number) => { + const lastDay = new Date(Date.UTC(year, monthIndex + 1, 0)).getUTCDate(); + return Math.min(day, lastDay); +}; + +export const addMonthsWithClamp = (date: Date, months: number) => { + const year = date.getUTCFullYear(); + const month = date.getUTCMonth(); + const day = date.getUTCDate(); + const hour = date.getUTCHours(); + const minute = date.getUTCMinutes(); + const second = date.getUTCSeconds(); + + const targetMonth = month + months; + const targetYear = year + Math.floor(targetMonth / 12); + const normalizedMonth = ((targetMonth % 12) + 12) % 12; + const clampedDay = clampDay(targetYear, normalizedMonth, day); + + return new Date(Date.UTC(targetYear, normalizedMonth, clampedDay, hour, minute, second)); +}; + +export const addYearsWithClamp = (date: Date, years: number) => { + const targetYear = date.getUTCFullYear() + years; + const month = date.getUTCMonth(); + const day = date.getUTCDate(); + const hour = date.getUTCHours(); + const minute = date.getUTCMinutes(); + const second = date.getUTCSeconds(); + const clampedDay = clampDay(targetYear, month, day); + return new Date(Date.UTC(targetYear, month, clampedDay, hour, minute, second)); +}; + +export const nextDueAt = (dueAt: Date, rule: RecurrenceRule) => { + const interval = rule.interval ?? 1; + switch (rule.type) { + case "hourly": + return new Date(dueAt.getTime() + interval * 60 * 60 * 1000); + case "daily": + return new Date(dueAt.getTime() + interval * 24 * 60 * 60 * 1000); + case "weekly": + return new Date(dueAt.getTime() + interval * 7 * 24 * 60 * 60 * 1000); + case "monthly": + return addMonthsWithClamp(dueAt, interval); + case "yearly": + return addYearsWithClamp(dueAt, interval); + default: + return dueAt; + } +}; diff --git a/backend/src/worker.ts b/backend/src/worker.ts new file mode 100644 index 0000000..27a9304 --- /dev/null +++ b/backend/src/worker.ts @@ -0,0 +1,255 @@ +import "dotenv/config"; + +import { prisma } from "./db"; +import { sendBarkPush } from "./services/bark"; +import { nextDueAt } from "./services/recurrence"; + +const LOCK_TIMEOUT_MS = 5 * 60 * 1000; + +const calcBackoffMs = (attemptNo: number) => { + const steps = [60_000, 5 * 60_000, 15 * 60_000, 60 * 60_000, 4 * 60 * 60_000]; + return steps[Math.min(attemptNo - 1, steps.length - 1)]; +}; + +const buildOffsets = (offsets: { offsetMinutes: number; channelInapp: boolean; channelBark: boolean }[]) => { + if (offsets.length === 0) { + return [{ offsetMinutes: 0, channelInapp: true, channelBark: false }]; + } + return offsets; +}; + +const generateNotifications = async (now: Date) => { + const maxOffset = await prisma.reminderOffset.aggregate({ + _max: { offsetMinutes: true }, + }); + const offsetMinutes = maxOffset._max.offsetMinutes ?? 0; + const upperBound = new Date(now.getTime() + offsetMinutes * 60 * 1000); + + const todos = await prisma.todo.findMany({ + where: { dueAt: { lte: upperBound } }, + include: { owner: true, recurrenceRule: true }, + }); + const todoOffsets = await prisma.reminderOffset.findMany({ + where: { targetType: "todo", targetId: { in: todos.map((todo) => todo.id) } }, + }); + const todoOffsetsById = todoOffsets.reduce>((acc, offset) => { + acc[offset.targetId] = acc[offset.targetId] ?? []; + acc[offset.targetId].push(offset); + return acc; + }, {}); + + for (const todo of todos) { + const offsets = buildOffsets(todoOffsetsById[todo.id] ?? []); + const data = []; + for (const offset of offsets) { + const triggerAt = new Date(todo.dueAt.getTime() - offset.offsetMinutes * 60 * 1000); + if (triggerAt > now) continue; + if (offset.channelInapp && todo.owner.inappEnabled) { + data.push({ + recipientId: todo.ownerId, + targetType: "todo" as const, + targetId: todo.id, + triggerAt, + channel: "inapp" as const, + }); + } + if (offset.channelBark && todo.owner.barkEnabled && todo.owner.barkUrl) { + data.push({ + recipientId: todo.ownerId, + targetType: "todo" as const, + targetId: todo.id, + triggerAt, + channel: "bark" as const, + }); + } + } + + if (data.length > 0) { + await prisma.notification.createMany({ data, skipDuplicates: true }); + } + + if (todo.recurrenceRule && todo.dueAt <= now) { + const next = nextDueAt(todo.dueAt, todo.recurrenceRule); + await prisma.todo.update({ where: { id: todo.id }, data: { dueAt: next } }); + } + } + + const tasks = await prisma.reminderTask.findMany({ + where: { dueAt: { lte: upperBound } }, + include: { + recurrenceRule: true, + recipients: { include: { user: true } }, + }, + }); + const taskOffsets = await prisma.reminderOffset.findMany({ + where: { targetType: "reminder_task", targetId: { in: tasks.map((task) => task.id) } }, + }); + const taskOffsetsById = taskOffsets.reduce>((acc, offset) => { + acc[offset.targetId] = acc[offset.targetId] ?? []; + acc[offset.targetId].push(offset); + return acc; + }, {}); + + for (const task of tasks) { + const offsets = buildOffsets(taskOffsetsById[task.id] ?? []); + const data = []; + for (const offset of offsets) { + const triggerAt = new Date(task.dueAt.getTime() - offset.offsetMinutes * 60 * 1000); + if (triggerAt > now) continue; + for (const recipient of task.recipients) { + const user = recipient.user; + if (offset.channelInapp && user.inappEnabled) { + data.push({ + recipientId: user.id, + targetType: "reminder_task" as const, + targetId: task.id, + triggerAt, + channel: "inapp" as const, + }); + } + if (offset.channelBark && user.barkEnabled && user.barkUrl) { + data.push({ + recipientId: user.id, + targetType: "reminder_task" as const, + targetId: task.id, + triggerAt, + channel: "bark" as const, + }); + } + } + } + + if (data.length > 0) { + await prisma.notification.createMany({ data, skipDuplicates: true }); + } + + if (task.recurrenceRule && task.dueAt <= now) { + const next = nextDueAt(task.dueAt, task.recurrenceRule); + await prisma.reminderTask.update({ where: { id: task.id }, data: { dueAt: next } }); + } + } +}; + +const deliverNotifications = async (now: Date) => { + const expiredLock = new Date(now.getTime() - LOCK_TIMEOUT_MS); + const pending = await prisma.notification.findMany({ + where: { + status: "pending", + triggerAt: { lte: now }, + OR: [{ lockedAt: null }, { lockedAt: { lt: expiredLock } }], + }, + include: { recipient: true }, + take: 50, + }); + + for (const notification of pending) { + const locked = await prisma.notification.updateMany({ + where: { id: notification.id, status: "pending" }, + data: { status: "queued", lockedAt: now }, + }); + if (locked.count === 0) continue; + + try { + if (notification.channel === "inapp") { + await prisma.notification.update({ + where: { id: notification.id }, + data: { status: "sent", sentAt: now, lockedAt: null }, + }); + await prisma.deliveryLog.create({ + data: { + notificationId: notification.id, + attemptNo: 1, + channel: "inapp", + status: "sent", + }, + }); + continue; + } + + const recipient = notification.recipient; + if (!recipient.barkEnabled || !recipient.barkUrl) { + await prisma.notification.update({ + where: { id: notification.id }, + data: { status: "failed", lockedAt: null }, + }); + await prisma.deliveryLog.create({ + data: { + notificationId: notification.id, + attemptNo: 1, + channel: "bark", + status: "failed", + responseMeta: { reason: "bark_disabled" }, + }, + }); + continue; + } + + const title = "Notify 提醒"; + const body = `触发时间:${notification.triggerAt.toISOString()}`; + await sendBarkPush(recipient.barkUrl, { + title, + body, + group: "notify", + }); + + await prisma.notification.update({ + where: { id: notification.id }, + data: { status: "sent", sentAt: now, lockedAt: null }, + }); + await prisma.deliveryLog.create({ + data: { + notificationId: notification.id, + attemptNo: 1, + channel: "bark", + status: "sent", + }, + }); + } catch (error) { + const logs = await prisma.deliveryLog.findMany({ + where: { notificationId: notification.id }, + orderBy: { attemptNo: "desc" }, + take: 1, + }); + const attemptNo = (logs[0]?.attemptNo ?? 0) + 1; + const shouldRetry = attemptNo < 5; + const retryAt = new Date(now.getTime() + calcBackoffMs(attemptNo)); + + await prisma.notification.update({ + where: { id: notification.id }, + data: { + status: shouldRetry ? "pending" : "failed", + lockedAt: null, + triggerAt: shouldRetry ? retryAt : notification.triggerAt, + }, + }); + await prisma.deliveryLog.create({ + data: { + notificationId: notification.id, + attemptNo, + channel: notification.channel, + status: shouldRetry ? "pending" : "failed", + responseMeta: { message: (error as Error).message }, + }, + }); + } + } +}; + +const loop = async () => { + const now = new Date(); + await generateNotifications(now); + await deliverNotifications(now); +}; + +const start = async () => { + // eslint-disable-next-line no-console + console.log("Notify worker started"); + await loop(); + setInterval(loop, 30 * 1000); +}; + +start().catch((error) => { + // eslint-disable-next-line no-console + console.error("Worker error", error); + process.exit(1); +}); diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 0000000..be8e0c6 --- /dev/null +++ b/backend/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "CommonJS", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true + }, + "include": ["src/**/*.ts"] +} diff --git a/backend_rust/.gitignore b/backend_rust/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/backend_rust/.gitignore @@ -0,0 +1 @@ +/target diff --git a/backend_rust/Cargo.lock b/backend_rust/Cargo.lock new file mode 100644 index 0000000..4aaf446 --- /dev/null +++ b/backend_rust/Cargo.lock @@ -0,0 +1,4444 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "actix-codec" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f7b0a21988c1bf877cf4759ef5ddaac04c1c9fe808c9142ecb78ba97d97a28a" +dependencies = [ + "bitflags", + "bytes", + "futures-core", + "futures-sink", + "memchr", + "pin-project-lite", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "actix-cors" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daa239b93927be1ff123eebada5a3ff23e89f0124ccb8609234e5103d5a5ae6d" +dependencies = [ + "actix-utils", + "actix-web", + "derive_more 2.1.1", + "futures-util", + "log", + "once_cell", + "smallvec", +] + +[[package]] +name = "actix-files" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4009a8beb4dc78a58286ac9d58969ee0a8acecb7912d5ce898b4da4335579341" +dependencies = [ + "actix-http", + "actix-service", + "actix-utils", + "actix-web", + "bitflags", + "bytes", + "derive_more 2.1.1", + "futures-core", + "http-range", + "log", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "v_htmlescape", +] + +[[package]] +name = "actix-http" +version = "3.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7926860314cbe2fb5d1f13731e387ab43bd32bca224e82e6e2db85de0a3dba49" +dependencies = [ + "actix-codec", + "actix-rt", + "actix-service", + "actix-utils", + "base64", + "bitflags", + "brotli", + "bytes", + "bytestring", + "derive_more 2.1.1", + "encoding_rs", + "flate2", + "foldhash", + "futures-core", + "h2 0.3.27", + "http 0.2.12", + "httparse", + "httpdate", + "itoa", + "language-tags", + "local-channel", + "mime", + "percent-encoding", + "pin-project-lite", + "rand 0.9.2", + "sha1", + "smallvec", + "tokio", + "tokio-util", + "tracing", + "zstd", +] + +[[package]] +name = "actix-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01ed3140b2f8d422c68afa1ed2e85d996ea619c988ac834d255db32138655cb" +dependencies = [ + "quote", + "syn 2.0.114", +] + +[[package]] +name = "actix-multipart" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5118a26dee7e34e894f7e85aa0ee5080ae4c18bf03c0e30d49a80e418f00a53" +dependencies = [ + "actix-multipart-derive", + "actix-utils", + "actix-web", + "derive_more 0.99.20", + "futures-core", + "futures-util", + "httparse", + "local-waker", + "log", + "memchr", + "mime", + "rand 0.8.5", + "serde", + "serde_json", + "serde_plain", + "tempfile", + "tokio", +] + +[[package]] +name = "actix-multipart-derive" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11eb847f49a700678ea2fa73daeb3208061afa2b9d1a8527c03390f4c4a1c6b" +dependencies = [ + "darling", + "parse-size", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "actix-router" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13d324164c51f63867b57e73ba5936ea151b8a41a1d23d1031eeb9f70d0236f8" +dependencies = [ + "bytestring", + "cfg-if", + "http 0.2.12", + "regex", + "regex-lite", + "serde", + "tracing", +] + +[[package]] +name = "actix-rt" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92589714878ca59a7626ea19734f0e07a6a875197eec751bb5d3f99e64998c63" +dependencies = [ + "futures-core", + "tokio", +] + +[[package]] +name = "actix-server" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a65064ea4a457eaf07f2fba30b4c695bf43b721790e9530d26cb6f9019ff7502" +dependencies = [ + "actix-rt", + "actix-service", + "actix-utils", + "futures-core", + "futures-util", + "mio", + "socket2 0.5.10", + "tokio", + "tracing", +] + +[[package]] +name = "actix-service" +version = "2.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e46f36bf0e5af44bdc4bdb36fbbd421aa98c79a9bce724e1edeb3894e10dc7f" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "actix-utils" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88a1dcdff1466e3c2488e1cb5c36a71822750ad43839937f85d2f4d9f8b705d8" +dependencies = [ + "local-waker", + "pin-project-lite", +] + +[[package]] +name = "actix-web" +version = "4.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1654a77ba142e37f049637a3e5685f864514af11fcbc51cb51eb6596afe5b8d6" +dependencies = [ + "actix-codec", + "actix-http", + "actix-macros", + "actix-router", + "actix-rt", + "actix-server", + "actix-service", + "actix-utils", + "actix-web-codegen", + "bytes", + "bytestring", + "cfg-if", + "cookie", + "derive_more 2.1.1", + "encoding_rs", + "foldhash", + "futures-core", + "futures-util", + "impl-more", + "itoa", + "language-tags", + "log", + "mime", + "once_cell", + "pin-project-lite", + "regex", + "regex-lite", + "serde", + "serde_json", + "serde_urlencoded", + "smallvec", + "socket2 0.6.1", + "time", + "tracing", + "url", +] + +[[package]] +name = "actix-web-codegen" +version = "4.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f591380e2e68490b5dfaf1dd1aa0ebe78d84ba7067078512b4ea6e4492d622b8" +dependencies = [ + "actix-router", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "actix-web-httpauth" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456348ed9dcd72a13a1f4a660449fafdecee9ac8205552e286809eb5b0b29bd3" +dependencies = [ + "actix-utils", + "actix-web", + "base64", + "futures-core", + "futures-util", + "log", + "pin-project-lite", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "ahash" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "version_check", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "aliasable" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "250f629c0161ad8107cf89319e990051fae62832fd343083bea452d93e2205fd" + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "backend_rust" +version = "0.1.0" +dependencies = [ + "actix-cors", + "actix-files", + "actix-multipart", + "actix-web", + "actix-web-httpauth", + "anyhow", + "bcrypt", + "chrono", + "futures-util", + "jsonwebtoken", + "migration", + "rand 0.9.2", + "reqwest", + "sea-orm", + "sea-orm-migration", + "serde", + "serde_json", + "thiserror 1.0.69", + "tokio", + "tracing", + "tracing-subscriber", + "urlencoding", + "uuid", + "validator", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bcrypt" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abaf6da45c74385272ddf00e1ac074c7d8a6c1a1dda376902bd6a427522a8b2c" +dependencies = [ + "base64", + "blowfish", + "getrandom 0.3.4", + "subtle", + "zeroize", +] + +[[package]] +name = "bigdecimal" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d6867f1565b3aad85681f1015055b087fcfd840d6aeee6eee7f2da317603695" +dependencies = [ + "autocfg", + "libm", + "num-bigint", + "num-integer", + "num-traits", + "serde", +] + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +dependencies = [ + "serde_core", +] + +[[package]] +name = "bitvec" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "blowfish" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7" +dependencies = [ + "byteorder", + "cipher", +] + +[[package]] +name = "borsh" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1da5ab77c1437701eeff7c88d968729e7766172279eab0676857b3d63af7a6f" +dependencies = [ + "borsh-derive", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0686c856aa6aac0c4498f936d7d6a02df690f614c03e4d906d1018062b5c5e2c" +dependencies = [ + "once_cell", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "brotli" +version = "8.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bumpalo" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" + +[[package]] +name = "bytecheck" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2" +dependencies = [ + "bytecheck_derive", + "ptr_meta", + "simdutf8", +] + +[[package]] +name = "bytecheck_derive" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" + +[[package]] +name = "bytestring" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "113b4343b5f6617e7ad401ced8de3cc8b012e73a594347c307b90db3e9271289" +dependencies = [ + "bytes", +] + +[[package]] +name = "cc" +version = "1.2.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "755d2fce177175ffca841e9a06afdb2c4ab0f593d53b4dee48147dfaade85932" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chrono" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "clap" +version = "4.5.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6e6ff9dcd79cff5cd969a17a545d79e84ab086e444102a591e288a8aa3ce394" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa42cf4d2b7a41bc8f663a7cab4031ebafa1bf3875705bfaf8466dc60ab52c00" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "clap_lex" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "convert_case" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "cookie" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e859cd57d0710d9e06c381b550c06e76992472a8c6d527aecd2fc673dcc231fb" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.114", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +dependencies = [ + "powerfmt", + "serde_core", +] + +[[package]] +name = "derive_more" +version = "0.99.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" +dependencies = [ + "convert_case 0.4.0", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.114", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case 0.10.0", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.114", + "unicode-xid", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +dependencies = [ + "serde", +] + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "find-msvc-tools" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8591b0bcc8a98a64310a2fae1bb3e9b8564dd10e381e6e28010fde8e8e8568db" + +[[package]] +name = "flate2" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b375d6465b98090a5f25b1c7703f3859783755aa9a80433b36e0379a3ec2f369" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "h2" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http 0.2.12", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http 1.4.0", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http 1.4.0", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http 1.4.0", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "http-range" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573" + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2 0.4.13", + "http 1.4.0", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http 1.4.0", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http 1.4.0", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2 0.6.1", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "impl-more" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8a5a9a0ff0086c7a148acb942baaabeadf9504d10400b5a05645853729b9cd2" + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", +] + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "inherent" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c727f80bfa4a6c6e2508d2f05b6f4bfce242030bd88ed15ae5331c5b5d30fba7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "iri-string" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "jsonwebtoken" +version = "9.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" +dependencies = [ + "base64", + "js-sys", + "pem", + "ring", + "serde", + "serde_json", + "simple_asn1", +] + +[[package]] +name = "language-tags" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4345964bb142484797b161f473a503a434de77149dd8c7427788c6e13379388" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "libc" +version = "0.2.180" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" + +[[package]] +name = "libm" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" + +[[package]] +name = "libredox" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +dependencies = [ + "bitflags", + "libc", + "redox_syscall 0.7.0", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "local-channel" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6cbc85e69b8df4b8bb8b89ec634e7189099cea8927a276b7384ce5488e53ec8" +dependencies = [ + "futures-core", + "futures-sink", + "local-waker", +] + +[[package]] +name = "local-waker" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d873d7c67ce09b42110d801813efbc9364414e356be9935700d368351657487" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "migration" +version = "0.1.0" +dependencies = [ + "sea-orm-migration", + "tokio", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "native-tls" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.5", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openssl" +version = "0.10.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "openssl-sys" +version = "0.9.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "ordered-float" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" +dependencies = [ + "num-traits", +] + +[[package]] +name = "ouroboros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0f050db9c44b97a94723127e6be766ac5c340c48f2c4bb3ffa11713744be59" +dependencies = [ + "aliasable", + "ouroboros_macro", + "static_assertions", +] + +[[package]] +name = "ouroboros_macro" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c7028bdd3d43083f6d8d4d5187680d0d3560d54df4cc9d752005268b41e64d0" +dependencies = [ + "heck 0.4.1", + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "parse-size" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "487f2ccd1e17ce8c1bfab3a65c89525af41cfad4c8659021a1e9a2aacd73b89b" + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pgvector" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc58e2d255979a31caa7cabfa7aac654af0354220719ab7a68520ae7a91e8c0b" +dependencies = [ + "serde", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "pluralizer" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b3eba432a00a1f6c16f39147847a870e94e2e9b992759b503e330efec778cbe" +dependencies = [ + "once_cell", + "regex", +] + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro-crate" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "proc-macro2-diagnostics" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", + "version_check", + "yansi", +] + +[[package]] +name = "ptr_meta" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1" +dependencies = [ + "ptr_meta_derive", +] + +[[package]] +name = "ptr_meta_derive" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "quote" +version = "1.0.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74d9a594b72ae6656596548f56f667211f8a97b3d4c3d467150794690dc40a" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_syscall" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49f3fe0889e69e2ae9e41f4d6c4c0181701d00e4697b356fb1f74173a5e0ee27" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-lite" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d942b98df5e658f56f20d592c7f868833fe38115e65c33003d8cd224b0155da" + +[[package]] +name = "regex-syntax" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" + +[[package]] +name = "rend" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c" +dependencies = [ + "bytecheck", +] + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-core", + "h2 0.4.13", + "http 1.4.0", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "mime", + "native-tls", + "percent-encoding", + "pin-project-lite", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rkyv" +version = "0.7.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2297bf9c81a3f0dc96bc9521370b88f054168c29826a75e89c55ff196e7ed6a1" +dependencies = [ + "bitvec", + "bytecheck", + "bytes", + "hashbrown 0.12.3", + "ptr_meta", + "rend", + "rkyv_derive", + "seahash", + "tinyvec", + "uuid", +] + +[[package]] +name = "rkyv_derive" +version = "0.7.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d7b42d4b8d06048d3ac8db0eb31bcb942cbeb709f0b5f2b2ebde398d3038f5" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rust_decimal" +version = "1.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61f703d19852dbf87cbc513643fa81428361eb6940f1ac14fd58155d295a3eb0" +dependencies = [ + "arrayvec", + "borsh", + "bytes", + "num-traits", + "rand 0.8.5", + "rkyv", + "serde", + "serde_json", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" + +[[package]] +name = "schannel" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sea-bae" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f694a6ab48f14bc063cfadff30ab551d3c7e46d8f81836c51989d548f44a2a25" +dependencies = [ + "heck 0.4.1", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "sea-orm" +version = "2.0.0-rc.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fe6e5203d25568227d8dfbbfb362051e1ccac66bd5200538ed0f50f763cd980" +dependencies = [ + "async-stream", + "async-trait", + "bigdecimal", + "chrono", + "derive_more 2.1.1", + "futures-util", + "itertools", + "log", + "ouroboros", + "pgvector", + "rust_decimal", + "sea-orm-macros", + "sea-query", + "sea-query-sqlx", + "sea-schema", + "serde", + "serde_json", + "sqlx", + "strum", + "thiserror 2.0.18", + "time", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "sea-orm-cli" +version = "2.0.0-rc.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebedf30b59f3f7ee88baabb157d824fd30c32ce3c8ff2512196848ba00e049f0" +dependencies = [ + "chrono", + "clap", + "dotenvy", + "glob", + "indoc", + "regex", + "sea-schema", + "sqlx", + "tokio", + "tracing", + "tracing-subscriber", + "url", +] + +[[package]] +name = "sea-orm-macros" +version = "2.0.0-rc.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "719c5ba754a5cb517f9ac6fc9f581bfb791ed1aabfc4e72faa9ab810922b87ad" +dependencies = [ + "heck 0.5.0", + "pluralizer", + "proc-macro2", + "quote", + "sea-bae", + "syn 2.0.114", + "unicode-ident", +] + +[[package]] +name = "sea-orm-migration" +version = "2.0.0-rc.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf48f4281089ce7440f30a6617e0b7083e70f248a9bc1c46ab06ba113b5f41bb" +dependencies = [ + "async-trait", + "clap", + "dotenvy", + "sea-orm", + "sea-orm-cli", + "sea-schema", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "sea-query" +version = "1.0.0-rc.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93948054fb2d208555a96d03d2c887591deb42ffe3210eedbd8a3234c6fb6d34" +dependencies = [ + "chrono", + "inherent", + "ordered-float", + "rust_decimal", + "sea-query-derive", + "serde_json", + "time", + "uuid", +] + +[[package]] +name = "sea-query-derive" +version = "1.0.0-rc.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "365d236217f5daa4f40d3c9998ff3921351b53472da50308e384388162353b3a" +dependencies = [ + "darling", + "heck 0.4.1", + "proc-macro2", + "quote", + "syn 2.0.114", + "thiserror 2.0.18", +] + +[[package]] +name = "sea-query-sqlx" +version = "0.8.0-rc.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "693f3ac3a10a228afaf3512b122cffc07c57b4269d233c7ff60571ebb4f0dd17" +dependencies = [ + "chrono", + "rust_decimal", + "sea-query", + "serde_json", + "sqlx", + "time", + "uuid", +] + +[[package]] +name = "sea-schema" +version = "0.17.0-rc.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b363dd21c20fe4d1488819cb2bc7f8d4696c62dd9f39554f97639f54d57dd0ab" +dependencies = [ + "async-trait", + "sea-query", + "sea-query-sqlx", + "sea-schema-derive", + "sqlx", +] + +[[package]] +name = "sea-schema-derive" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "debdc8729c37fdbf88472f97fd470393089f997a909e535ff67c544d18cfccf0" +dependencies = [ + "heck 0.4.1", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "seahash" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_plain" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce1fc6db65a611022b23a0dec6975d63fb80a302cb3388835ff02c097258d50" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "simple_asn1" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "297f631f50729c8c99b84667867963997ec0b50f32b2a7dbcab828ef0541e8bb" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "slab" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlx" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" +dependencies = [ + "base64", + "bytes", + "chrono", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.15.5", + "hashlink", + "indexmap", + "log", + "memchr", + "once_cell", + "percent-encoding", + "rust_decimal", + "rustls", + "serde", + "serde_json", + "sha2", + "smallvec", + "thiserror 2.0.18", + "time", + "tokio", + "tokio-stream", + "tracing", + "url", + "uuid", + "webpki-roots 0.26.11", +] + +[[package]] +name = "sqlx-macros" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn 2.0.114", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" +dependencies = [ + "dotenvy", + "either", + "heck 0.5.0", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn 2.0.114", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" +dependencies = [ + "atoi", + "base64", + "bitflags", + "byteorder", + "bytes", + "chrono", + "crc", + "digest", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand 0.8.5", + "rsa", + "rust_decimal", + "serde", + "sha1", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.18", + "time", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" +dependencies = [ + "atoi", + "base64", + "bitflags", + "byteorder", + "chrono", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "home", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "rand 0.8.5", + "rust_decimal", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.18", + "time", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" +dependencies = [ + "atoi", + "chrono", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "serde_urlencoded", + "sqlx-core", + "thiserror 2.0.18", + "time", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "system-configuration" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" +dependencies = [ + "bitflags", + "core-foundation", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "tempfile" +version = "3.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9e442fc33d7fdb45aa9bfeb312c095964abdf596f7567261062b2a7107aaabd" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b36ee98fd31ec7426d599183e8fe26932a8dc1fb76ddb6214d05493377d34ca" + +[[package]] +name = "time-macros" +version = "0.2.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e552d1249bf61ac2a52db88179fd0673def1e1ad8243a00d9ec9ed71fee3dd" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.6.1", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.23.10+spec-1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.0.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44" +dependencies = [ + "winnow", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http 1.4.0", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2e054861b4bd027cd373e18e8d8d8e6548085000e41290d95ce0c373a654b4a" +dependencies = [ + "getrandom 0.3.4", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "v_htmlescape" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e8257fbc510f0a46eb602c10215901938b5c2a7d5e70fc11483b1d3c9b5b18c" + +[[package]] +name = "validator" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43fb22e1a008ece370ce08a3e9e4447a910e92621bb49b85d6e48a45397e7cfa" +dependencies = [ + "idna", + "once_cell", + "regex", + "serde", + "serde_derive", + "serde_json", + "url", + "validator_derive", +] + +[[package]] +name = "validator_derive" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7df16e474ef958526d1205f6dda359fdfab79d9aa6d54bafcb92dcd07673dca" +dependencies = [ + "darling", + "once_cell", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" +dependencies = [ + "cfg-if", + "futures-util", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.114", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.5", +] + +[[package]] +name = "webpki-roots" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12bed680863276c63889429bfd6cab3b99943659923822de1c8a39c49e4d722c" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "whoami" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "wasite", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.7.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668f5168d10b9ee831de31933dc111a459c97ec93225beb307aed970d1372dfd" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c7962b26b0a8685668b671ee4b54d007a67d4eaf05fda79ac0ecf41e32270f1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "zmij" +version = "1.0.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfcd145825aace48cff44a8844de64bf75feec3080e0aa5cdbde72961ae51a65" + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/backend_rust/Cargo.toml b/backend_rust/Cargo.toml new file mode 100644 index 0000000..e3cd509 --- /dev/null +++ b/backend_rust/Cargo.toml @@ -0,0 +1,34 @@ +[workspace] +members = [".", "migration"] +resolver = "2" + +[package] +name = "backend_rust" +version = "0.1.0" +edition = "2024" + +[dependencies] +tokio = { version = "1.49.0", features = ["full"] } +actix-web = "4" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +chrono = { version = "0", features = ["serde"] } +sea-orm = { version = "2.0.0-rc", features = ["runtime-tokio-rustls", "sqlx-postgres", "macros", "with-chrono", "with-uuid", "with-json"] } +tracing = "0.1" +tracing-subscriber = "0.3" +anyhow = "1" +thiserror = "1" +bcrypt = "0.17" +jsonwebtoken = "9" +uuid = { version = "1", features = ["v4", "serde"] } +validator = { version = "0.20", features = ["derive"] } +actix-web-httpauth = "0.8" +rand = "0.9" +reqwest = { version = "0.12", features = ["json"] } +urlencoding = "2" +actix-cors = "0.7.1" +actix-files = "0.6" +actix-multipart = "0.7" +futures-util = "0.3" +sea-orm-migration = "2.0.0-rc" +migration = { path = "./migration" } \ No newline at end of file diff --git a/backend_rust/Dockerfile b/backend_rust/Dockerfile new file mode 100644 index 0000000..c4b1d77 --- /dev/null +++ b/backend_rust/Dockerfile @@ -0,0 +1,46 @@ +# Build stage +FROM rust:1.84-alpine AS builder + +RUN apk add --no-cache musl-dev pkgconfig openssl-dev + +WORKDIR /app + +# Copy manifests +COPY Cargo.toml Cargo.lock ./ +COPY migration/Cargo.toml ./migration/ + +# Create dummy files to build dependencies +RUN mkdir src && echo "fn main() {}" > src/main.rs +RUN mkdir -p migration/src && echo "fn main() {}" > migration/src/main.rs && echo "" > migration/src/lib.rs + +# Build dependencies only +RUN cargo build --release + +# Remove dummy files +RUN rm -rf src migration/src + +# Copy actual source code +COPY src ./src +COPY migration/src ./migration/src + +# Build the actual application +RUN touch src/main.rs migration/src/main.rs migration/src/lib.rs +RUN cargo build --release + +# Runtime stage +FROM alpine:3.21 + +RUN apk add --no-cache ca-certificates libgcc + +WORKDIR /app + +# Copy the binary from builder +COPY --from=builder /app/target/release/backend_rust /app/backend_rust +COPY --from=builder /app/target/release/migration /app/migration + +# Create uploads directory +RUN mkdir -p /app/uploads/avatars + +EXPOSE 4000 + +CMD ["/app/backend_rust"] diff --git a/backend_rust/README.md b/backend_rust/README.md new file mode 100644 index 0000000..e69de29 diff --git a/backend_rust/migration/Cargo.toml b/backend_rust/migration/Cargo.toml new file mode 100644 index 0000000..0c073af --- /dev/null +++ b/backend_rust/migration/Cargo.toml @@ -0,0 +1,22 @@ +[package] +edition = "2024" +name = "migration" +publish = false +version = "0.1.0" + +[lib] +name = "migration" +path = "src/lib.rs" + +[dependencies] +tokio = { version = "1", features = ["macros", "rt", "rt-multi-thread"] } + +[dependencies.sea-orm-migration] +features = [ + # Enable at least one `ASYNC_RUNTIME` and `DATABASE_DRIVER` feature if you want to run migration via CLI. + # View the list of supported features at https://www.sea-ql.org/SeaORM/docs/install-and-config/database-and-async-runtime. + # e.g. + "runtime-tokio-rustls", + "sqlx-postgres", +] +version = "~2.0.0-rc" diff --git a/backend_rust/migration/README.md b/backend_rust/migration/README.md new file mode 100644 index 0000000..1b0ce2e --- /dev/null +++ b/backend_rust/migration/README.md @@ -0,0 +1,47 @@ +# Running Migrator CLI + +- Generate a new migration file + ```sh + cargo run -- generate MIGRATION_NAME + ``` +- Apply all pending migrations + ```sh + cargo run + ``` + ```sh + cargo run -- up + ``` +- Apply first 10 pending migrations + ```sh + cargo run -- up -n 10 + ``` +- Rollback last applied migrations + ```sh + cargo run -- down + ``` +- Rollback last 10 applied migrations + ```sh + cargo run -- down -n 10 + ``` +- Drop all tables from the database, then reapply all migrations + ```sh + cargo run -- fresh + ``` +- Rollback all applied migrations, then reapply all migrations + ```sh + cargo run -- refresh + ``` +- Rollback all applied migrations + ```sh + cargo run -- reset + ``` +- Check the status of all migrations + ```sh + cargo run -- status + ``` + + +- Generate entity from sea-orm-cli + ```sh + sea-orm-cli generate entity --database-url postgres://dyc:dycdyc89@192.168.150.142/notify --output-dir ./src/entity --entity-format dense + ``` \ No newline at end of file diff --git a/backend_rust/migration/src/lib.rs b/backend_rust/migration/src/lib.rs new file mode 100644 index 0000000..366f6a0 --- /dev/null +++ b/backend_rust/migration/src/lib.rs @@ -0,0 +1,42 @@ +pub use sea_orm_migration::prelude::*; + +mod m20220101_000001_create_user; +mod m20220101_000002_create_enums; +mod m20220101_000003_create_invite; +mod m20220101_000004_create_recurrence_rule; +mod m20220101_000005_create_todo; +mod m20220101_000006_create_reminder_task; +mod m20220101_000007_create_reminder_task_recipient; +mod m20220101_000008_create_reminder_offset; +mod m20220101_000009_create_notification; +mod m20220101_000010_create_delivery_log; +mod m20260128_000011_modify_todo; +mod m20260129_000012_add_bark_params; +mod m20260129_000013_add_notification_offset_id; +mod m20260129_000014_convert_timestamps_to_timestamptz; +mod m20260129_000015_add_user_invite_id; + +pub struct Migrator; + +#[async_trait::async_trait] +impl MigratorTrait for Migrator { + fn migrations() -> Vec> { + vec![ + Box::new(m20220101_000001_create_user::Migration), + Box::new(m20220101_000002_create_enums::Migration), + Box::new(m20220101_000003_create_invite::Migration), + Box::new(m20220101_000004_create_recurrence_rule::Migration), + Box::new(m20220101_000005_create_todo::Migration), + Box::new(m20220101_000006_create_reminder_task::Migration), + Box::new(m20220101_000007_create_reminder_task_recipient::Migration), + Box::new(m20220101_000008_create_reminder_offset::Migration), + Box::new(m20220101_000009_create_notification::Migration), + Box::new(m20220101_000010_create_delivery_log::Migration), + Box::new(m20260128_000011_modify_todo::Migration), + Box::new(m20260129_000012_add_bark_params::Migration), + Box::new(m20260129_000013_add_notification_offset_id::Migration), + Box::new(m20260129_000014_convert_timestamps_to_timestamptz::Migration), + Box::new(m20260129_000015_add_user_invite_id::Migration), + ] + } +} diff --git a/backend_rust/migration/src/m20220101_000001_create_user.rs b/backend_rust/migration/src/m20220101_000001_create_user.rs new file mode 100644 index 0000000..1970d63 --- /dev/null +++ b/backend_rust/migration/src/m20220101_000001_create_user.rs @@ -0,0 +1,74 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(User::Table) + .if_not_exists() + .col(ColumnDef::new(User::Id).uuid().primary_key().not_null()) + .col( + ColumnDef::new(User::Username) + .string() + .unique_key() + .not_null(), + ) + .col(ColumnDef::new(User::PasswordHash).string().not_null()) + .col(ColumnDef::new(User::Avatar).string().null()) + .col( + ColumnDef::new(User::Timezone) + .string() + .not_null() + .default("Asia/Shanghai"), + ) + .col(ColumnDef::new(User::BarkUrl).string().null()) + .col( + ColumnDef::new(User::InappEnabled) + .boolean() + .not_null() + .default(true), + ) + .col( + ColumnDef::new(User::BarkEnabled) + .boolean() + .not_null() + .default(false), + ) + .col( + ColumnDef::new(User::CreatedAt) + .timestamp() + .not_null() + .extra("DEFAULT NOW()"), + ) + .col(ColumnDef::new(User::UpdatedAt).timestamp().not_null()) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table("User").to_owned()) + .await + } +} + +#[derive(DeriveIden)] +enum User { + Table, + Id, + Username, + PasswordHash, + Avatar, + Timezone, + BarkUrl, + InappEnabled, + BarkEnabled, + CreatedAt, + UpdatedAt, +} diff --git a/backend_rust/migration/src/m20220101_000002_create_enums.rs b/backend_rust/migration/src/m20220101_000002_create_enums.rs new file mode 100644 index 0000000..bedbe69 --- /dev/null +++ b/backend_rust/migration/src/m20220101_000002_create_enums.rs @@ -0,0 +1,137 @@ +use sea_orm_migration::prelude::*; +use sea_query::extension::postgres::Type; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + // Create RecurrenceType enum + manager + .create_type( + Type::create() + .as_enum(RecurrenceType::Type) + .values([ + RecurrenceType::Hourly, + RecurrenceType::Daily, + RecurrenceType::Weekly, + RecurrenceType::Monthly, + RecurrenceType::Yearly, + ]) + .to_owned(), + ) + .await?; + + // Create TargetType enum + manager + .create_type( + Type::create() + .as_enum(TargetType::Type) + .values([TargetType::Todo, TargetType::ReminderTask]) + .to_owned(), + ) + .await?; + + // Create ChannelType enum + manager + .create_type( + Type::create() + .as_enum(ChannelType::Type) + .values([ChannelType::Inapp, ChannelType::Bark]) + .to_owned(), + ) + .await?; + + // Create NotificationStatus enum + manager + .create_type( + Type::create() + .as_enum(NotificationStatus::Type) + .values([ + NotificationStatus::Pending, + NotificationStatus::Queued, + NotificationStatus::Sent, + NotificationStatus::Failed, + ]) + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_type(Type::drop().name(NotificationStatus::Type).to_owned()) + .await?; + + manager + .drop_type(Type::drop().name(ChannelType::Type).to_owned()) + .await?; + + manager + .drop_type(Type::drop().name(TargetType::Type).to_owned()) + .await?; + + manager + .drop_type(Type::drop().name(RecurrenceType::Type).to_owned()) + .await?; + + Ok(()) + } +} + +// RecurrenceType enum +#[derive(DeriveIden)] +pub enum RecurrenceType { + #[sea_orm(iden = "recurrence_type")] + Type, + #[sea_orm(iden = "hourly")] + Hourly, + #[sea_orm(iden = "daily")] + Daily, + #[sea_orm(iden = "weekly")] + Weekly, + #[sea_orm(iden = "monthly")] + Monthly, + #[sea_orm(iden = "yearly")] + Yearly, +} + +// TargetType enum +#[derive(DeriveIden)] +pub enum TargetType { + #[sea_orm(iden = "target_type")] + Type, + #[sea_orm(iden = "todo")] + Todo, + #[sea_orm(iden = "reminder_task")] + ReminderTask, +} + +// ChannelType enum +#[derive(DeriveIden)] +pub enum ChannelType { + #[sea_orm(iden = "channel_type")] + Type, + #[sea_orm(iden = "inapp")] + Inapp, + #[sea_orm(iden = "bark")] + Bark, +} + +// NotificationStatus enum +#[derive(DeriveIden)] +pub enum NotificationStatus { + #[sea_orm(iden = "notification_status")] + Type, + #[sea_orm(iden = "pending")] + Pending, + #[sea_orm(iden = "queued")] + Queued, + #[sea_orm(iden = "sent")] + Sent, + #[sea_orm(iden = "failed")] + Failed, +} diff --git a/backend_rust/migration/src/m20220101_000003_create_invite.rs b/backend_rust/migration/src/m20220101_000003_create_invite.rs new file mode 100644 index 0000000..c3e7225 --- /dev/null +++ b/backend_rust/migration/src/m20220101_000003_create_invite.rs @@ -0,0 +1,102 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(Invite::Table) + .if_not_exists() + .col(ColumnDef::new(Invite::Id).uuid().primary_key().not_null()) + .col( + ColumnDef::new(Invite::Code) + .string() + .unique_key() + .not_null(), + ) + .col(ColumnDef::new(Invite::CreatorId).uuid().not_null()) + .col( + ColumnDef::new(Invite::MaxUses) + .integer() + .not_null() + .default(5), + ) + .col( + ColumnDef::new(Invite::UsedCount) + .integer() + .not_null() + .default(0), + ) + .col(ColumnDef::new(Invite::ExpiresAt).timestamp().not_null()) + .col(ColumnDef::new(Invite::RevokedAt).timestamp().null()) + .col( + ColumnDef::new(Invite::CreatedAt) + .timestamp() + .not_null() + .extra("DEFAULT NOW()"), + ) + .foreign_key( + ForeignKey::create() + .name("FK_invite_creator") + .from(Invite::Table, Invite::CreatorId) + .to(User::Table, User::Id) + .on_delete(ForeignKeyAction::Cascade) + .on_update(ForeignKeyAction::Cascade), + ) + .to_owned(), + ) + .await?; + + // Create indexes + manager + .create_index( + Index::create() + .name("IDX_invite_creator_id") + .table(Invite::Table) + .col(Invite::CreatorId) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("IDX_invite_expires_at") + .table(Invite::Table) + .col(Invite::ExpiresAt) + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(Invite::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +enum Invite { + Table, + Id, + Code, + CreatorId, + MaxUses, + UsedCount, + ExpiresAt, + RevokedAt, + CreatedAt, +} + +#[derive(DeriveIden)] +enum User { + Table, + Id, +} diff --git a/backend_rust/migration/src/m20220101_000004_create_recurrence_rule.rs b/backend_rust/migration/src/m20220101_000004_create_recurrence_rule.rs new file mode 100644 index 0000000..ef06e38 --- /dev/null +++ b/backend_rust/migration/src/m20220101_000004_create_recurrence_rule.rs @@ -0,0 +1,75 @@ +use sea_orm_migration::prelude::*; + +use crate::m20220101_000002_create_enums::RecurrenceType; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(RecurrenceRule::Table) + .if_not_exists() + .col( + ColumnDef::new(RecurrenceRule::Id) + .uuid() + .primary_key() + .not_null(), + ) + .col( + ColumnDef::new(RecurrenceRule::Type) + .custom(RecurrenceType::Type) + .not_null(), + ) + .col( + ColumnDef::new(RecurrenceRule::Interval) + .integer() + .not_null() + .default(1), + ) + .col(ColumnDef::new(RecurrenceRule::ByWeekday).integer().null()) + .col(ColumnDef::new(RecurrenceRule::ByMonthday).integer().null()) + .col( + ColumnDef::new(RecurrenceRule::Timezone) + .string() + .not_null() + .default("Asia/Shanghai"), + ) + .col( + ColumnDef::new(RecurrenceRule::CreatedAt) + .timestamp() + .not_null() + .extra("DEFAULT NOW()"), + ) + .col( + ColumnDef::new(RecurrenceRule::UpdatedAt) + .timestamp() + .not_null(), + ) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(RecurrenceRule::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +enum RecurrenceRule { + Table, + Id, + Type, + Interval, + ByWeekday, + ByMonthday, + Timezone, + CreatedAt, + UpdatedAt, +} diff --git a/backend_rust/migration/src/m20220101_000005_create_todo.rs b/backend_rust/migration/src/m20220101_000005_create_todo.rs new file mode 100644 index 0000000..5155048 --- /dev/null +++ b/backend_rust/migration/src/m20220101_000005_create_todo.rs @@ -0,0 +1,102 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(Todo::Table) + .if_not_exists() + .col(ColumnDef::new(Todo::Id).uuid().primary_key().not_null()) + .col(ColumnDef::new(Todo::OwnerId).uuid().not_null()) + .col(ColumnDef::new(Todo::Title).string().not_null()) + .col(ColumnDef::new(Todo::Description).string().null()) + .col(ColumnDef::new(Todo::DueAt).timestamp().not_null()) + .col(ColumnDef::new(Todo::RecurrenceRuleId).uuid().null()) + .col( + ColumnDef::new(Todo::CreatedAt) + .timestamp() + .not_null() + .extra("DEFAULT NOW()"), + ) + .col(ColumnDef::new(Todo::UpdatedAt).timestamp().not_null()) + .foreign_key( + ForeignKey::create() + .name("FK_todo_owner") + .from(Todo::Table, Todo::OwnerId) + .to(User::Table, User::Id) + .on_delete(ForeignKeyAction::Cascade) + .on_update(ForeignKeyAction::Cascade), + ) + .foreign_key( + ForeignKey::create() + .name("FK_todo_recurrence_rule") + .from(Todo::Table, Todo::RecurrenceRuleId) + .to(RecurrenceRule::Table, RecurrenceRule::Id) + .on_delete(ForeignKeyAction::SetNull) + .on_update(ForeignKeyAction::Cascade), + ) + .to_owned(), + ) + .await?; + + // Create indexes + manager + .create_index( + Index::create() + .name("IDX_todo_owner_due") + .table(Todo::Table) + .col(Todo::OwnerId) + .col(Todo::DueAt) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("IDX_todo_recurrence_rule_id") + .table(Todo::Table) + .col(Todo::RecurrenceRuleId) + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(Todo::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +enum Todo { + Table, + Id, + OwnerId, + Title, + Description, + DueAt, + RecurrenceRuleId, + CreatedAt, + UpdatedAt, +} + +#[derive(DeriveIden)] +enum User { + Table, + Id, +} + +#[derive(DeriveIden)] +enum RecurrenceRule { + Table, + Id, +} diff --git a/backend_rust/migration/src/m20220101_000006_create_reminder_task.rs b/backend_rust/migration/src/m20220101_000006_create_reminder_task.rs new file mode 100644 index 0000000..723b9c4 --- /dev/null +++ b/backend_rust/migration/src/m20220101_000006_create_reminder_task.rs @@ -0,0 +1,111 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(ReminderTask::Table) + .if_not_exists() + .col( + ColumnDef::new(ReminderTask::Id) + .uuid() + .primary_key() + .not_null(), + ) + .col(ColumnDef::new(ReminderTask::CreatorId).uuid().not_null()) + .col(ColumnDef::new(ReminderTask::Title).string().not_null()) + .col(ColumnDef::new(ReminderTask::Description).string().null()) + .col(ColumnDef::new(ReminderTask::DueAt).timestamp().not_null()) + .col(ColumnDef::new(ReminderTask::RecurrenceRuleId).uuid().null()) + .col( + ColumnDef::new(ReminderTask::CreatedAt) + .timestamp() + .not_null() + .extra("DEFAULT NOW()"), + ) + .col( + ColumnDef::new(ReminderTask::UpdatedAt) + .timestamp() + .not_null(), + ) + .foreign_key( + ForeignKey::create() + .name("FK_reminder_task_creator") + .from(ReminderTask::Table, ReminderTask::CreatorId) + .to(User::Table, User::Id) + .on_delete(ForeignKeyAction::Cascade) + .on_update(ForeignKeyAction::Cascade), + ) + .foreign_key( + ForeignKey::create() + .name("FK_reminder_task_recurrence_rule") + .from(ReminderTask::Table, ReminderTask::RecurrenceRuleId) + .to(RecurrenceRule::Table, RecurrenceRule::Id) + .on_delete(ForeignKeyAction::SetNull) + .on_update(ForeignKeyAction::Cascade), + ) + .to_owned(), + ) + .await?; + + // Create indexes + manager + .create_index( + Index::create() + .name("IDX_reminder_task_creator_due") + .table(ReminderTask::Table) + .col(ReminderTask::CreatorId) + .col(ReminderTask::DueAt) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("IDX_reminder_task_recurrence_rule_id") + .table(ReminderTask::Table) + .col(ReminderTask::RecurrenceRuleId) + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(ReminderTask::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +enum ReminderTask { + Table, + Id, + CreatorId, + Title, + Description, + DueAt, + RecurrenceRuleId, + CreatedAt, + UpdatedAt, +} + +#[derive(DeriveIden)] +enum User { + Table, + Id, +} + +#[derive(DeriveIden)] +enum RecurrenceRule { + Table, + Id, +} diff --git a/backend_rust/migration/src/m20220101_000007_create_reminder_task_recipient.rs b/backend_rust/migration/src/m20220101_000007_create_reminder_task_recipient.rs new file mode 100644 index 0000000..8ce8389 --- /dev/null +++ b/backend_rust/migration/src/m20220101_000007_create_reminder_task_recipient.rs @@ -0,0 +1,87 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(ReminderTaskRecipient::Table) + .if_not_exists() + .col( + ColumnDef::new(ReminderTaskRecipient::TaskId) + .uuid() + .not_null(), + ) + .col( + ColumnDef::new(ReminderTaskRecipient::UserId) + .uuid() + .not_null(), + ) + .primary_key( + Index::create() + .col(ReminderTaskRecipient::TaskId) + .col(ReminderTaskRecipient::UserId), + ) + .foreign_key( + ForeignKey::create() + .name("FK_reminder_task_recipient_task") + .from(ReminderTaskRecipient::Table, ReminderTaskRecipient::TaskId) + .to(ReminderTask::Table, ReminderTask::Id) + .on_delete(ForeignKeyAction::Cascade) + .on_update(ForeignKeyAction::Cascade), + ) + .foreign_key( + ForeignKey::create() + .name("FK_reminder_task_recipient_user") + .from(ReminderTaskRecipient::Table, ReminderTaskRecipient::UserId) + .to(User::Table, User::Id) + .on_delete(ForeignKeyAction::Cascade) + .on_update(ForeignKeyAction::Cascade), + ) + .to_owned(), + ) + .await?; + + // Create index on user_id for reverse lookups + manager + .create_index( + Index::create() + .name("IDX_reminder_task_recipient_user_id") + .table(ReminderTaskRecipient::Table) + .col(ReminderTaskRecipient::UserId) + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(ReminderTaskRecipient::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +enum ReminderTaskRecipient { + Table, + TaskId, + UserId, +} + +#[derive(DeriveIden)] +enum ReminderTask { + Table, + Id, +} + +#[derive(DeriveIden)] +enum User { + Table, + Id, +} diff --git a/backend_rust/migration/src/m20220101_000008_create_reminder_offset.rs b/backend_rust/migration/src/m20220101_000008_create_reminder_offset.rs new file mode 100644 index 0000000..c2cfc8e --- /dev/null +++ b/backend_rust/migration/src/m20220101_000008_create_reminder_offset.rs @@ -0,0 +1,87 @@ +use sea_orm_migration::prelude::*; + +use crate::m20220101_000002_create_enums::TargetType; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(ReminderOffset::Table) + .if_not_exists() + .col( + ColumnDef::new(ReminderOffset::Id) + .uuid() + .primary_key() + .not_null(), + ) + .col( + ColumnDef::new(ReminderOffset::TargetType) + .custom(TargetType::Type) + .not_null(), + ) + .col(ColumnDef::new(ReminderOffset::TargetId).uuid().not_null()) + .col( + ColumnDef::new(ReminderOffset::OffsetMinutes) + .integer() + .not_null(), + ) + .col( + ColumnDef::new(ReminderOffset::ChannelInapp) + .boolean() + .not_null() + .default(true), + ) + .col( + ColumnDef::new(ReminderOffset::ChannelBark) + .boolean() + .not_null() + .default(false), + ) + .col( + ColumnDef::new(ReminderOffset::CreatedAt) + .timestamp() + .not_null() + .extra("DEFAULT NOW()"), + ) + .to_owned(), + ) + .await?; + + // Create index for polymorphic lookup + manager + .create_index( + Index::create() + .name("IDX_reminder_offset_target") + .table(ReminderOffset::Table) + .col(ReminderOffset::TargetType) + .col(ReminderOffset::TargetId) + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(ReminderOffset::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +enum ReminderOffset { + Table, + Id, + TargetType, + TargetId, + OffsetMinutes, + ChannelInapp, + ChannelBark, + CreatedAt, +} diff --git a/backend_rust/migration/src/m20220101_000009_create_notification.rs b/backend_rust/migration/src/m20220101_000009_create_notification.rs new file mode 100644 index 0000000..dc2c03c --- /dev/null +++ b/backend_rust/migration/src/m20220101_000009_create_notification.rs @@ -0,0 +1,141 @@ +use sea_orm_migration::prelude::*; + +use crate::m20220101_000002_create_enums::{ChannelType, NotificationStatus, TargetType}; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(Notification::Table) + .if_not_exists() + .col( + ColumnDef::new(Notification::Id) + .uuid() + .primary_key() + .not_null(), + ) + .col(ColumnDef::new(Notification::RecipientId).uuid().not_null()) + .col( + ColumnDef::new(Notification::TargetType) + .custom(TargetType::Type) + .not_null(), + ) + .col(ColumnDef::new(Notification::TargetId).uuid().not_null()) + .col( + ColumnDef::new(Notification::TriggerAt) + .timestamp() + .not_null(), + ) + .col( + ColumnDef::new(Notification::Channel) + .custom(ChannelType::Type) + .not_null(), + ) + .col( + ColumnDef::new(Notification::Status) + .custom(NotificationStatus::Type) + .not_null() + .default("pending"), + ) + .col(ColumnDef::new(Notification::LockedAt).timestamp().null()) + .col(ColumnDef::new(Notification::SentAt).timestamp().null()) + .col(ColumnDef::new(Notification::ReadAt).timestamp().null()) + .col( + ColumnDef::new(Notification::CreatedAt) + .timestamp() + .not_null() + .extra("DEFAULT NOW()"), + ) + .col( + ColumnDef::new(Notification::UpdatedAt) + .timestamp() + .not_null(), + ) + .foreign_key( + ForeignKey::create() + .name("FK_notification_recipient") + .from(Notification::Table, Notification::RecipientId) + .to(User::Table, User::Id) + .on_delete(ForeignKeyAction::Cascade) + .on_update(ForeignKeyAction::Cascade), + ) + .to_owned(), + ) + .await?; + + // Create unique constraint + manager + .create_index( + Index::create() + .name("UQ_notification_recipient_target_trigger_channel") + .table(Notification::Table) + .col(Notification::RecipientId) + .col(Notification::TargetType) + .col(Notification::TargetId) + .col(Notification::TriggerAt) + .col(Notification::Channel) + .unique() + .to_owned(), + ) + .await?; + + // Create indexes + manager + .create_index( + Index::create() + .name("IDX_notification_status_trigger") + .table(Notification::Table) + .col(Notification::Status) + .col(Notification::TriggerAt) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("IDX_notification_recipient_read") + .table(Notification::Table) + .col(Notification::RecipientId) + .col(Notification::ReadAt) + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(Notification::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +enum Notification { + Table, + Id, + RecipientId, + TargetType, + TargetId, + TriggerAt, + Channel, + Status, + LockedAt, + SentAt, + ReadAt, + CreatedAt, + UpdatedAt, +} + +#[derive(DeriveIden)] +enum User { + Table, + Id, +} diff --git a/backend_rust/migration/src/m20220101_000010_create_delivery_log.rs b/backend_rust/migration/src/m20220101_000010_create_delivery_log.rs new file mode 100644 index 0000000..8284ccc --- /dev/null +++ b/backend_rust/migration/src/m20220101_000010_create_delivery_log.rs @@ -0,0 +1,94 @@ +use sea_orm_migration::prelude::*; + +use crate::m20220101_000002_create_enums::{ChannelType, NotificationStatus}; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(DeliveryLog::Table) + .if_not_exists() + .col( + ColumnDef::new(DeliveryLog::Id) + .uuid() + .primary_key() + .not_null(), + ) + .col( + ColumnDef::new(DeliveryLog::NotificationId) + .uuid() + .not_null(), + ) + .col(ColumnDef::new(DeliveryLog::AttemptNo).integer().not_null()) + .col( + ColumnDef::new(DeliveryLog::Channel) + .custom(ChannelType::Type) + .not_null(), + ) + .col( + ColumnDef::new(DeliveryLog::Status) + .custom(NotificationStatus::Type) + .not_null(), + ) + .col(ColumnDef::new(DeliveryLog::ResponseMeta).json_binary().null()) + .col( + ColumnDef::new(DeliveryLog::CreatedAt) + .timestamp() + .not_null() + .extra("DEFAULT NOW()"), + ) + .foreign_key( + ForeignKey::create() + .name("FK_delivery_log_notification") + .from(DeliveryLog::Table, DeliveryLog::NotificationId) + .to(Notification::Table, Notification::Id) + .on_delete(ForeignKeyAction::Cascade) + .on_update(ForeignKeyAction::Cascade), + ) + .to_owned(), + ) + .await?; + + // Create index + manager + .create_index( + Index::create() + .name("IDX_delivery_log_notification_id") + .table(DeliveryLog::Table) + .col(DeliveryLog::NotificationId) + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(DeliveryLog::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +enum DeliveryLog { + Table, + Id, + NotificationId, + AttemptNo, + Channel, + Status, + ResponseMeta, + CreatedAt, +} + +#[derive(DeriveIden)] +enum Notification { + Table, + Id, +} diff --git a/backend_rust/migration/src/m20260128_000011_modify_todo.rs b/backend_rust/migration/src/m20260128_000011_modify_todo.rs new file mode 100644 index 0000000..d436ea6 --- /dev/null +++ b/backend_rust/migration/src/m20260128_000011_modify_todo.rs @@ -0,0 +1,38 @@ +use sea_orm_migration::prelude::*; +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(Todo::Table) + .add_column(ColumnDef::new(Todo::CheckInAt).timestamp().null()) + .add_column( + ColumnDef::new(Todo::CheckInCount) + .integer() + .not_null() + .default(0), + ) + .add_column( + ColumnDef::new(Todo::IsCheckedIn) + .boolean() + .not_null() + .default(false), + ) + .to_owned(), + ) + .await?; + Ok(()) + } +} + +#[derive(DeriveIden)] +enum Todo { + Table, + CheckInAt, + CheckInCount, + IsCheckedIn, +} diff --git a/backend_rust/migration/src/m20260129_000012_add_bark_params.rs b/backend_rust/migration/src/m20260129_000012_add_bark_params.rs new file mode 100644 index 0000000..bc56c51 --- /dev/null +++ b/backend_rust/migration/src/m20260129_000012_add_bark_params.rs @@ -0,0 +1,49 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(ReminderOffset::Table) + .add_column(ColumnDef::new(ReminderOffset::BarkTitle).string().null()) + .add_column(ColumnDef::new(ReminderOffset::BarkSubtitle).string().null()) + .add_column(ColumnDef::new(ReminderOffset::BarkBodyMarkdown).text().null()) + .add_column(ColumnDef::new(ReminderOffset::BarkLevel).string().null()) + .add_column(ColumnDef::new(ReminderOffset::BarkIcon).string().null()) + .to_owned(), + ) + .await?; + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(ReminderOffset::Table) + .drop_column(ReminderOffset::BarkTitle) + .drop_column(ReminderOffset::BarkSubtitle) + .drop_column(ReminderOffset::BarkBodyMarkdown) + .drop_column(ReminderOffset::BarkLevel) + .drop_column(ReminderOffset::BarkIcon) + .to_owned(), + ) + .await?; + Ok(()) + } +} + +#[derive(DeriveIden)] +enum ReminderOffset { + Table, + BarkTitle, + BarkSubtitle, + BarkBodyMarkdown, + BarkLevel, + BarkIcon, +} diff --git a/backend_rust/migration/src/m20260129_000013_add_notification_offset_id.rs b/backend_rust/migration/src/m20260129_000013_add_notification_offset_id.rs new file mode 100644 index 0000000..728e2fc --- /dev/null +++ b/backend_rust/migration/src/m20260129_000013_add_notification_offset_id.rs @@ -0,0 +1,37 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(Notification::Table) + .add_column(ColumnDef::new(Notification::OffsetId).uuid().null()) + .to_owned(), + ) + .await?; + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(Notification::Table) + .drop_column(Notification::OffsetId) + .to_owned(), + ) + .await?; + Ok(()) + } +} + +#[derive(DeriveIden)] +enum Notification { + Table, + OffsetId, +} diff --git a/backend_rust/migration/src/m20260129_000014_convert_timestamps_to_timestamptz.rs b/backend_rust/migration/src/m20260129_000014_convert_timestamps_to_timestamptz.rs new file mode 100644 index 0000000..19e24ea --- /dev/null +++ b/backend_rust/migration/src/m20260129_000014_convert_timestamps_to_timestamptz.rs @@ -0,0 +1,143 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let db = manager.get_connection(); + + // User table: created_at, updated_at + db.execute_unprepared( + "ALTER TABLE \"user\" + ALTER COLUMN created_at TYPE TIMESTAMP WITH TIME ZONE USING created_at AT TIME ZONE 'UTC', + ALTER COLUMN updated_at TYPE TIMESTAMP WITH TIME ZONE USING updated_at AT TIME ZONE 'UTC'" + ).await?; + + // Invite table: expires_at, revoked_at, created_at + db.execute_unprepared( + "ALTER TABLE invite + ALTER COLUMN expires_at TYPE TIMESTAMP WITH TIME ZONE USING expires_at AT TIME ZONE 'UTC', + ALTER COLUMN revoked_at TYPE TIMESTAMP WITH TIME ZONE USING revoked_at AT TIME ZONE 'UTC', + ALTER COLUMN created_at TYPE TIMESTAMP WITH TIME ZONE USING created_at AT TIME ZONE 'UTC'" + ).await?; + + // RecurrenceRule table: created_at, updated_at + db.execute_unprepared( + "ALTER TABLE recurrence_rule + ALTER COLUMN created_at TYPE TIMESTAMP WITH TIME ZONE USING created_at AT TIME ZONE 'UTC', + ALTER COLUMN updated_at TYPE TIMESTAMP WITH TIME ZONE USING updated_at AT TIME ZONE 'UTC'" + ).await?; + + // Todo table: due_at, created_at, updated_at, check_in_at + db.execute_unprepared( + "ALTER TABLE todo + ALTER COLUMN due_at TYPE TIMESTAMP WITH TIME ZONE USING due_at AT TIME ZONE 'UTC', + ALTER COLUMN created_at TYPE TIMESTAMP WITH TIME ZONE USING created_at AT TIME ZONE 'UTC', + ALTER COLUMN updated_at TYPE TIMESTAMP WITH TIME ZONE USING updated_at AT TIME ZONE 'UTC', + ALTER COLUMN check_in_at TYPE TIMESTAMP WITH TIME ZONE USING check_in_at AT TIME ZONE 'UTC'" + ).await?; + + // ReminderTask table: due_at, created_at, updated_at + db.execute_unprepared( + "ALTER TABLE reminder_task + ALTER COLUMN due_at TYPE TIMESTAMP WITH TIME ZONE USING due_at AT TIME ZONE 'UTC', + ALTER COLUMN created_at TYPE TIMESTAMP WITH TIME ZONE USING created_at AT TIME ZONE 'UTC', + ALTER COLUMN updated_at TYPE TIMESTAMP WITH TIME ZONE USING updated_at AT TIME ZONE 'UTC'" + ).await?; + + // ReminderOffset table: created_at + db.execute_unprepared( + "ALTER TABLE reminder_offset + ALTER COLUMN created_at TYPE TIMESTAMP WITH TIME ZONE USING created_at AT TIME ZONE 'UTC'" + ).await?; + + // Notification table: trigger_at, locked_at, sent_at, read_at, created_at, updated_at + db.execute_unprepared( + "ALTER TABLE notification + ALTER COLUMN trigger_at TYPE TIMESTAMP WITH TIME ZONE USING trigger_at AT TIME ZONE 'UTC', + ALTER COLUMN locked_at TYPE TIMESTAMP WITH TIME ZONE USING locked_at AT TIME ZONE 'UTC', + ALTER COLUMN sent_at TYPE TIMESTAMP WITH TIME ZONE USING sent_at AT TIME ZONE 'UTC', + ALTER COLUMN read_at TYPE TIMESTAMP WITH TIME ZONE USING read_at AT TIME ZONE 'UTC', + ALTER COLUMN created_at TYPE TIMESTAMP WITH TIME ZONE USING created_at AT TIME ZONE 'UTC', + ALTER COLUMN updated_at TYPE TIMESTAMP WITH TIME ZONE USING updated_at AT TIME ZONE 'UTC'" + ).await?; + + // DeliveryLog table: created_at + db.execute_unprepared( + "ALTER TABLE delivery_log + ALTER COLUMN created_at TYPE TIMESTAMP WITH TIME ZONE USING created_at AT TIME ZONE 'UTC'" + ).await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let db = manager.get_connection(); + + // Revert User table + db.execute_unprepared( + "ALTER TABLE \"user\" + ALTER COLUMN created_at TYPE TIMESTAMP USING created_at AT TIME ZONE 'UTC', + ALTER COLUMN updated_at TYPE TIMESTAMP USING updated_at AT TIME ZONE 'UTC'" + ).await?; + + // Revert Invite table + db.execute_unprepared( + "ALTER TABLE invite + ALTER COLUMN expires_at TYPE TIMESTAMP USING expires_at AT TIME ZONE 'UTC', + ALTER COLUMN revoked_at TYPE TIMESTAMP USING revoked_at AT TIME ZONE 'UTC', + ALTER COLUMN created_at TYPE TIMESTAMP USING created_at AT TIME ZONE 'UTC'" + ).await?; + + // Revert RecurrenceRule table + db.execute_unprepared( + "ALTER TABLE recurrence_rule + ALTER COLUMN created_at TYPE TIMESTAMP USING created_at AT TIME ZONE 'UTC', + ALTER COLUMN updated_at TYPE TIMESTAMP USING updated_at AT TIME ZONE 'UTC'" + ).await?; + + // Revert Todo table + db.execute_unprepared( + "ALTER TABLE todo + ALTER COLUMN due_at TYPE TIMESTAMP USING due_at AT TIME ZONE 'UTC', + ALTER COLUMN created_at TYPE TIMESTAMP USING created_at AT TIME ZONE 'UTC', + ALTER COLUMN updated_at TYPE TIMESTAMP USING updated_at AT TIME ZONE 'UTC', + ALTER COLUMN check_in_at TYPE TIMESTAMP USING check_in_at AT TIME ZONE 'UTC'" + ).await?; + + // Revert ReminderTask table + db.execute_unprepared( + "ALTER TABLE reminder_task + ALTER COLUMN due_at TYPE TIMESTAMP USING due_at AT TIME ZONE 'UTC', + ALTER COLUMN created_at TYPE TIMESTAMP USING created_at AT TIME ZONE 'UTC', + ALTER COLUMN updated_at TYPE TIMESTAMP USING updated_at AT TIME ZONE 'UTC'" + ).await?; + + // Revert ReminderOffset table + db.execute_unprepared( + "ALTER TABLE reminder_offset + ALTER COLUMN created_at TYPE TIMESTAMP USING created_at AT TIME ZONE 'UTC'" + ).await?; + + // Revert Notification table + db.execute_unprepared( + "ALTER TABLE notification + ALTER COLUMN trigger_at TYPE TIMESTAMP USING trigger_at AT TIME ZONE 'UTC', + ALTER COLUMN locked_at TYPE TIMESTAMP USING locked_at AT TIME ZONE 'UTC', + ALTER COLUMN sent_at TYPE TIMESTAMP USING sent_at AT TIME ZONE 'UTC', + ALTER COLUMN read_at TYPE TIMESTAMP USING read_at AT TIME ZONE 'UTC', + ALTER COLUMN created_at TYPE TIMESTAMP USING created_at AT TIME ZONE 'UTC', + ALTER COLUMN updated_at TYPE TIMESTAMP USING updated_at AT TIME ZONE 'UTC'" + ).await?; + + // Revert DeliveryLog table + db.execute_unprepared( + "ALTER TABLE delivery_log + ALTER COLUMN created_at TYPE TIMESTAMP USING created_at AT TIME ZONE 'UTC'" + ).await?; + + Ok(()) + } +} diff --git a/backend_rust/migration/src/m20260129_000015_add_user_invite_id.rs b/backend_rust/migration/src/m20260129_000015_add_user_invite_id.rs new file mode 100644 index 0000000..0d73cb2 --- /dev/null +++ b/backend_rust/migration/src/m20260129_000015_add_user_invite_id.rs @@ -0,0 +1,86 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + // Add invite_id column to user table + manager + .alter_table( + Table::alter() + .table(User::Table) + .add_column(ColumnDef::new(User::InviteId).uuid().null()) + .to_owned(), + ) + .await?; + + // Add foreign key constraint + manager + .create_foreign_key( + ForeignKey::create() + .name("fk_user_invite_id") + .from(User::Table, User::InviteId) + .to(Invite::Table, Invite::Id) + .on_delete(ForeignKeyAction::SetNull) + .on_update(ForeignKeyAction::Cascade) + .to_owned(), + ) + .await?; + + // Add index for better query performance + manager + .create_index( + Index::create() + .name("idx_user_invite_id") + .table(User::Table) + .col(User::InviteId) + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + // Drop index + manager + .drop_index(Index::drop().name("idx_user_invite_id").to_owned()) + .await?; + + // Drop foreign key + manager + .drop_foreign_key( + ForeignKey::drop() + .table(User::Table) + .name("fk_user_invite_id") + .to_owned(), + ) + .await?; + + // Drop column + manager + .alter_table( + Table::alter() + .table(User::Table) + .drop_column(User::InviteId) + .to_owned(), + ) + .await?; + + Ok(()) + } +} + +#[derive(DeriveIden)] +enum User { + Table, + InviteId, +} + +#[derive(DeriveIden)] +enum Invite { + Table, + Id, +} diff --git a/backend_rust/migration/src/main.rs b/backend_rust/migration/src/main.rs new file mode 100644 index 0000000..f054dea --- /dev/null +++ b/backend_rust/migration/src/main.rs @@ -0,0 +1,6 @@ +use sea_orm_migration::prelude::*; + +#[tokio::main] +async fn main() { + cli::run_cli(migration::Migrator).await; +} diff --git a/backend_rust/src/api/auth.rs b/backend_rust/src/api/auth.rs new file mode 100644 index 0000000..535d1bc --- /dev/null +++ b/backend_rust/src/api/auth.rs @@ -0,0 +1,161 @@ +use actix_web::{HttpResponse, Responder, Scope, post, web}; +use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set, TransactionTrait}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::app_data::AppData; +use crate::entity::{invite, user}; +use crate::error::ApiError; +use crate::middleware::auth::create_token; + +#[derive(Debug, Deserialize)] +pub struct RegisterRequest { + pub username: String, + pub password: String, + pub invite_code: String, +} + +#[derive(Debug, Deserialize)] +pub struct LoginRequest { + pub username: String, + pub password: String, +} + +#[derive(Debug, Serialize)] +pub struct AuthResponse { + pub token: String, + pub user: UserInfo, +} + +#[derive(Debug, Serialize)] +pub struct UserInfo { + pub id: Uuid, + pub username: String, +} + +#[post("/register")] +async fn register( + app_data: web::Data, + body: web::Json, +) -> Result { + if body.username.len() < 3 { + return Err(ApiError::BadRequest("Invalid payload".to_string())); + } + if body.password.len() < 6 { + return Err(ApiError::BadRequest("Invalid payload".to_string())); + } + if body.invite_code.len() < 4 { + return Err(ApiError::BadRequest("Invalid payload".to_string())); + } + + let now = chrono::Utc::now().fixed_offset(); + + let result = app_data + .db + .transaction::<_, (Uuid, String), ApiError>(|txn| { + let username = body.username.clone(); + let password = body.password.clone(); + let invite_code = body.invite_code.clone(); + Box::pin(async move { + // Find valid invite + let inv = invite::Entity::find() + .filter(invite::Column::Code.eq(&invite_code)) + .filter(invite::Column::RevokedAt.is_null()) + .filter(invite::Column::ExpiresAt.gt(now)) + .one(txn) + .await? + .ok_or_else(|| ApiError::BadRequest("Invalid invite".to_string()))?; + + if inv.used_count >= inv.max_uses { + return Err(ApiError::BadRequest("Invalid invite".to_string())); + } + + // Check username exists + let existing = user::Entity::find() + .filter(user::Column::Username.eq(&username)) + .one(txn) + .await?; + + if existing.is_some() { + return Err(ApiError::Conflict("Username taken".to_string())); + } + + // Create user + let password_hash = bcrypt::hash(&password, 10)?; + let user_id = Uuid::new_v4(); + let invite_id = inv.id; + + let new_user = user::ActiveModel { + id: Set(user_id), + username: Set(username.clone()), + password_hash: Set(password_hash), + avatar: Set(None), + timezone: Set("Asia/Shanghai".to_string()), + bark_url: Set(None), + inapp_enabled: Set(true), + bark_enabled: Set(false), + invite_id: Set(Some(invite_id)), + created_at: Set(now), + updated_at: Set(now), + }; + new_user.insert(txn).await?; + + // Update invite used count + let mut inv_active: invite::ActiveModel = inv.into(); + inv_active.used_count = Set(inv_active.used_count.unwrap() + 1); + inv_active.update(txn).await?; + + Ok((user_id, username)) + }) + }) + .await + .map_err(|e| match e { + sea_orm::TransactionError::Connection(e) => ApiError::Internal(e.to_string()), + sea_orm::TransactionError::Transaction(e) => e, + })?; + + let token = create_token(result.0, &app_data.jwt_secret)?; + + Ok(HttpResponse::Ok().json(AuthResponse { + token, + user: UserInfo { + id: result.0, + username: result.1, + }, + })) +} + +#[post("/login")] +async fn login( + app_data: web::Data, + body: web::Json, +) -> Result { + if body.username.len() < 3 || body.password.len() < 6 { + return Err(ApiError::BadRequest("Invalid payload".to_string())); + } + + let user = user::Entity::find() + .filter(user::Column::Username.eq(&body.username)) + .one(&app_data.db) + .await? + .ok_or_else(|| ApiError::Unauthorized("Invalid credentials".to_string()))?; + + let valid = bcrypt::verify(&body.password, &user.password_hash)?; + if !valid { + return Err(ApiError::Unauthorized("Invalid credentials".to_string())); + } + + let token = create_token(user.id, &app_data.jwt_secret)?; + + Ok(HttpResponse::Ok().json(AuthResponse { + token, + user: UserInfo { + id: user.id, + username: user.username, + }, + })) +} + +pub fn routes() -> Scope { + web::scope("/api/auth").service(register).service(login) +} diff --git a/backend_rust/src/api/health.rs b/backend_rust/src/api/health.rs new file mode 100644 index 0000000..bc27682 --- /dev/null +++ b/backend_rust/src/api/health.rs @@ -0,0 +1,10 @@ +use actix_web::{HttpResponse, Responder, Scope, get, web}; + +#[get("")] +async fn health() -> impl Responder { + HttpResponse::Ok().body("OK") +} + +pub fn routes() -> Scope { + web::scope("/health").service(health) +} diff --git a/backend_rust/src/api/invites.rs b/backend_rust/src/api/invites.rs new file mode 100644 index 0000000..4e3c8ae --- /dev/null +++ b/backend_rust/src/api/invites.rs @@ -0,0 +1,167 @@ +use actix_web::{HttpResponse, Responder, Scope, get, post, web}; +use rand::{Rng, distr::Alphanumeric}; +use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::app_data::AppData; +use crate::entity::{invite, user}; +use crate::error::ApiError; +use crate::middleware::auth::AuthUser; + +#[derive(Debug, Deserialize)] +pub struct CreateInviteRequest { + pub max_uses: Option, + pub expires_in_days: Option, +} + +#[derive(Debug, Serialize)] +pub struct OkResponse { + pub ok: bool, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RegisteredUserInfo { + pub id: Uuid, + pub username: String, + pub avatar: Option, + pub created_at: chrono::DateTime, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct InviteWithUsers { + pub id: Uuid, + pub code: String, + pub creator_id: Uuid, + pub max_uses: i32, + pub used_count: i32, + pub expires_at: chrono::DateTime, + pub revoked_at: Option>, + pub created_at: chrono::DateTime, + pub registered_users: Vec, +} + +#[post("")] +async fn create_invite( + app_data: web::Data, + auth: AuthUser, + body: web::Json, +) -> Result { + let max_uses = body.max_uses.unwrap_or(5).clamp(1, 20); + let expires_in_days = body.expires_in_days.unwrap_or(7).clamp(1, 30); + + let expires_at = + chrono::Utc::now().fixed_offset() + chrono::Duration::days(expires_in_days as i64); + + let code = generate_invite_code(); + + let new_invite = invite::ActiveModel { + id: Set(Uuid::new_v4()), + code: Set(code), + creator_id: Set(auth.user_id), + max_uses: Set(max_uses), + used_count: Set(0), + expires_at: Set(expires_at), + revoked_at: Set(None), + created_at: Set(chrono::Utc::now().fixed_offset()), + }; + + let result = new_invite.insert(&app_data.db).await?; + + Ok(HttpResponse::Ok().json(result)) +} + +#[get("")] +async fn list_invites( + app_data: web::Data, + auth: AuthUser, +) -> Result { + let invites = invite::Entity::find() + .filter(invite::Column::CreatorId.eq(auth.user_id)) + .order_by_desc(invite::Column::CreatedAt) + .all(&app_data.db) + .await?; + + Ok(HttpResponse::Ok().json(invites)) +} + +#[get("/{id}")] +async fn get_invite( + app_data: web::Data, + auth: AuthUser, + path: web::Path, +) -> Result { + let id = path.into_inner(); + + let invite = invite::Entity::find_by_id(id) + .filter(invite::Column::CreatorId.eq(auth.user_id)) + .one(&app_data.db) + .await? + .ok_or_else(|| ApiError::NotFound("Invite not found".to_string()))?; + + // Get users who registered with this invite + let registered_users = user::Entity::find() + .filter(user::Column::InviteId.eq(invite.id)) + .order_by_asc(user::Column::CreatedAt) + .all(&app_data.db) + .await?; + + let registered_users_info: Vec = registered_users + .into_iter() + .map(|u| RegisteredUserInfo { + id: u.id, + username: u.username, + avatar: u.avatar, + created_at: u.created_at, + }) + .collect(); + + Ok(HttpResponse::Ok().json(InviteWithUsers { + id: invite.id, + code: invite.code, + creator_id: invite.creator_id, + max_uses: invite.max_uses, + used_count: invite.used_count, + expires_at: invite.expires_at, + revoked_at: invite.revoked_at, + created_at: invite.created_at, + registered_users: registered_users_info, + })) +} + +#[post("/{id}/revoke")] +async fn revoke_invite( + app_data: web::Data, + auth: AuthUser, + path: web::Path, +) -> Result { + let id = path.into_inner(); + + let invite = invite::Entity::find_by_id(id) + .filter(invite::Column::CreatorId.eq(auth.user_id)) + .one(&app_data.db) + .await? + .ok_or_else(|| ApiError::NotFound("Invite not found".to_string()))?; + + let mut active: invite::ActiveModel = invite.into(); + active.revoked_at = Set(Some(chrono::Utc::now().fixed_offset())); + active.update(&app_data.db).await?; + + Ok(HttpResponse::Ok().json(OkResponse { ok: true })) +} + +fn generate_invite_code() -> String { + let mut rng = rand::rng(); + let suffix: String = (0..6).map(|_| rng.sample(Alphanumeric) as char).collect(); + format!("INV-{}", suffix) +} + +pub fn routes() -> Scope { + web::scope("/api/invites") + .service(create_invite) + .service(list_invites) + .service(get_invite) + .service(revoke_invite) +} diff --git a/backend_rust/src/api/me.rs b/backend_rust/src/api/me.rs new file mode 100644 index 0000000..753a1e4 --- /dev/null +++ b/backend_rust/src/api/me.rs @@ -0,0 +1,216 @@ +use actix_multipart::Multipart; +use actix_web::{HttpResponse, Responder, Scope, get, post, put, web}; +use futures_util::StreamExt; +use sea_orm::{ActiveModelTrait, EntityTrait, Set}; +use serde::{Deserialize, Serialize}; +use std::io::Write; +use uuid::Uuid; + +use crate::app_data::AppData; +use crate::entity::user; +use crate::error::ApiError; +use crate::middleware::auth::AuthUser; + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MeResponse { + pub id: Uuid, + pub username: String, + pub avatar: Option, + pub timezone: String, + pub bark_url: Option, + pub inapp_enabled: bool, + pub bark_enabled: bool, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateSettingsRequest { + pub avatar: Option, + pub timezone: Option, + pub bark_url: Option, + pub inapp_enabled: Option, + pub bark_enabled: Option, +} + +#[get("")] +async fn get_me( + app_data: web::Data, + auth: AuthUser, +) -> Result { + let user = user::Entity::find_by_id(auth.user_id) + .one(&app_data.db) + .await? + .ok_or_else(|| ApiError::NotFound("User not found".to_string()))?; + + Ok(HttpResponse::Ok().json(MeResponse { + id: user.id, + username: user.username, + avatar: user.avatar, + timezone: user.timezone, + bark_url: user.bark_url, + inapp_enabled: user.inapp_enabled, + bark_enabled: user.bark_enabled, + })) +} + +#[put("/settings")] +async fn update_settings( + app_data: web::Data, + auth: AuthUser, + body: web::Json, +) -> Result { + let user = user::Entity::find_by_id(auth.user_id) + .one(&app_data.db) + .await? + .ok_or_else(|| ApiError::NotFound("User not found".to_string()))?; + + let mut active: user::ActiveModel = user.into(); + + if let Some(avatar) = &body.avatar { + active.avatar = Set(Some(avatar.clone())); + } + if let Some(timezone) = &body.timezone { + active.timezone = Set(timezone.clone()); + } + if let Some(bark_url) = &body.bark_url { + active.bark_url = Set(Some(bark_url.clone())); + } + if let Some(inapp_enabled) = body.inapp_enabled { + active.inapp_enabled = Set(inapp_enabled); + } + if let Some(bark_enabled) = body.bark_enabled { + active.bark_enabled = Set(bark_enabled); + } + active.updated_at = Set(chrono::Utc::now().fixed_offset()); + + let updated = active.update(&app_data.db).await?; + + Ok(HttpResponse::Ok().json(MeResponse { + id: updated.id, + username: updated.username, + avatar: updated.avatar, + timezone: updated.timezone, + bark_url: updated.bark_url, + inapp_enabled: updated.inapp_enabled, + bark_enabled: updated.bark_enabled, + })) +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct UploadAvatarResponse { + pub avatar_url: String, +} + +/// 上传用户头像 +/// 支持的格式: jpg, jpeg, png, gif, webp +/// 最大文件大小: 5MB +#[post("/avatar")] +async fn upload_avatar( + app_data: web::Data, + auth: AuthUser, + mut payload: Multipart, +) -> Result { + const MAX_FILE_SIZE: usize = 5 * 1024 * 1024; // 5MB + const ALLOWED_EXTENSIONS: &[&str] = &["jpg", "jpeg", "png", "gif", "webp"]; + + let mut file_data: Option<(Vec, String)> = None; + + // 解析 multipart 表单数据 + while let Some(item) = payload.next().await { + let mut field = item.map_err(|e| ApiError::BadRequest(format!("Multipart error: {}", e)))?; + + let content_disposition = field + .content_disposition() + .ok_or_else(|| ApiError::BadRequest("Missing content disposition".to_string()))?; + let field_name = content_disposition.get_name().unwrap_or(""); + + if field_name != "avatar" { + continue; + } + + // 获取文件名和扩展名 + let filename = content_disposition + .get_filename() + .ok_or_else(|| ApiError::BadRequest("Missing filename".to_string()))?; + + let extension: String = filename + .rsplit('.') + .next() + .map(|s| s.to_lowercase()) + .ok_or_else(|| ApiError::BadRequest("Invalid filename".to_string()))?; + + if !ALLOWED_EXTENSIONS.contains(&extension.as_str()) { + return Err(ApiError::BadRequest(format!( + "Unsupported file format. Allowed: {}", + ALLOWED_EXTENSIONS.join(", ") + ))); + } + + // 读取文件内容 + let mut data = Vec::new(); + while let Some(chunk) = field.next().await { + let chunk = + chunk.map_err(|e| ApiError::BadRequest(format!("Error reading chunk: {}", e)))?; + + if data.len() + chunk.len() > MAX_FILE_SIZE { + return Err(ApiError::BadRequest(format!( + "File too large. Max size: {}MB", + MAX_FILE_SIZE / 1024 / 1024 + ))); + } + data.extend_from_slice(&chunk); + } + + file_data = Some((data, extension)); + break; + } + + let (data, extension) = file_data.ok_or_else(|| { + ApiError::BadRequest("No avatar file provided. Use field name 'avatar'".to_string()) + })?; + + // 生成唯一文件名 + let file_id = Uuid::new_v4(); + let filename = format!("{}.{}", file_id, extension); + let file_path = app_data.upload_dir.join("avatars").join(&filename); + + // 保存文件 + let mut file = std::fs::File::create(&file_path) + .map_err(|e| ApiError::Internal(format!("Failed to create file: {}", e)))?; + file.write_all(&data) + .map_err(|e| ApiError::Internal(format!("Failed to write file: {}", e)))?; + + // 生成头像 URL + let avatar_url = format!("{}/uploads/avatars/{}", app_data.base_url, filename); + + // 更新用户头像 + let user = user::Entity::find_by_id(auth.user_id) + .one(&app_data.db) + .await? + .ok_or_else(|| ApiError::NotFound("User not found".to_string()))?; + + // 如果用户之前有头像,尝试删除旧文件 + if let Some(old_avatar) = &user.avatar { + if let Some(old_filename) = old_avatar.rsplit('/').next() { + let old_path = app_data.upload_dir.join("avatars").join(old_filename); + // 忽略删除错误,旧文件可能不存在 + let _ = std::fs::remove_file(old_path); + } + } + + let mut active: user::ActiveModel = user.into(); + active.avatar = Set(Some(avatar_url.clone())); + active.updated_at = Set(chrono::Utc::now().fixed_offset()); + active.update(&app_data.db).await?; + + Ok(HttpResponse::Ok().json(UploadAvatarResponse { avatar_url })) +} + +pub fn routes() -> Scope { + web::scope("/api/me") + .service(get_me) + .service(update_settings) + .service(upload_avatar) +} diff --git a/backend_rust/src/api/mod.rs b/backend_rust/src/api/mod.rs new file mode 100644 index 0000000..9ed279b --- /dev/null +++ b/backend_rust/src/api/mod.rs @@ -0,0 +1,8 @@ +pub mod auth; +pub mod health; +pub mod invites; +pub mod me; +pub mod notifications; +pub mod reminder_tasks; +pub mod todos; +pub mod users; diff --git a/backend_rust/src/api/notifications.rs b/backend_rust/src/api/notifications.rs new file mode 100644 index 0000000..a671c25 --- /dev/null +++ b/backend_rust/src/api/notifications.rs @@ -0,0 +1,95 @@ +use actix_web::{HttpResponse, Responder, Scope, get, post, web}; +use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::app_data::AppData; +use crate::entity::notification; +use crate::error::ApiError; +use crate::middleware::auth::AuthUser; + +#[derive(Debug, Deserialize)] +pub struct ListQuery { + pub status: Option, +} + +#[derive(Debug, Serialize)] +pub struct OkResponse { + pub ok: bool, +} + +#[get("")] +async fn list_notifications( + app_data: web::Data, + auth: AuthUser, + query: web::Query, +) -> Result { + let status = query.status.as_deref().unwrap_or("all"); + + let mut q = + notification::Entity::find().filter(notification::Column::RecipientId.eq(auth.user_id)); + + if status == "unread" { + q = q.filter(notification::Column::ReadAt.is_null()); + } + + let notifications = q + .order_by_desc(notification::Column::TriggerAt) + .all(&app_data.db) + .await?; + + Ok(HttpResponse::Ok().json(notifications)) +} + +#[post("/{id}/read")] +async fn mark_read( + app_data: web::Data, + auth: AuthUser, + path: web::Path, +) -> Result { + let id = path.into_inner(); + + let notif = notification::Entity::find_by_id(id) + .filter(notification::Column::RecipientId.eq(auth.user_id)) + .one(&app_data.db) + .await? + .ok_or_else(|| ApiError::NotFound("Not found".to_string()))?; + + let mut active: notification::ActiveModel = notif.into(); + active.read_at = Set(Some(chrono::Utc::now().fixed_offset())); + active.updated_at = Set(chrono::Utc::now().fixed_offset()); + active.update(&app_data.db).await?; + + Ok(HttpResponse::Ok().json(OkResponse { ok: true })) +} + +#[post("/read-all")] +async fn mark_all_read( + app_data: web::Data, + auth: AuthUser, +) -> Result { + let now = chrono::Utc::now().fixed_offset(); + + notification::Entity::update_many() + .filter(notification::Column::RecipientId.eq(auth.user_id)) + .filter(notification::Column::ReadAt.is_null()) + .col_expr( + notification::Column::ReadAt, + sea_orm::sea_query::Expr::value(now), + ) + .col_expr( + notification::Column::UpdatedAt, + sea_orm::sea_query::Expr::value(now), + ) + .exec(&app_data.db) + .await?; + + Ok(HttpResponse::Ok().json(OkResponse { ok: true })) +} + +pub fn routes() -> Scope { + web::scope("/api/notifications") + .service(list_notifications) + .service(mark_all_read) + .service(mark_read) +} diff --git a/backend_rust/src/api/reminder_tasks.rs b/backend_rust/src/api/reminder_tasks.rs new file mode 100644 index 0000000..2258da3 --- /dev/null +++ b/backend_rust/src/api/reminder_tasks.rs @@ -0,0 +1,451 @@ +use actix_web::{HttpResponse, Responder, Scope, delete, get, post, put, web}; +use sea_orm::{ + ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set, TransactionTrait, +}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::app_data::AppData; +use crate::entity::sea_orm_active_enums::{RecurrenceType, TargetType}; +use crate::entity::{recurrence_rule, reminder_offset, reminder_task, reminder_task_recipient}; +use crate::error::ApiError; +use crate::middleware::auth::AuthUser; +use crate::timer::WorkerCommand; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RecurrenceRuleInput { + pub r#type: String, + pub interval: Option, + pub by_weekday: Option, + pub by_monthday: Option, + pub timezone: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OffsetInput { + pub offset_minutes: i32, + pub channel_inapp: Option, + pub channel_bark: Option, + /// Custom title for Bark push notification + pub bark_title: Option, + /// Custom subtitle for Bark push notification + pub bark_subtitle: Option, + /// Markdown content for Bark push (overrides body if set) + pub bark_body_markdown: Option, + /// Alert level: active, timeSensitive, passive, critical + pub bark_level: Option, + /// Custom icon URL for Bark push + pub bark_icon: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReminderTaskInput { + pub title: String, + pub description: Option, + pub due_at: chrono::DateTime, + pub recipient_ids: Vec, + pub recurrence_rule: Option, + pub offsets: Option>, +} + +#[derive(Debug, Serialize)] +pub struct OkResponse { + pub ok: bool, +} + +#[get("")] +async fn list_tasks( + app_data: web::Data, + auth: AuthUser, +) -> Result { + let items = reminder_task::Entity::find() + .filter(reminder_task::Column::CreatorId.eq(auth.user_id)) + .order_by_desc(reminder_task::Column::DueAt) + .find_also_related(recurrence_rule::Entity) + .all(&app_data.db) + .await?; + + let task_ids: Vec = items.iter().map(|(t, _)| t.id).collect(); + + let recipients = reminder_task_recipient::Entity::find() + .filter(reminder_task_recipient::Column::TaskId.is_in(task_ids.clone())) + .all(&app_data.db) + .await?; + + let offsets = reminder_offset::Entity::find() + .filter(reminder_offset::Column::TargetType.eq(TargetType::ReminderTask)) + .filter(reminder_offset::Column::TargetId.is_in(task_ids)) + .all(&app_data.db) + .await?; + + let result: Vec = items + .into_iter() + .map(|(t, rule)| { + let task_recipients: Vec<_> = recipients + .iter() + .filter(|r| r.task_id == t.id) + .cloned() + .collect(); + let task_offsets: Vec<_> = offsets + .iter() + .filter(|o| o.target_id == t.id) + .cloned() + .collect(); + serde_json::json!({ + "id": t.id, + "creatorId": t.creator_id, + "title": t.title, + "description": t.description, + "dueAt": t.due_at, + "recurrenceRuleId": t.recurrence_rule_id, + "createdAt": t.created_at, + "updatedAt": t.updated_at, + "recurrenceRule": rule, + "recipients": task_recipients, + "offsets": task_offsets, + }) + }) + .collect(); + + Ok(HttpResponse::Ok().json(result)) +} + +#[post("")] +async fn create_task( + app_data: web::Data, + auth: AuthUser, + body: web::Json, +) -> Result { + if body.title.is_empty() { + return Err(ApiError::BadRequest("Invalid payload".to_string())); + } + + let now = chrono::Utc::now().fixed_offset(); + let body = body.into_inner(); + let user_id = auth.user_id; + + let result = app_data + .db + .transaction::<_, reminder_task::Model, ApiError>(|txn| { + Box::pin(async move { + // Create recurrence rule if provided + let rule_id = if let Some(rule_input) = body.recurrence_rule { + let rule = recurrence_rule::ActiveModel { + id: Set(Uuid::new_v4()), + r#type: Set(parse_recurrence_type(&rule_input.r#type)?), + interval: Set(rule_input.interval.unwrap_or(1)), + by_weekday: Set(rule_input.by_weekday), + by_monthday: Set(rule_input.by_monthday), + timezone: Set(rule_input.timezone.unwrap_or("Asia/Shanghai".to_string())), + created_at: Set(now), + updated_at: Set(now), + }; + let created = rule.insert(txn).await?; + Some(created.id) + } else { + None + }; + + // Create task + let new_task = reminder_task::ActiveModel { + id: Set(Uuid::new_v4()), + creator_id: Set(user_id), + title: Set(body.title), + description: Set(body.description), + due_at: Set(body.due_at.fixed_offset()), + recurrence_rule_id: Set(rule_id), + created_at: Set(now), + updated_at: Set(now), + }; + let created_task = new_task.insert(txn).await?; + + // Create recipients + for recipient_id in body.recipient_ids { + let new_recipient = reminder_task_recipient::ActiveModel { + task_id: Set(created_task.id), + user_id: Set(recipient_id), + }; + new_recipient.insert(txn).await?; + } + + // Create offsets + if let Some(offsets) = body.offsets { + for offset in offsets { + let new_offset = reminder_offset::ActiveModel { + id: Set(Uuid::new_v4()), + target_type: Set(TargetType::ReminderTask), + target_id: Set(created_task.id), + offset_minutes: Set(offset.offset_minutes), + channel_inapp: Set(offset.channel_inapp.unwrap_or(true)), + channel_bark: Set(offset.channel_bark.unwrap_or(false)), + created_at: Set(now), + bark_title: Set(offset.bark_title), + bark_subtitle: Set(offset.bark_subtitle), + bark_body_markdown: Set(offset.bark_body_markdown), + bark_level: Set(offset.bark_level), + bark_icon: Set(offset.bark_icon), + }; + new_offset.insert(txn).await?; + } + } + + Ok(created_task) + }) + }) + .await + .map_err(|e| match e { + sea_orm::TransactionError::Connection(e) => ApiError::Internal(e.to_string()), + sea_orm::TransactionError::Transaction(e) => e, + })?; + + // 触发通知生成 + let _ = app_data + .send_worker_command(WorkerCommand::GenerateNotifications { + target_type: TargetType::ReminderTask, + target_id: result.id, + }) + .await; + + Ok(HttpResponse::Ok().json(result)) +} + +#[get("/{id}")] +async fn get_task( + app_data: web::Data, + auth: AuthUser, + path: web::Path, +) -> Result { + let id = path.into_inner(); + + let (t, rule) = reminder_task::Entity::find_by_id(id) + .filter(reminder_task::Column::CreatorId.eq(auth.user_id)) + .find_also_related(recurrence_rule::Entity) + .one(&app_data.db) + .await? + .ok_or_else(|| ApiError::NotFound("Not found".to_string()))?; + + let recipients = reminder_task_recipient::Entity::find() + .filter(reminder_task_recipient::Column::TaskId.eq(t.id)) + .all(&app_data.db) + .await?; + + let offsets = reminder_offset::Entity::find() + .filter(reminder_offset::Column::TargetType.eq(TargetType::ReminderTask)) + .filter(reminder_offset::Column::TargetId.eq(t.id)) + .all(&app_data.db) + .await?; + + let result = serde_json::json!({ + "id": t.id, + "creatorId": t.creator_id, + "title": t.title, + "description": t.description, + "dueAt": t.due_at, + "recurrenceRuleId": t.recurrence_rule_id, + "createdAt": t.created_at, + "updatedAt": t.updated_at, + "recurrenceRule": rule, + "recipients": recipients, + "offsets": offsets, + }); + + Ok(HttpResponse::Ok().json(result)) +} + +#[put("/{id}")] +async fn update_task( + app_data: web::Data, + auth: AuthUser, + path: web::Path, + body: web::Json, +) -> Result { + let id = path.into_inner(); + + if body.title.is_empty() { + return Err(ApiError::BadRequest("Invalid payload".to_string())); + } + + let now = chrono::Utc::now().fixed_offset(); + let body = body.into_inner(); + let user_id = auth.user_id; + + let result = app_data + .db + .transaction::<_, reminder_task::Model, ApiError>(|txn| { + Box::pin(async move { + let existing = reminder_task::Entity::find_by_id(id) + .filter(reminder_task::Column::CreatorId.eq(user_id)) + .one(txn) + .await? + .ok_or_else(|| ApiError::NotFound("Not found".to_string()))?; + + // Handle recurrence rule + let mut rule_id = existing.recurrence_rule_id; + if let Some(rule_input) = body.recurrence_rule { + if let Some(existing_rule_id) = rule_id { + // Update existing rule + let mut rule: recurrence_rule::ActiveModel = + recurrence_rule::Entity::find_by_id(existing_rule_id) + .one(txn) + .await? + .ok_or_else(|| ApiError::Internal("Rule not found".to_string()))? + .into(); + rule.r#type = Set(parse_recurrence_type(&rule_input.r#type)?); + rule.interval = Set(rule_input.interval.unwrap_or(1)); + rule.by_weekday = Set(rule_input.by_weekday); + rule.by_monthday = Set(rule_input.by_monthday); + rule.timezone = + Set(rule_input.timezone.unwrap_or("Asia/Shanghai".to_string())); + rule.updated_at = Set(now); + rule.update(txn).await?; + } else { + // Create new rule + let rule = recurrence_rule::ActiveModel { + id: Set(Uuid::new_v4()), + r#type: Set(parse_recurrence_type(&rule_input.r#type)?), + interval: Set(rule_input.interval.unwrap_or(1)), + by_weekday: Set(rule_input.by_weekday), + by_monthday: Set(rule_input.by_monthday), + timezone: Set(rule_input + .timezone + .unwrap_or("Asia/Shanghai".to_string())), + created_at: Set(now), + updated_at: Set(now), + }; + let created = rule.insert(txn).await?; + rule_id = Some(created.id); + } + } else if let Some(existing_rule_id) = rule_id { + // Delete existing rule + recurrence_rule::Entity::delete_by_id(existing_rule_id) + .exec(txn) + .await?; + rule_id = None; + } + + // Delete existing recipients and create new ones + reminder_task_recipient::Entity::delete_many() + .filter(reminder_task_recipient::Column::TaskId.eq(id)) + .exec(txn) + .await?; + + for recipient_id in body.recipient_ids { + let new_recipient = reminder_task_recipient::ActiveModel { + task_id: Set(id), + user_id: Set(recipient_id), + }; + new_recipient.insert(txn).await?; + } + + // Delete existing offsets and create new ones + reminder_offset::Entity::delete_many() + .filter(reminder_offset::Column::TargetType.eq(TargetType::ReminderTask)) + .filter(reminder_offset::Column::TargetId.eq(id)) + .exec(txn) + .await?; + + if let Some(offsets) = body.offsets { + for offset in offsets { + let new_offset = reminder_offset::ActiveModel { + id: Set(Uuid::new_v4()), + target_type: Set(TargetType::ReminderTask), + target_id: Set(id), + offset_minutes: Set(offset.offset_minutes), + channel_inapp: Set(offset.channel_inapp.unwrap_or(true)), + channel_bark: Set(offset.channel_bark.unwrap_or(false)), + created_at: Set(now), + bark_title: Set(offset.bark_title), + bark_subtitle: Set(offset.bark_subtitle), + bark_body_markdown: Set(offset.bark_body_markdown), + bark_level: Set(offset.bark_level), + bark_icon: Set(offset.bark_icon), + }; + new_offset.insert(txn).await?; + } + } + + // Update task + let mut active: reminder_task::ActiveModel = existing.into(); + active.title = Set(body.title); + active.description = Set(body.description); + active.due_at = Set(body.due_at.fixed_offset()); + active.recurrence_rule_id = Set(rule_id); + active.updated_at = Set(now); + let updated = active.update(txn).await?; + + Ok(updated) + }) + }) + .await + .map_err(|e| match e { + sea_orm::TransactionError::Connection(e) => ApiError::Internal(e.to_string()), + sea_orm::TransactionError::Transaction(e) => e, + })?; + + // 触发通知重新生成 + let _ = app_data + .send_worker_command(WorkerCommand::GenerateNotifications { + target_type: TargetType::ReminderTask, + target_id: result.id, + }) + .await; + + Ok(HttpResponse::Ok().json(result)) +} + +#[delete("/{id}")] +async fn delete_task( + app_data: web::Data, + auth: AuthUser, + path: web::Path, +) -> Result { + let id = path.into_inner(); + + let result = reminder_task::Entity::delete_many() + .filter(reminder_task::Column::Id.eq(id)) + .filter(reminder_task::Column::CreatorId.eq(auth.user_id)) + .exec(&app_data.db) + .await?; + + if result.rows_affected == 0 { + return Err(ApiError::NotFound("Not found".to_string())); + } + + // Delete offsets + reminder_offset::Entity::delete_many() + .filter(reminder_offset::Column::TargetType.eq(TargetType::ReminderTask)) + .filter(reminder_offset::Column::TargetId.eq(id)) + .exec(&app_data.db) + .await?; + + // Delete recipients + reminder_task_recipient::Entity::delete_many() + .filter(reminder_task_recipient::Column::TaskId.eq(id)) + .exec(&app_data.db) + .await?; + + Ok(HttpResponse::Ok().json(OkResponse { ok: true })) +} + +fn parse_recurrence_type(s: &str) -> Result { + match s { + "hourly" => Ok(RecurrenceType::Hourly), + "daily" => Ok(RecurrenceType::Daily), + "weekly" => Ok(RecurrenceType::Weekly), + "monthly" => Ok(RecurrenceType::Monthly), + "yearly" => Ok(RecurrenceType::Yearly), + _ => Err(ApiError::BadRequest("Invalid recurrence type".to_string())), + } +} + +pub fn routes() -> Scope { + web::scope("/api/reminder-tasks") + .service(list_tasks) + .service(create_task) + .service(get_task) + .service(update_task) + .service(delete_task) +} diff --git a/backend_rust/src/api/todos.rs b/backend_rust/src/api/todos.rs new file mode 100644 index 0000000..44a2acd --- /dev/null +++ b/backend_rust/src/api/todos.rs @@ -0,0 +1,432 @@ +use actix_web::{HttpResponse, Responder, Scope, delete, get, post, put, web}; +use sea_orm::{ + ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set, TransactionTrait, +}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::app_data::AppData; +use crate::entity::sea_orm_active_enums::{RecurrenceType, TargetType}; +use crate::entity::{recurrence_rule, reminder_offset, todo}; +use crate::error::ApiError; +use crate::middleware::auth::AuthUser; +use crate::timer::WorkerCommand; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RecurrenceRuleInput { + pub r#type: String, + pub interval: Option, + pub by_weekday: Option, + pub by_monthday: Option, + pub timezone: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OffsetInput { + pub offset_minutes: i32, + pub channel_inapp: Option, + pub channel_bark: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TodoInput { + pub title: String, + pub description: Option, + pub due_at: chrono::DateTime, + pub recurrence_rule: Option, + pub offsets: Option>, +} + +#[derive(Debug, Serialize)] +pub struct OkResponse { + pub ok: bool, +} + +#[get("")] +async fn list_todos( + app_data: web::Data, + auth: AuthUser, +) -> Result { + let items = todo::Entity::find() + .filter(todo::Column::OwnerId.eq(auth.user_id)) + .order_by_asc(todo::Column::DueAt) + .find_also_related(recurrence_rule::Entity) + .all(&app_data.db) + .await?; + + let todo_ids: Vec = items.iter().map(|(t, _)| t.id).collect(); + + let offsets = reminder_offset::Entity::find() + .filter(reminder_offset::Column::TargetType.eq(TargetType::Todo)) + .filter(reminder_offset::Column::TargetId.is_in(todo_ids)) + .all(&app_data.db) + .await?; + + let result: Vec = items + .into_iter() + .map(|(t, rule)| { + let todo_offsets: Vec<_> = offsets + .iter() + .filter(|o| o.target_id == t.id) + .cloned() + .collect(); + serde_json::json!({ + "id": t.id, + "ownerId": t.owner_id, + "title": t.title, + "description": t.description, + "dueAt": t.due_at, + "recurrenceRuleId": t.recurrence_rule_id, + "createdAt": t.created_at, + "updatedAt": t.updated_at, + "checkInAt": t.check_in_at, + "checkInCount": t.check_in_count, + "isCheckedIn": t.is_checked_in, + "recurrenceRule": rule, + "offsets": todo_offsets, + }) + }) + .collect(); + + Ok(HttpResponse::Ok().json(result)) +} + +#[post("")] +async fn create_todo( + app_data: web::Data, + auth: AuthUser, + body: web::Json, +) -> Result { + if body.title.is_empty() { + return Err(ApiError::BadRequest("Invalid payload".to_string())); + } + + let now = chrono::Utc::now().fixed_offset(); + let body = body.into_inner(); + let user_id = auth.user_id; + + let result = app_data + .db + .transaction::<_, todo::Model, ApiError>(|txn| { + Box::pin(async move { + // Create recurrence rule if provided + let rule_id = if let Some(rule_input) = body.recurrence_rule { + let rule = recurrence_rule::ActiveModel { + id: Set(Uuid::new_v4()), + r#type: Set(parse_recurrence_type(&rule_input.r#type)?), + interval: Set(rule_input.interval.unwrap_or(1)), + by_weekday: Set(rule_input.by_weekday), + by_monthday: Set(rule_input.by_monthday), + timezone: Set(rule_input.timezone.unwrap_or("Asia/Shanghai".to_string())), + created_at: Set(now), + updated_at: Set(now), + }; + let created = rule.insert(txn).await?; + Some(created.id) + } else { + None + }; + + // Create todo + let new_todo = todo::ActiveModel { + id: Set(Uuid::new_v4()), + owner_id: Set(user_id), + title: Set(body.title), + description: Set(body.description), + due_at: Set(body.due_at.fixed_offset()), + recurrence_rule_id: Set(rule_id), + created_at: Set(now), + updated_at: Set(now), + check_in_at: Set(None), + check_in_count: Set(0), + is_checked_in: Set(false), + }; + let created_todo = new_todo.insert(txn).await?; + + // Create offsets + if let Some(offsets) = body.offsets { + for offset in offsets { + let new_offset = reminder_offset::ActiveModel { + id: Set(Uuid::new_v4()), + target_type: Set(TargetType::Todo), + target_id: Set(created_todo.id), + offset_minutes: Set(offset.offset_minutes), + channel_inapp: Set(offset.channel_inapp.unwrap_or(true)), + channel_bark: Set(offset.channel_bark.unwrap_or(false)), + created_at: Set(now), + bark_title: Set(None), + bark_subtitle: Set(None), + bark_body_markdown: Set(None), + bark_level: Set(None), + bark_icon: Set(None), + }; + new_offset.insert(txn).await?; + } + } + + Ok(created_todo) + }) + }) + .await + .map_err(|e| match e { + sea_orm::TransactionError::Connection(e) => ApiError::Internal(e.to_string()), + sea_orm::TransactionError::Transaction(e) => e, + })?; + + // 触发通知生成 + let _ = app_data + .send_worker_command(WorkerCommand::GenerateNotifications { + target_type: TargetType::Todo, + target_id: result.id, + }) + .await; + + Ok(HttpResponse::Ok().json(result)) +} + +#[get("/{id}")] +async fn get_todo( + app_data: web::Data, + auth: AuthUser, + path: web::Path, +) -> Result { + let id = path.into_inner(); + + let (t, rule) = todo::Entity::find_by_id(id) + .filter(todo::Column::OwnerId.eq(auth.user_id)) + .find_also_related(recurrence_rule::Entity) + .one(&app_data.db) + .await? + .ok_or_else(|| ApiError::NotFound("Not found".to_string()))?; + + let offsets = reminder_offset::Entity::find() + .filter(reminder_offset::Column::TargetType.eq(TargetType::Todo)) + .filter(reminder_offset::Column::TargetId.eq(t.id)) + .all(&app_data.db) + .await?; + + let result = serde_json::json!({ + "id": t.id, + "ownerId": t.owner_id, + "title": t.title, + "description": t.description, + "dueAt": t.due_at, + "recurrenceRuleId": t.recurrence_rule_id, + "createdAt": t.created_at, + "updatedAt": t.updated_at, + "checkInAt": t.check_in_at, + "checkInCount": t.check_in_count, + "isCheckedIn": t.is_checked_in, + "recurrenceRule": rule, + "offsets": offsets, + }); + + Ok(HttpResponse::Ok().json(result)) +} + +#[put("/{id}")] +async fn update_todo( + app_data: web::Data, + auth: AuthUser, + path: web::Path, + body: web::Json, +) -> Result { + let id = path.into_inner(); + + if body.title.is_empty() { + return Err(ApiError::BadRequest("Invalid payload".to_string())); + } + + let now = chrono::Utc::now().fixed_offset(); + let body = body.into_inner(); + let user_id = auth.user_id; + + let result = app_data + .db + .transaction::<_, todo::Model, ApiError>(|txn| { + Box::pin(async move { + let existing = todo::Entity::find_by_id(id) + .filter(todo::Column::OwnerId.eq(user_id)) + .one(txn) + .await? + .ok_or_else(|| ApiError::NotFound("Not found".to_string()))?; + + // Handle recurrence rule + let mut rule_id = existing.recurrence_rule_id; + if let Some(rule_input) = body.recurrence_rule { + if let Some(existing_rule_id) = rule_id { + // Update existing rule + let mut rule: recurrence_rule::ActiveModel = + recurrence_rule::Entity::find_by_id(existing_rule_id) + .one(txn) + .await? + .ok_or_else(|| ApiError::Internal("Rule not found".to_string()))? + .into(); + rule.r#type = Set(parse_recurrence_type(&rule_input.r#type)?); + rule.interval = Set(rule_input.interval.unwrap_or(1)); + rule.by_weekday = Set(rule_input.by_weekday); + rule.by_monthday = Set(rule_input.by_monthday); + rule.timezone = + Set(rule_input.timezone.unwrap_or("Asia/Shanghai".to_string())); + rule.updated_at = Set(now); + rule.update(txn).await?; + } else { + // Create new rule + let rule = recurrence_rule::ActiveModel { + id: Set(Uuid::new_v4()), + r#type: Set(parse_recurrence_type(&rule_input.r#type)?), + interval: Set(rule_input.interval.unwrap_or(1)), + by_weekday: Set(rule_input.by_weekday), + by_monthday: Set(rule_input.by_monthday), + timezone: Set(rule_input + .timezone + .unwrap_or("Asia/Shanghai".to_string())), + created_at: Set(now), + updated_at: Set(now), + }; + let created = rule.insert(txn).await?; + rule_id = Some(created.id); + } + } else if let Some(existing_rule_id) = rule_id { + // Delete existing rule + recurrence_rule::Entity::delete_by_id(existing_rule_id) + .exec(txn) + .await?; + rule_id = None; + } + + // Delete existing offsets and create new ones + reminder_offset::Entity::delete_many() + .filter(reminder_offset::Column::TargetType.eq(TargetType::Todo)) + .filter(reminder_offset::Column::TargetId.eq(id)) + .exec(txn) + .await?; + + if let Some(offsets) = body.offsets { + for offset in offsets { + let new_offset = reminder_offset::ActiveModel { + id: Set(Uuid::new_v4()), + target_type: Set(TargetType::Todo), + target_id: Set(id), + offset_minutes: Set(offset.offset_minutes), + channel_inapp: Set(offset.channel_inapp.unwrap_or(true)), + channel_bark: Set(offset.channel_bark.unwrap_or(false)), + created_at: Set(now), + bark_title: Set(None), + bark_subtitle: Set(None), + bark_body_markdown: Set(None), + bark_level: Set(None), + bark_icon: Set(None), + }; + new_offset.insert(txn).await?; + } + } + + // Update todo + let mut active: todo::ActiveModel = existing.into(); + active.title = Set(body.title); + active.description = Set(body.description); + active.due_at = Set(body.due_at.fixed_offset()); + active.recurrence_rule_id = Set(rule_id); + active.updated_at = Set(now); + let updated = active.update(txn).await?; + + Ok(updated) + }) + }) + .await + .map_err(|e| match e { + sea_orm::TransactionError::Connection(e) => ApiError::Internal(e.to_string()), + sea_orm::TransactionError::Transaction(e) => e, + })?; + + // 触发通知重新生成 + let _ = app_data + .send_worker_command(WorkerCommand::GenerateNotifications { + target_type: TargetType::Todo, + target_id: result.id, + }) + .await; + + Ok(HttpResponse::Ok().json(result)) +} + +#[post("/{id}/check-in")] +async fn check_in_todo( + app_data: web::Data, + auth: AuthUser, + path: web::Path, +) -> Result { + let id = path.into_inner(); + + // Find and verify ownership + let existing = todo::Entity::find_by_id(id) + .filter(todo::Column::OwnerId.eq(auth.user_id)) + .one(&app_data.db) + .await? + .ok_or_else(|| ApiError::NotFound("Not found".to_string()))?; + + let now = chrono::Utc::now().fixed_offset(); + + // Update check-in fields + let mut active: todo::ActiveModel = existing.clone().into(); + active.is_checked_in = Set(true); + active.check_in_at = Set(Some(now)); + active.check_in_count = Set(existing.check_in_count + 1); + active.updated_at = Set(now); + let updated = active.update(&app_data.db).await?; + + Ok(HttpResponse::Ok().json(updated)) +} + +#[delete("/{id}")] +async fn delete_todo( + app_data: web::Data, + auth: AuthUser, + path: web::Path, +) -> Result { + let id = path.into_inner(); + + let result = todo::Entity::delete_many() + .filter(todo::Column::Id.eq(id)) + .filter(todo::Column::OwnerId.eq(auth.user_id)) + .exec(&app_data.db) + .await?; + + if result.rows_affected == 0 { + return Err(ApiError::NotFound("Not found".to_string())); + } + + // Delete offsets + reminder_offset::Entity::delete_many() + .filter(reminder_offset::Column::TargetType.eq(TargetType::Todo)) + .filter(reminder_offset::Column::TargetId.eq(id)) + .exec(&app_data.db) + .await?; + + Ok(HttpResponse::Ok().json(OkResponse { ok: true })) +} + +fn parse_recurrence_type(s: &str) -> Result { + match s { + "hourly" => Ok(RecurrenceType::Hourly), + "daily" => Ok(RecurrenceType::Daily), + "weekly" => Ok(RecurrenceType::Weekly), + "monthly" => Ok(RecurrenceType::Monthly), + "yearly" => Ok(RecurrenceType::Yearly), + _ => Err(ApiError::BadRequest("Invalid recurrence type".to_string())), + } +} + +pub fn routes() -> Scope { + web::scope("/api/todos") + .service(list_todos) + .service(create_todo) + .service(get_todo) + .service(update_todo) + .service(check_in_todo) + .service(delete_todo) +} diff --git a/backend_rust/src/api/users.rs b/backend_rust/src/api/users.rs new file mode 100644 index 0000000..e7611d5 --- /dev/null +++ b/backend_rust/src/api/users.rs @@ -0,0 +1,64 @@ +use actix_web::{HttpResponse, Responder, Scope, get, web}; +use sea_orm::sea_query::extension::postgres::PgExpr; +use sea_orm::sea_query::Expr; +use sea_orm::{EntityTrait, QueryFilter, QueryOrder}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::app_data::AppData; +use crate::entity::user; +use crate::error::ApiError; +use crate::middleware::auth::AuthUser; + +#[derive(Debug, Deserialize)] +pub struct SearchQuery { + pub query: Option, +} + +#[derive(Debug, Serialize)] +pub struct UserResponse { + pub id: Uuid, + pub username: String, + pub avatar: Option, +} + +#[get("")] +async fn search_users( + app_data: web::Data, + _auth: AuthUser, + query: web::Query, +) -> Result { + let mut q = user::Entity::find(); + + if let Some(search) = &query.query { + let search = search.trim(); + if !search.is_empty() { + // 使用 ILIKE 进行不区分大小写的搜索(PostgreSQL) + let pattern = format!("%{}%", search); + q = q.filter( + Expr::col((user::Entity, user::Column::Username)) + .ilike(&pattern) + ); + } + } + + let users = q + .order_by_asc(user::Column::Username) + .all(&app_data.db) + .await?; + + let result: Vec = users + .into_iter() + .map(|u| UserResponse { + id: u.id, + username: u.username, + avatar: u.avatar, + }) + .collect(); + + Ok(HttpResponse::Ok().json(result)) +} + +pub fn routes() -> Scope { + web::scope("/api/users").service(search_users) +} diff --git a/backend_rust/src/app_data.rs b/backend_rust/src/app_data.rs new file mode 100644 index 0000000..17343fe --- /dev/null +++ b/backend_rust/src/app_data.rs @@ -0,0 +1,71 @@ +use migration::{Migrator, MigratorTrait}; +use sea_orm::{ConnectOptions, Database, DbConn}; +use std::env; +use std::path::PathBuf; +use tokio::sync::mpsc; +use tracing::info; + +use crate::timer::{NotificationWorker, SharedTimeWheel, WorkerCommand}; + +#[derive(Clone)] +pub struct AppData { + pub db: DbConn, + pub jwt_secret: String, + pub worker_tx: mpsc::Sender, + /// 服务器基础 URL,用于生成头像等资源的完整 URL + /// 本地调试: http://localhost:4000 + /// 生产环境: https://notify.michaelandmeryl.xyz + pub base_url: String, + /// 上传文件的存储目录 + pub upload_dir: PathBuf, +} + +impl AppData { + pub async fn new() -> Result { + let url = env::var("DATABASE_URL") + .unwrap_or_else(|_| "postgres://postgres:postgres@localhost:5432/notify".to_string()); + let mut opt = ConnectOptions::new(url); + opt.max_connections(10).sqlx_logging(false); + let db = Database::connect(opt).await?; + + // 自动运行数据库迁移 + info!("Running database migrations..."); + Migrator::up(&db, None).await?; + info!("Database migrations completed."); + + let jwt_secret = env::var("JWT_SECRET").unwrap_or_else(|_| "dev-secret".to_string()); + + // 从环境变量读取 BASE_URL,默认为本地开发地址 + let base_url = env::var("BASE_URL").unwrap_or_else(|_| "http://localhost:4000".to_string()); + + // 上传目录,默认为当前目录下的 uploads + let upload_dir = env::var("UPLOAD_DIR") + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from("./uploads")); + + // 确保上传目录存在 + tokio::fs::create_dir_all(&upload_dir).await?; + tokio::fs::create_dir_all(upload_dir.join("avatars")).await?; + + // 创建并启动时间轮 Worker + let time_wheel = SharedTimeWheel::new(); + let worker = NotificationWorker::new(db.clone(), time_wheel); + let worker_tx = worker.start().await; + + Ok(Self { + db, + jwt_secret, + worker_tx, + base_url, + upload_dir, + }) + } + + /// 发送命令给 Worker + pub async fn send_worker_command( + &self, + cmd: WorkerCommand, + ) -> Result<(), mpsc::error::SendError> { + self.worker_tx.send(cmd).await + } +} diff --git a/backend_rust/src/entity/delivery_log.rs b/backend_rust/src/entity/delivery_log.rs new file mode 100644 index 0000000..96b39f4 --- /dev/null +++ b/backend_rust/src/entity/delivery_log.rs @@ -0,0 +1,41 @@ +//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0 + +use super::sea_orm_active_enums::ChannelType; +use super::sea_orm_active_enums::NotificationStatus; +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "delivery_log")] +#[sea_orm::model] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub notification_id: Uuid, + pub attempt_no: i32, + pub channel: ChannelType, + pub status: NotificationStatus, + #[sea_orm(column_type = "JsonBinary", nullable)] + pub response_meta: Option, + pub created_at: DateTimeWithTimeZone, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::notification::Entity", + from = "Column::NotificationId", + to = "super::notification::Column::Id", + on_update = "Cascade", + on_delete = "Cascade" + )] + Notification, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Notification.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/backend_rust/src/entity/invite.rs b/backend_rust/src/entity/invite.rs new file mode 100644 index 0000000..c7f27fc --- /dev/null +++ b/backend_rust/src/entity/invite.rs @@ -0,0 +1,40 @@ +//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0 + +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "invite")] +#[sea_orm::model] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + #[sea_orm(unique)] + pub code: String, + pub creator_id: Uuid, + pub max_uses: i32, + pub used_count: i32, + pub expires_at: DateTimeWithTimeZone, + pub revoked_at: Option, + pub created_at: DateTimeWithTimeZone, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::user::Entity", + from = "Column::CreatorId", + to = "super::user::Column::Id", + on_update = "Cascade", + on_delete = "Cascade" + )] + User, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::User.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/backend_rust/src/entity/mod.rs b/backend_rust/src/entity/mod.rs new file mode 100644 index 0000000..85e2fc7 --- /dev/null +++ b/backend_rust/src/entity/mod.rs @@ -0,0 +1,14 @@ +//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0 + +pub mod prelude; + +pub mod delivery_log; +pub mod invite; +pub mod notification; +pub mod recurrence_rule; +pub mod reminder_offset; +pub mod reminder_task; +pub mod reminder_task_recipient; +pub mod sea_orm_active_enums; +pub mod todo; +pub mod user; diff --git a/backend_rust/src/entity/notification.rs b/backend_rust/src/entity/notification.rs new file mode 100644 index 0000000..aec111f --- /dev/null +++ b/backend_rust/src/entity/notification.rs @@ -0,0 +1,61 @@ +//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0 + +use super::sea_orm_active_enums::ChannelType; +use super::sea_orm_active_enums::NotificationStatus; +use super::sea_orm_active_enums::TargetType; +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "notification")] +#[sea_orm::model] +#[serde(rename_all = "camelCase")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + #[sea_orm(unique_key = "UQ_notification_recipient_target_trigger_channel")] + pub recipient_id: Uuid, + #[sea_orm(unique_key = "UQ_notification_recipient_target_trigger_channel")] + pub target_type: TargetType, + #[sea_orm(unique_key = "UQ_notification_recipient_target_trigger_channel")] + pub target_id: Uuid, + #[sea_orm(unique_key = "UQ_notification_recipient_target_trigger_channel")] + pub trigger_at: DateTimeWithTimeZone, + #[sea_orm(unique_key = "UQ_notification_recipient_target_trigger_channel")] + pub channel: ChannelType, + pub status: NotificationStatus, + pub locked_at: Option, + pub sent_at: Option, + pub read_at: Option, + pub created_at: DateTimeWithTimeZone, + pub updated_at: DateTimeWithTimeZone, + pub offset_id: Option, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm(has_many = "super::delivery_log::Entity")] + DeliveryLog, + #[sea_orm( + belongs_to = "super::user::Entity", + from = "Column::RecipientId", + to = "super::user::Column::Id", + on_update = "Cascade", + on_delete = "Cascade" + )] + User, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::DeliveryLog.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::User.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/backend_rust/src/entity/prelude.rs b/backend_rust/src/entity/prelude.rs new file mode 100644 index 0000000..38cb9fc --- /dev/null +++ b/backend_rust/src/entity/prelude.rs @@ -0,0 +1,11 @@ +//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0 + +pub use super::delivery_log::Entity as DeliveryLog; +pub use super::invite::Entity as Invite; +pub use super::notification::Entity as Notification; +pub use super::recurrence_rule::Entity as RecurrenceRule; +pub use super::reminder_offset::Entity as ReminderOffset; +pub use super::reminder_task::Entity as ReminderTask; +pub use super::reminder_task_recipient::Entity as ReminderTaskRecipient; +pub use super::todo::Entity as Todo; +pub use super::user::Entity as User; diff --git a/backend_rust/src/entity/recurrence_rule.rs b/backend_rust/src/entity/recurrence_rule.rs new file mode 100644 index 0000000..e8adc5c --- /dev/null +++ b/backend_rust/src/entity/recurrence_rule.rs @@ -0,0 +1,43 @@ +//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0 + +use super::sea_orm_active_enums::RecurrenceType; +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "recurrence_rule")] +#[sea_orm::model] +#[serde(rename_all = "camelCase")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub r#type: RecurrenceType, + pub interval: i32, + pub by_weekday: Option, + pub by_monthday: Option, + pub timezone: String, + pub created_at: DateTimeWithTimeZone, + pub updated_at: DateTimeWithTimeZone, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm(has_many = "super::reminder_task::Entity")] + ReminderTask, + #[sea_orm(has_many = "super::todo::Entity")] + Todo, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::ReminderTask.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Todo.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/backend_rust/src/entity/reminder_offset.rs b/backend_rust/src/entity/reminder_offset.rs new file mode 100644 index 0000000..1c1e5b9 --- /dev/null +++ b/backend_rust/src/entity/reminder_offset.rs @@ -0,0 +1,31 @@ +//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0 + +use super::sea_orm_active_enums::TargetType; +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "reminder_offset")] +#[sea_orm::model] +#[serde(rename_all = "camelCase")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub target_type: TargetType, + pub target_id: Uuid, + pub offset_minutes: i32, + pub channel_inapp: bool, + pub channel_bark: bool, + pub created_at: DateTimeWithTimeZone, + pub bark_title: Option, + pub bark_subtitle: Option, + #[sea_orm(column_type = "Text", nullable)] + pub bark_body_markdown: Option, + pub bark_level: Option, + pub bark_icon: Option, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/backend_rust/src/entity/reminder_task.rs b/backend_rust/src/entity/reminder_task.rs new file mode 100644 index 0000000..87815a9 --- /dev/null +++ b/backend_rust/src/entity/reminder_task.rs @@ -0,0 +1,68 @@ +//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0 + +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "reminder_task")] +#[sea_orm::model] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub creator_id: Uuid, + pub title: String, + pub description: Option, + pub due_at: DateTimeWithTimeZone, + pub recurrence_rule_id: Option, + pub created_at: DateTimeWithTimeZone, + pub updated_at: DateTimeWithTimeZone, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::recurrence_rule::Entity", + from = "Column::RecurrenceRuleId", + to = "super::recurrence_rule::Column::Id", + on_update = "Cascade", + on_delete = "SetNull" + )] + RecurrenceRule, + #[sea_orm(has_many = "super::reminder_task_recipient::Entity")] + ReminderTaskRecipient, + #[sea_orm( + belongs_to = "super::user::Entity", + from = "Column::CreatorId", + to = "super::user::Column::Id", + on_update = "Cascade", + on_delete = "Cascade" + )] + User, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::RecurrenceRule.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::ReminderTaskRecipient.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + super::reminder_task_recipient::Relation::User.def() + } + fn via() -> Option { + Some( + super::reminder_task_recipient::Relation::ReminderTask + .def() + .rev(), + ) + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/backend_rust/src/entity/reminder_task_recipient.rs b/backend_rust/src/entity/reminder_task_recipient.rs new file mode 100644 index 0000000..59b5a8e --- /dev/null +++ b/backend_rust/src/entity/reminder_task_recipient.rs @@ -0,0 +1,49 @@ +//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0 + +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "reminder_task_recipient")] +#[sea_orm::model] +#[serde(rename_all = "camelCase")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub task_id: Uuid, + #[sea_orm(primary_key, auto_increment = false)] + pub user_id: Uuid, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::reminder_task::Entity", + from = "Column::TaskId", + to = "super::reminder_task::Column::Id", + on_update = "Cascade", + on_delete = "Cascade" + )] + ReminderTask, + #[sea_orm( + belongs_to = "super::user::Entity", + from = "Column::UserId", + to = "super::user::Column::Id", + on_update = "Cascade", + on_delete = "Cascade" + )] + User, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::ReminderTask.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::User.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/backend_rust/src/entity/sea_orm_active_enums.rs b/backend_rust/src/entity/sea_orm_active_enums.rs new file mode 100644 index 0000000..ed08ee1 --- /dev/null +++ b/backend_rust/src/entity/sea_orm_active_enums.rs @@ -0,0 +1,51 @@ +//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0 + +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, EnumIter, DeriveActiveEnum, Serialize, Deserialize)] +#[sea_orm(rs_type = "String", db_type = "Enum", enum_name = "channel_type")] +pub enum ChannelType { + #[sea_orm(string_value = "inapp")] + Inapp, + #[sea_orm(string_value = "bark")] + Bark, +} +#[derive(Debug, Clone, PartialEq, Eq, EnumIter, DeriveActiveEnum, Serialize, Deserialize)] +#[sea_orm( + rs_type = "String", + db_type = "Enum", + enum_name = "notification_status" +)] +pub enum NotificationStatus { + #[sea_orm(string_value = "pending")] + Pending, + #[sea_orm(string_value = "queued")] + Queued, + #[sea_orm(string_value = "sent")] + Sent, + #[sea_orm(string_value = "failed")] + Failed, +} +#[derive(Debug, Clone, PartialEq, Eq, EnumIter, DeriveActiveEnum, Serialize, Deserialize)] +#[sea_orm(rs_type = "String", db_type = "Enum", enum_name = "recurrence_type")] +pub enum RecurrenceType { + #[sea_orm(string_value = "hourly")] + Hourly, + #[sea_orm(string_value = "daily")] + Daily, + #[sea_orm(string_value = "weekly")] + Weekly, + #[sea_orm(string_value = "monthly")] + Monthly, + #[sea_orm(string_value = "yearly")] + Yearly, +} +#[derive(Debug, Clone, PartialEq, Eq, EnumIter, DeriveActiveEnum, Serialize, Deserialize)] +#[sea_orm(rs_type = "String", db_type = "Enum", enum_name = "target_type")] +pub enum TargetType { + #[sea_orm(string_value = "todo")] + Todo, + #[sea_orm(string_value = "reminder_task")] + ReminderTask, +} diff --git a/backend_rust/src/entity/todo.rs b/backend_rust/src/entity/todo.rs new file mode 100644 index 0000000..20cd728 --- /dev/null +++ b/backend_rust/src/entity/todo.rs @@ -0,0 +1,56 @@ +//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0 + +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "todo")] +#[sea_orm::model] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub owner_id: Uuid, + pub title: String, + pub description: Option, + pub due_at: DateTimeWithTimeZone, + pub recurrence_rule_id: Option, + pub created_at: DateTimeWithTimeZone, + pub updated_at: DateTimeWithTimeZone, + pub check_in_at: Option, + pub check_in_count: i32, + pub is_checked_in: bool, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::recurrence_rule::Entity", + from = "Column::RecurrenceRuleId", + to = "super::recurrence_rule::Column::Id", + on_update = "Cascade", + on_delete = "SetNull" + )] + RecurrenceRule, + #[sea_orm( + belongs_to = "super::user::Entity", + from = "Column::OwnerId", + to = "super::user::Column::Id", + on_update = "Cascade", + on_delete = "Cascade" + )] + User, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::RecurrenceRule.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::User.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/backend_rust/src/entity/user.rs b/backend_rust/src/entity/user.rs new file mode 100644 index 0000000..8cac2f8 --- /dev/null +++ b/backend_rust/src/entity/user.rs @@ -0,0 +1,79 @@ +//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0 + +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "user")] +#[sea_orm::model] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + #[sea_orm(unique)] + pub username: String, + #[serde(skip_serializing)] + pub password_hash: String, + pub avatar: Option, + pub timezone: String, + pub bark_url: Option, + pub inapp_enabled: bool, + pub bark_enabled: bool, + pub created_at: DateTimeWithTimeZone, + pub updated_at: DateTimeWithTimeZone, + pub invite_id: Option, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::invite::Entity", + from = "Column::InviteId", + to = "super::invite::Column::Id", + on_update = "Cascade", + on_delete = "SetNull" + )] + Invite, + #[sea_orm(has_many = "super::notification::Entity")] + Notification, + #[sea_orm(has_many = "super::reminder_task::Entity")] + ReminderTask, + #[sea_orm(has_many = "super::reminder_task_recipient::Entity")] + ReminderTaskRecipient, + #[sea_orm(has_many = "super::todo::Entity")] + Todo, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Invite.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Notification.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::ReminderTaskRecipient.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Todo.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + super::reminder_task_recipient::Relation::ReminderTask.def() + } + fn via() -> Option { + Some(super::reminder_task_recipient::Relation::User.def().rev()) + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/backend_rust/src/error.rs b/backend_rust/src/error.rs new file mode 100644 index 0000000..8ea8bde --- /dev/null +++ b/backend_rust/src/error.rs @@ -0,0 +1,69 @@ +use actix_web::{HttpResponse, ResponseError, http::StatusCode}; +use serde::Serialize; +use std::fmt; + +#[derive(Debug)] +pub enum ApiError { + BadRequest(String), + Unauthorized(String), + Forbidden(String), + NotFound(String), + Conflict(String), + Internal(String), +} + +#[derive(Serialize)] +struct ErrorResponse { + error: String, +} + +impl fmt::Display for ApiError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ApiError::BadRequest(msg) => write!(f, "{}", msg), + ApiError::Unauthorized(msg) => write!(f, "{}", msg), + ApiError::Forbidden(msg) => write!(f, "{}", msg), + ApiError::NotFound(msg) => write!(f, "{}", msg), + ApiError::Conflict(msg) => write!(f, "{}", msg), + ApiError::Internal(msg) => write!(f, "{}", msg), + } + } +} + +impl ResponseError for ApiError { + fn error_response(&self) -> HttpResponse { + let error_response = ErrorResponse { + error: self.to_string(), + }; + HttpResponse::build(self.status_code()).json(error_response) + } + + fn status_code(&self) -> StatusCode { + match self { + ApiError::BadRequest(_) => StatusCode::BAD_REQUEST, + ApiError::Unauthorized(_) => StatusCode::UNAUTHORIZED, + ApiError::Forbidden(_) => StatusCode::FORBIDDEN, + ApiError::NotFound(_) => StatusCode::NOT_FOUND, + ApiError::Conflict(_) => StatusCode::CONFLICT, + ApiError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR, + } + } +} + +impl From for ApiError { + fn from(err: sea_orm::DbErr) -> Self { + ApiError::Internal(err.to_string()) + } +} + +impl From for ApiError { + fn from(err: bcrypt::BcryptError) -> Self { + ApiError::Internal(err.to_string()) + } +} + +impl From for ApiError { + fn from(_: jsonwebtoken::errors::Error) -> Self { + ApiError::Unauthorized("Invalid token".to_string()) + } +} diff --git a/backend_rust/src/lib.rs b/backend_rust/src/lib.rs new file mode 100644 index 0000000..02226f8 --- /dev/null +++ b/backend_rust/src/lib.rs @@ -0,0 +1,6 @@ +pub mod api; +pub mod app_data; +pub mod entity; +pub mod error; +pub mod middleware; +pub mod timer; diff --git a/backend_rust/src/main.rs b/backend_rust/src/main.rs new file mode 100644 index 0000000..aff8b6b --- /dev/null +++ b/backend_rust/src/main.rs @@ -0,0 +1,64 @@ +use actix_cors::Cors; +use actix_files::Files; +use actix_web::{App, HttpServer, web}; +use backend_rust::api::{auth, health, invites, me, notifications, reminder_tasks, todos, users}; +use backend_rust::app_data::AppData; +use std::env; +use std::path::PathBuf; +use tracing::{error, info}; + +#[actix_web::main] +async fn main() -> std::io::Result<()> { + let format = tracing_subscriber::fmt::format().pretty(); + tracing_subscriber::fmt().event_format(format).init(); + + // 从环境变量读取配置,使用默认值 + let host = env::var("HOST").unwrap_or_else(|_| "0.0.0.0".to_string()); + let port: u16 = env::var("PORT") + .unwrap_or_else(|_| "4000".to_string()) + .parse() + .expect("PORT must be a valid number"); + + // 上传目录配置 + let upload_dir = env::var("UPLOAD_DIR") + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from("./uploads")); + + let app_data = match AppData::new().await { + Ok(app_data) => web::Data::new(app_data), + Err(e) => { + error!("Failed to connect to database: {}", e); + std::process::exit(1); + } + }; + info!("Starting server on {}:{}", host, port); + info!("Upload directory: {:?}", upload_dir); + + HttpServer::new(move || { + let cors = Cors::default() + .allow_any_header() + .allow_any_method() + .allow_any_origin(); + + App::new() + .wrap(cors) + .app_data(app_data.clone()) + .configure(configure_routes) + // 配置静态文件服务,用于提供上传的文件(如头像) + .service(Files::new("/uploads", upload_dir.clone()).show_files_listing()) + }) + .bind((host, port))? + .run() + .await +} + +fn configure_routes(m: &mut web::ServiceConfig) { + m.service(health::routes()) + .service(auth::routes()) + .service(invites::routes()) + .service(me::routes()) + .service(notifications::routes()) + .service(todos::routes()) + .service(reminder_tasks::routes()) + .service(users::routes()); +} diff --git a/backend_rust/src/middleware/auth.rs b/backend_rust/src/middleware/auth.rs new file mode 100644 index 0000000..c42ba73 --- /dev/null +++ b/backend_rust/src/middleware/auth.rs @@ -0,0 +1,76 @@ +use actix_web::{FromRequest, HttpRequest, dev::Payload, web}; +use jsonwebtoken::{DecodingKey, Validation, decode}; +use serde::{Deserialize, Serialize}; +use std::future::{Ready, ready}; +use uuid::Uuid; + +use crate::app_data::AppData; +use crate::error::ApiError; + +#[derive(Debug, Serialize, Deserialize)] +pub struct Claims { + pub user_id: Uuid, + pub exp: usize, +} + +#[derive(Debug, Clone)] +pub struct AuthUser { + pub user_id: Uuid, +} + +impl FromRequest for AuthUser { + type Error = ApiError; + type Future = Ready>; + + fn from_request(req: &HttpRequest, _payload: &mut Payload) -> Self::Future { + let result = extract_auth_user(req); + ready(result) + } +} + +fn extract_auth_user(req: &HttpRequest) -> Result { + let app_data = req + .app_data::>() + .ok_or_else(|| ApiError::Internal("AppData not found".to_string()))?; + + let auth_header = req + .headers() + .get("Authorization") + .and_then(|h| h.to_str().ok()) + .ok_or_else(|| ApiError::Unauthorized("Missing authorization header".to_string()))?; + + let token = auth_header + .strip_prefix("Bearer ") + .ok_or_else(|| ApiError::Unauthorized("Invalid authorization header".to_string()))?; + + let token_data = decode::( + token, + &DecodingKey::from_secret(app_data.jwt_secret.as_bytes()), + &Validation::default(), + ) + .map_err(|_| ApiError::Unauthorized("Invalid token".to_string()))?; + + Ok(AuthUser { + user_id: token_data.claims.user_id, + }) +} + +pub fn create_token(user_id: Uuid, secret: &str) -> Result { + use jsonwebtoken::{EncodingKey, Header, encode}; + + let expiration = chrono::Utc::now() + .checked_add_signed(chrono::Duration::days(7)) + .expect("valid timestamp") + .timestamp() as usize; + + let claims = Claims { + user_id, + exp: expiration, + }; + + encode( + &Header::default(), + &claims, + &EncodingKey::from_secret(secret.as_bytes()), + ) +} diff --git a/backend_rust/src/middleware/mod.rs b/backend_rust/src/middleware/mod.rs new file mode 100644 index 0000000..0e4a05d --- /dev/null +++ b/backend_rust/src/middleware/mod.rs @@ -0,0 +1 @@ +pub mod auth; diff --git a/backend_rust/src/timer/mod.rs b/backend_rust/src/timer/mod.rs new file mode 100644 index 0000000..2545583 --- /dev/null +++ b/backend_rust/src/timer/mod.rs @@ -0,0 +1,7 @@ +pub mod recurrence; +pub mod time_wheel; +pub mod worker; + +pub use recurrence::calculate_next_due; +pub use time_wheel::{SharedTimeWheel, TimerTask, TimeWheel}; +pub use worker::{NotificationWorker, WorkerCommand}; diff --git a/backend_rust/src/timer/recurrence.rs b/backend_rust/src/timer/recurrence.rs new file mode 100644 index 0000000..e98d1b0 --- /dev/null +++ b/backend_rust/src/timer/recurrence.rs @@ -0,0 +1,207 @@ +use chrono::{DateTime, Datelike, Duration, FixedOffset, NaiveDateTime, TimeZone, Weekday}; + +use crate::entity::recurrence_rule; +use crate::entity::sea_orm_active_enums::RecurrenceType; + +/// 根据循环规则计算下一次触发时间 +pub fn calculate_next_due( + rule: &recurrence_rule::Model, + current_due: DateTime, +) -> Option> { + let interval = rule.interval.max(1) as i64; + let offset = current_due.offset().clone(); + + match rule.r#type { + RecurrenceType::Hourly => { + // 每 N 小时 + Some(current_due + Duration::hours(interval)) + } + RecurrenceType::Daily => { + // 每 N 天 + Some(current_due + Duration::days(interval)) + } + RecurrenceType::Weekly => { + // 每 N 周,可选指定星期几 + if let Some(weekday) = rule.by_weekday { + // 找到下一个指定的星期几 + let target_weekday = num_to_weekday(weekday); + let next = current_due + Duration::weeks(interval); + + // 调整到目标星期几 + let current_weekday = next.weekday(); + let days_ahead = (target_weekday.num_days_from_monday() as i64 + - current_weekday.num_days_from_monday() as i64 + + 7) + % 7; + + if days_ahead == 0 { + // 同一天,保持当前时间 + Some(next) + } else { + Some(next + Duration::days(days_ahead)) + } + } else { + Some(current_due + Duration::weeks(interval)) + } + } + RecurrenceType::Monthly => { + // 每 N 月,可选指定几号 + let target_day = rule.by_monthday.unwrap_or(current_due.day() as i32) as u32; + + let mut year = current_due.year(); + let mut month = current_due.month() as i32 + interval as i32; + + // 处理年份进位 + while month > 12 { + month -= 12; + year += 1; + } + + // 处理月份天数不足的情况(如 2 月没有 31 号) + let day = target_day.min(days_in_month(year, month as u32)); + + let naive = NaiveDateTime::new( + chrono::NaiveDate::from_ymd_opt(year, month as u32, day)?, + current_due.time(), + ); + Some(offset.from_local_datetime(&naive).single()?) + } + RecurrenceType::Yearly => { + // 每 N 年 + let year = current_due.year() + interval as i32; + let month = current_due.month(); + let day = current_due.day().min(days_in_month(year, month)); + + let naive = NaiveDateTime::new( + chrono::NaiveDate::from_ymd_opt(year, month, day)?, + current_due.time(), + ); + Some(offset.from_local_datetime(&naive).single()?) + } + } +} + +fn num_to_weekday(num: i32) -> Weekday { + match num % 7 { + 0 => Weekday::Sun, + 1 => Weekday::Mon, + 2 => Weekday::Tue, + 3 => Weekday::Wed, + 4 => Weekday::Thu, + 5 => Weekday::Fri, + 6 => Weekday::Sat, + _ => Weekday::Mon, + } +} + +fn days_in_month(year: i32, month: u32) -> u32 { + match month { + 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31, + 4 | 6 | 9 | 11 => 30, + 2 => { + if is_leap_year(year) { + 29 + } else { + 28 + } + } + _ => 30, + } +} + +fn is_leap_year(year: i32) -> bool { + (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0) +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::{NaiveDate, Timelike}; + use uuid::Uuid; + + fn make_rule( + rule_type: RecurrenceType, + interval: i32, + by_weekday: Option, + by_monthday: Option, + ) -> recurrence_rule::Model { + recurrence_rule::Model { + id: Uuid::new_v4(), + r#type: rule_type, + interval, + by_weekday, + by_monthday, + timezone: "Asia/Shanghai".to_string(), + created_at: chrono::Utc::now().fixed_offset(), + updated_at: chrono::Utc::now().fixed_offset(), + } + } + + fn make_datetime(year: i32, month: u32, day: u32, hour: u32, min: u32) -> DateTime { + let naive = NaiveDate::from_ymd_opt(year, month, day) + .unwrap() + .and_hms_opt(hour, min, 0) + .unwrap(); + FixedOffset::east_opt(0).unwrap().from_local_datetime(&naive).unwrap() + } + + #[test] + fn test_hourly() { + let rule = make_rule(RecurrenceType::Hourly, 2, None, None); + let current = make_datetime(2024, 1, 15, 10, 30); + + let next = calculate_next_due(&rule, current).unwrap(); + assert_eq!(next.hour(), 12); + assert_eq!(next.minute(), 30); + } + + #[test] + fn test_daily() { + let rule = make_rule(RecurrenceType::Daily, 3, None, None); + let current = make_datetime(2024, 1, 15, 10, 30); + + let next = calculate_next_due(&rule, current).unwrap(); + assert_eq!(next.day(), 18); + } + + #[test] + fn test_weekly() { + let rule = make_rule(RecurrenceType::Weekly, 1, None, None); + let current = make_datetime(2024, 1, 15, 10, 30); + + let next = calculate_next_due(&rule, current).unwrap(); + assert_eq!(next.day(), 22); + } + + #[test] + fn test_monthly() { + let rule = make_rule(RecurrenceType::Monthly, 1, None, Some(15)); + let current = make_datetime(2024, 1, 15, 10, 30); + + let next = calculate_next_due(&rule, current).unwrap(); + assert_eq!(next.month(), 2); + assert_eq!(next.day(), 15); + } + + #[test] + fn test_monthly_overflow() { + // 1月31号 -> 2月没有31号,应该是28号(或29号) + let rule = make_rule(RecurrenceType::Monthly, 1, None, Some(31)); + let current = make_datetime(2024, 1, 31, 10, 30); + + let next = calculate_next_due(&rule, current).unwrap(); + assert_eq!(next.month(), 2); + assert_eq!(next.day(), 29); // 2024 是闰年 + } + + #[test] + fn test_yearly() { + let rule = make_rule(RecurrenceType::Yearly, 1, None, None); + let current = make_datetime(2024, 6, 15, 10, 30); + + let next = calculate_next_due(&rule, current).unwrap(); + assert_eq!(next.year(), 2025); + assert_eq!(next.month(), 6); + assert_eq!(next.day(), 15); + } +} diff --git a/backend_rust/src/timer/time_wheel.rs b/backend_rust/src/timer/time_wheel.rs new file mode 100644 index 0000000..26a8a3e --- /dev/null +++ b/backend_rust/src/timer/time_wheel.rs @@ -0,0 +1,395 @@ +use std::collections::HashMap; +use std::sync::Arc; +use tokio::sync::RwLock; +use uuid::Uuid; + +/// 时间轮任务 +#[derive(Clone, Debug)] +pub struct TimerTask { + pub id: Uuid, + pub notification_id: Uuid, + pub trigger_at: i64, // Unix timestamp in seconds +} + +/// 单层时间轮 +struct WheelLevel { + slots: Vec>, + current: usize, + slot_count: usize, + interval_secs: i64, // 每个槽位代表的秒数 +} + +impl WheelLevel { + fn new(slot_count: usize, interval_secs: i64) -> Self { + let slots = (0..slot_count).map(|_| Vec::new()).collect(); + Self { + slots, + current: 0, + slot_count, + interval_secs, + } + } + + /// 将任务添加到指定槽位 + fn add_to_slot(&mut self, slot: usize, task: TimerTask) { + self.slots[slot].push(task); + } + + /// 获取当前槽位的所有任务并清空 + fn take_current(&mut self) -> Vec { + std::mem::take(&mut self.slots[self.current]) + } + + /// 移动到下一个槽位,返回是否完成一圈 + fn advance(&mut self) -> bool { + self.current = (self.current + 1) % self.slot_count; + self.current == 0 + } +} + +/// 多级时间轮 +/// +/// 结构: +/// - Level 0: 秒轮 (60 slots, 每槽 1 秒) +/// - Level 1: 分钟轮 (60 slots, 每槽 60 秒) +/// - Level 2: 小时轮 (24 slots, 每槽 3600 秒) +/// - Level 3: 天轮 (30 slots, 每槽 86400 秒) +pub struct TimeWheel { + levels: Vec, + current_time: i64, // 当前时间 + task_index: HashMap, // task_id -> notification_id 用于取消任务 +} + +impl TimeWheel { + pub fn new() -> Self { + let now = chrono::Utc::now().timestamp(); + Self { + levels: vec![ + WheelLevel::new(60, 1), // 秒轮: 60槽, 每槽1秒 + WheelLevel::new(60, 60), // 分钟轮: 60槽, 每槽60秒 + WheelLevel::new(24, 3600), // 小时轮: 24槽, 每槽1小时 + WheelLevel::new(30, 86400), // 天轮: 30槽, 每槽1天 + ], + current_time: now, + task_index: HashMap::new(), + } + } + + /// 计算任务应该放入哪一层的哪个槽位 + /// + /// 时间轮结构: + /// - Level 0 (秒轮): 60 slots, interval=1s, 覆盖 1-60s 的延迟 + /// - Level 1 (分钟轮): 60 slots, interval=60s, 覆盖 60s-1h 的延迟 + /// - Level 2 (小时轮): 24 slots, interval=3600s, 覆盖 1h-24h 的延迟 + /// - Level 3 (天轮): 30 slots, interval=86400s, 覆盖 1d-30d 的延迟 + /// + /// 级联逻辑:当 Level N 完成一圈时,从 Level N+1 的当前槽取出任务降级 + fn calculate_slot(&self, trigger_at: i64) -> Option<(usize, usize)> { + let delay = trigger_at - self.current_time; + if delay <= 0 { + // 已经过期,放入秒轮当前槽位(立即执行) + return Some((0, self.levels[0].current)); + } + + // 计算每个层级的总覆盖范围 + let mut level_ranges: Vec = Vec::new(); + let mut cumulative = 0i64; + for level in &self.levels { + cumulative += level.slot_count as i64 * level.interval_secs; + level_ranges.push(cumulative); + } + + // 找到合适的层级 + let mut prev_range = 0i64; + for (level_idx, &range) in level_ranges.iter().enumerate() { + if delay <= range { + let level = &self.levels[level_idx]; + + // 计算槽位偏移 + // 对于秒轮:delay=5 -> slot 5 (从当前位置偏移5) + // 对于高层级轮:需要考虑何时会级联 + let slots_away = if level_idx == 0 { + delay as usize + } else { + // 高层级轮中,任务会在 (slots_away * lower_level_total_ticks) 后级联 + // 我们需要找到最小的 slots_away 使得 (slots_away * lower_level_total_ticks) >= delay + // 但不能太大,否则会延迟太久 + // + // 例如:delay=65, lower_level_range=60 + // 我们希望在 tick 60 时级联(slots_away=0),然后剩余 5 秒在 L0 处理 + // + // 公式:slots_away = (delay - prev_range - 1) / lower_level_range + // delay=65, prev_range=60: (65-60-1)/60 = 0 -> 在当前槽级联 + // delay=120, prev_range=60: (120-60-1)/60 = 0 -> 在当前槽级联 + // delay=121, prev_range=60: (121-60-1)/60 = 1 -> 在下一槽级联 + + // 实际上更简单:我们计算从当前槽开始,需要多少个槽位 + // delay 落在 (prev_range, range] 区间 + // 每个槽位覆盖 level.interval_secs 秒 + // 从当前槽开始,slot[0] 在下一次级联时处理,覆盖 (prev_range, prev_range + interval] + // slot[1] 覆盖 (prev_range + interval, prev_range + 2*interval] + // + // slots_away = (delay - prev_range - 1) / interval + ((delay - prev_range - 1) / level.interval_secs) as usize + }; + + let target_slot = (level.current + slots_away) % level.slot_count; + return Some((level_idx, target_slot)); + } + prev_range = range; + } + + // 超过最大范围,放入最高层级的最后一个槽位 + let last_level = self.levels.len() - 1; + Some(( + last_level, + (self.levels[last_level].current + self.levels[last_level].slot_count - 1) + % self.levels[last_level].slot_count, + )) + } + + /// 添加定时任务 + pub fn add_task(&mut self, task: TimerTask) -> bool { + if let Some((level, slot)) = self.calculate_slot(task.trigger_at) { + self.task_index.insert(task.id, task.notification_id); + self.levels[level].add_to_slot(slot, task); + true + } else { + false + } + } + + /// 取消任务(标记删除,实际在 tick 时过滤) + pub fn cancel_task(&mut self, task_id: Uuid) -> bool { + self.task_index.remove(&task_id).is_some() + } + + /// 检查任务是否有效(未被取消) + fn is_task_valid(&self, task: &TimerTask) -> bool { + self.task_index.contains_key(&task.id) + } + + /// 时间轮前进一秒 + /// 返回需要执行的任务列表 + pub fn tick(&mut self) -> Vec { + // 先移动到下一个槽位 + let cascade = self.levels[0].advance(); + self.current_time += 1; + + let mut ready_tasks = Vec::new(); + + // 级联处理高层级时间轮(在获取当前槽任务之前) + if cascade { + self.cascade_from_level(1); + } + + // 从秒轮获取当前槽位的任务 + let tasks = self.levels[0].take_current(); + for task in tasks { + if self.is_task_valid(&task) { + if task.trigger_at <= self.current_time { + // 任务已到期,加入执行队列 + self.task_index.remove(&task.id); + ready_tasks.push(task); + } else { + // 重新计算槽位(理论上不应该发生) + if let Some((level, slot)) = self.calculate_slot(task.trigger_at) { + self.levels[level].add_to_slot(slot, task); + } + } + } + } + + ready_tasks + } + + /// 从指定层级开始级联处理 + fn cascade_from_level(&mut self, start_level: usize) { + if start_level >= self.levels.len() { + return; + } + + // 从高层级轮取出当前槽位的任务,降级到低层级 + let tasks = self.levels[start_level].take_current(); + for task in tasks { + if self.is_task_valid(&task) { + // 重新计算槽位,降级到更低层级 + if let Some((new_level, slot)) = self.calculate_slot(task.trigger_at) { + self.levels[new_level].add_to_slot(slot, task); + } + } + } + + // 高层级轮前进 + let cascade = self.levels[start_level].advance(); + if cascade { + self.cascade_from_level(start_level + 1); + } + } + + /// 获取时间轮状态统计 + pub fn stats(&self) -> TimeWheelStats { + let mut total_tasks = 0; + let mut level_counts = Vec::new(); + + for level in &self.levels { + let count: usize = level.slots.iter().map(|s| s.len()).sum(); + level_counts.push(count); + total_tasks += count; + } + + TimeWheelStats { + total_tasks, + level_counts, + current_time: self.current_time, + } + } +} + +impl Default for TimeWheel { + fn default() -> Self { + Self::new() + } +} + +#[derive(Debug, Clone)] +pub struct TimeWheelStats { + pub total_tasks: usize, + pub level_counts: Vec, + pub current_time: i64, +} + +/// 线程安全的时间轮包装 +pub struct SharedTimeWheel { + inner: Arc>, +} + +impl SharedTimeWheel { + pub fn new() -> Self { + Self { + inner: Arc::new(RwLock::new(TimeWheel::new())), + } + } + + pub async fn add_task(&self, task: TimerTask) -> bool { + let mut wheel = self.inner.write().await; + wheel.add_task(task) + } + + pub async fn cancel_task(&self, task_id: Uuid) -> bool { + let mut wheel = self.inner.write().await; + wheel.cancel_task(task_id) + } + + pub async fn tick(&self) -> Vec { + let mut wheel = self.inner.write().await; + wheel.tick() + } + + pub async fn stats(&self) -> TimeWheelStats { + let wheel = self.inner.read().await; + wheel.stats() + } +} + +impl Default for SharedTimeWheel { + fn default() -> Self { + Self::new() + } +} + +impl Clone for SharedTimeWheel { + fn clone(&self) -> Self { + Self { + inner: Arc::clone(&self.inner), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_add_task_immediate() { + let mut wheel = TimeWheel::new(); + let now = wheel.current_time; + + let task = TimerTask { + id: Uuid::new_v4(), + notification_id: Uuid::new_v4(), + trigger_at: now + 5, // 5秒后 + }; + + assert!(wheel.add_task(task)); + assert_eq!(wheel.stats().total_tasks, 1); + } + + #[test] + fn test_tick_executes_task() { + let mut wheel = TimeWheel::new(); + let now = wheel.current_time; + + let task = TimerTask { + id: Uuid::new_v4(), + notification_id: Uuid::new_v4(), + trigger_at: now + 1, // 1秒后 + }; + + wheel.add_task(task.clone()); + + // 前进1秒 + let tasks = wheel.tick(); + assert_eq!(tasks.len(), 1); + assert_eq!(tasks[0].notification_id, task.notification_id); + } + + #[test] + fn test_cascade_from_minute_level() { + let mut wheel = TimeWheel::new(); + let now = wheel.current_time; + + let task = TimerTask { + id: Uuid::new_v4(), + notification_id: Uuid::new_v4(), + trigger_at: now + 65, // 65秒后,应该放入分钟轮 + }; + + wheel.add_task(task.clone()); + + // 分钟轮应该有任务 + assert!(wheel.stats().level_counts[1] > 0 || wheel.stats().level_counts[0] > 0); + + // 前进65秒 + let mut executed = Vec::new(); + for _ in 0..66 { + executed.extend(wheel.tick()); + } + + assert_eq!(executed.len(), 1); + assert_eq!(executed[0].notification_id, task.notification_id); + } + + #[test] + fn test_cancel_task() { + let mut wheel = TimeWheel::new(); + let now = wheel.current_time; + + let task = TimerTask { + id: Uuid::new_v4(), + notification_id: Uuid::new_v4(), + trigger_at: now + 5, + }; + + wheel.add_task(task.clone()); + assert!(wheel.cancel_task(task.id)); + + // 前进5秒,任务不应执行 + let mut executed = Vec::new(); + for _ in 0..6 { + executed.extend(wheel.tick()); + } + + assert!(executed.is_empty()); + } +} diff --git a/backend_rust/src/timer/worker.rs b/backend_rust/src/timer/worker.rs new file mode 100644 index 0000000..afeacff --- /dev/null +++ b/backend_rust/src/timer/worker.rs @@ -0,0 +1,842 @@ +use sea_orm::{ + ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, PaginatorTrait, QueryFilter, + QueryOrder, Set, +}; +use serde_json::json; +use std::sync::Arc; +use tokio::sync::mpsc; +use tokio::time::{Duration, interval}; +use tracing::{error, info, warn}; +use uuid::Uuid; + +use crate::entity::sea_orm_active_enums::{ChannelType, NotificationStatus, TargetType}; +use crate::entity::{ + delivery_log, notification, recurrence_rule, reminder_offset, reminder_task, todo, user, +}; +use crate::timer::recurrence::calculate_next_due; +use crate::timer::{SharedTimeWheel, TimerTask}; + +/// 重试退避时间(毫秒) +const BACKOFF_STEPS_MS: [i64; 5] = [ + 60_000, // 1 分钟 + 5 * 60_000, // 5 分钟 + 15 * 60_000, // 15 分钟 + 60 * 60_000, // 1 小时 + 4 * 60 * 60_000, // 4 小时 +]; + +/// 最大重试次数 +const MAX_RETRY_ATTEMPTS: i32 = 5; + +/// 锁超时时间(5 分钟) +const LOCK_TIMEOUT_MS: i64 = 5 * 60 * 1000; + +/// 计算退避时间 +fn calc_backoff_ms(attempt_no: i32) -> i64 { + let index = (attempt_no - 1) + .min(BACKOFF_STEPS_MS.len() as i32 - 1) + .max(0) as usize; + BACKOFF_STEPS_MS[index] +} + +/// 默认的提醒偏移配置 +struct DefaultOffset { + offset_minutes: i32, + channel_inapp: bool, + channel_bark: bool, +} + +impl Default for DefaultOffset { + fn default() -> Self { + Self { + offset_minutes: 0, + channel_inapp: true, + channel_bark: false, + } + } +} + +/// 通知推送 Worker +pub struct NotificationWorker { + db: DatabaseConnection, + time_wheel: SharedTimeWheel, + bark_client: reqwest::Client, +} + +impl NotificationWorker { + pub fn new(db: DatabaseConnection, time_wheel: SharedTimeWheel) -> Self { + Self { + db, + time_wheel, + bark_client: reqwest::Client::new(), + } + } + + /// 启动 Worker + /// 返回一个用于发送命令的 channel + pub async fn start(self) -> mpsc::Sender { + let (tx, mut rx) = mpsc::channel::(1000); + + let worker = Arc::new(self); + + // 时间轮 tick 任务 + let tick_worker = Arc::clone(&worker); + tokio::spawn(async move { + let mut ticker = interval(Duration::from_secs(1)); + loop { + ticker.tick().await; + let tasks = tick_worker.time_wheel.tick().await; + for task in tasks { + if let Err(e) = tick_worker.execute_notification(task.notification_id).await { + error!( + "Failed to execute notification {}: {}", + task.notification_id, e + ); + } + } + } + }); + + // 定期从数据库加载待处理通知 + let load_worker = Arc::clone(&worker); + tokio::spawn(async move { + let mut ticker = interval(Duration::from_secs(60)); // 每分钟检查一次 + loop { + ticker.tick().await; + if let Err(e) = load_worker.load_pending_notifications().await { + error!("Failed to load pending notifications: {}", e); + } + } + }); + + // 命令处理 + let cmd_worker = Arc::clone(&worker); + tokio::spawn(async move { + while let Some(cmd) = rx.recv().await { + match cmd { + WorkerCommand::ScheduleNotification { + notification_id, + trigger_at, + } => { + let task = TimerTask { + id: Uuid::new_v4(), + notification_id, + trigger_at, + }; + cmd_worker.time_wheel.add_task(task).await; + } + WorkerCommand::CancelNotification { notification_id } => { + // 标记数据库中的通知为取消状态 + if let Err(e) = cmd_worker.cancel_notification(notification_id).await { + error!("Failed to cancel notification {}: {}", notification_id, e); + } + } + WorkerCommand::GenerateNotifications { + target_type, + target_id, + } => { + if let Err(e) = cmd_worker + .generate_notifications(target_type.clone(), target_id) + .await + { + error!( + "Failed to generate notifications for {:?}/{}: {}", + target_type, target_id, e + ); + } + } + } + } + }); + + tx + } + + /// 从数据库加载待处理的通知到时间轮 + async fn load_pending_notifications( + &self, + ) -> Result<(), Box> { + let now = chrono::Utc::now().fixed_offset(); + let future_limit = now + chrono::Duration::hours(25); // 加载未来25小时的通知 + let expired_lock = now - chrono::Duration::milliseconds(LOCK_TIMEOUT_MS); + + // 查找待处理的通知(包括锁超时的) + let notifications = notification::Entity::find() + .filter(notification::Column::Status.eq(NotificationStatus::Pending)) + .filter(notification::Column::TriggerAt.lte(future_limit)) + .filter( + sea_orm::Condition::any() + .add(notification::Column::LockedAt.is_null()) + .add(notification::Column::LockedAt.lt(expired_lock)), + ) + .order_by_asc(notification::Column::TriggerAt) + .all(&self.db) + .await?; + + info!( + "Loading {} pending notifications into time wheel", + notifications.len() + ); + + for notif in notifications { + let trigger_at = notif.trigger_at.timestamp(); + let task = TimerTask { + id: Uuid::new_v4(), + notification_id: notif.id, + trigger_at, + }; + self.time_wheel.add_task(task).await; + } + + Ok(()) + } + + /// 执行通知推送 + async fn execute_notification( + &self, + notification_id: Uuid, + ) -> Result<(), Box> { + // 获取通知 + let notif = notification::Entity::find_by_id(notification_id) + .one(&self.db) + .await? + .ok_or("Notification not found")?; + + // 检查状态 + if notif.status != NotificationStatus::Pending { + warn!("Notification {} is not pending, skipping", notification_id); + return Ok(()); + } + + let now = chrono::Utc::now().fixed_offset(); + + // 锁定通知(防止重复处理) + let mut active: notification::ActiveModel = notif.clone().into(); + active.status = Set(NotificationStatus::Queued); + active.locked_at = Set(Some(now)); + active.updated_at = Set(now); + active.update(&self.db).await?; + + // 获取当前尝试次数 + let last_log = delivery_log::Entity::find() + .filter(delivery_log::Column::NotificationId.eq(notification_id)) + .order_by_desc(delivery_log::Column::AttemptNo) + .one(&self.db) + .await?; + let attempt_no = last_log.map(|l| l.attempt_no + 1).unwrap_or(1); + + // 获取接收者信息 + let recipient = user::Entity::find_by_id(notif.recipient_id) + .one(&self.db) + .await? + .ok_or("Recipient not found")?; + + // 根据渠道发送通知 + let result: Result<(), String> = match notif.channel { + ChannelType::Inapp => { + // 应用内通知:只需要更新状态即可 + Ok(()) + } + ChannelType::Bark => { + if !recipient.bark_enabled { + Err("bark_disabled".to_string()) + } else if let Some(bark_url) = &recipient.bark_url { + // 获取 offset 配置(如果有) + let offset = if let Some(offset_id) = notif.offset_id { + reminder_offset::Entity::find_by_id(offset_id) + .one(&self.db) + .await + .ok() + .flatten() + } else { + None + }; + + // 获取目标详情 + match self.get_notification_content(¬if).await { + Ok((default_title, default_body)) => { + // 使用 offset 中的自定义参数,如果没有则使用默认值 + let title = offset + .as_ref() + .and_then(|o| o.bark_title.clone()) + .unwrap_or(default_title); + let subtitle = offset.as_ref().and_then(|o| o.bark_subtitle.clone()); + let body_or_markdown = offset + .as_ref() + .and_then(|o| o.bark_body_markdown.clone()) + .or(Some(default_body)); + let level = offset.as_ref().and_then(|o| o.bark_level.clone()); + let icon = offset + .as_ref() + .and_then(|o| o.bark_icon.clone()) + .or_else(|| recipient.avatar.clone()); + tracing::info!("Sending Bark notification with icon: {:?}", icon); + self.send_bark_notification( + bark_url, + &title, + subtitle.as_deref(), + body_or_markdown.as_deref(), + level.as_deref(), + icon.as_deref(), + offset + .as_ref() + .and_then(|o| o.bark_body_markdown.as_ref()) + .is_some(), + ) + .await + .map_err(|e| e.to_string()) + } + Err(e) => Err(e.to_string()), + } + } else { + Err("no_bark_url".to_string()) + } + } + }; + + // 根据结果更新状态 + let target_type = notif.target_type.clone(); + let target_id = notif.target_id; + let channel = notif.channel.clone(); + let original_trigger_at = notif.trigger_at; + + match &result { + Ok(_) => { + // 发送成功 + let mut active: notification::ActiveModel = notif.into(); + active.status = Set(NotificationStatus::Sent); + active.sent_at = Set(Some(now)); + active.locked_at = Set(None); + active.updated_at = Set(now); + active.update(&self.db).await?; + + // 记录成功日志 + self.create_delivery_log( + notification_id, + attempt_no, + channel, + NotificationStatus::Sent, + None, + ) + .await?; + + // 检查是否需要推进周期 + self.check_and_advance_recurrence(target_type, target_id) + .await?; + } + Err(error_msg) => { + // 发送失败,检查是否需要重试 + let should_retry = attempt_no < MAX_RETRY_ATTEMPTS; + + if should_retry { + // 计算重试时间 + let backoff_ms = calc_backoff_ms(attempt_no); + let retry_at = now + chrono::Duration::milliseconds(backoff_ms); + + let mut active: notification::ActiveModel = notif.into(); + active.status = Set(NotificationStatus::Pending); + active.trigger_at = Set(retry_at); + active.locked_at = Set(None); + active.updated_at = Set(now); + active.update(&self.db).await?; + + // 重新添加到时间轮 + let task = TimerTask { + id: Uuid::new_v4(), + notification_id, + trigger_at: retry_at.timestamp(), + }; + self.time_wheel.add_task(task).await; + + info!( + "Notification {} scheduled for retry at {} (attempt {})", + notification_id, retry_at, attempt_no + ); + + // 记录重试日志 + self.create_delivery_log( + notification_id, + attempt_no, + channel, + NotificationStatus::Pending, + Some(json!({ "message": error_msg })), + ) + .await?; + } else { + // 超过最大重试次数,标记失败 + let mut active: notification::ActiveModel = notif.into(); + active.status = Set(NotificationStatus::Failed); + active.trigger_at = Set(original_trigger_at); // 恢复原始 trigger_at + active.locked_at = Set(None); + active.updated_at = Set(now); + active.update(&self.db).await?; + + error!( + "Notification {} failed after {} attempts: {}", + notification_id, attempt_no, error_msg + ); + + // 记录失败日志 + self.create_delivery_log( + notification_id, + attempt_no, + channel, + NotificationStatus::Failed, + Some(json!({ "message": error_msg })), + ) + .await?; + } + } + } + + Ok(()) + } + + /// 创建投递日志 + async fn create_delivery_log( + &self, + notification_id: Uuid, + attempt_no: i32, + channel: ChannelType, + status: NotificationStatus, + response_meta: Option, + ) -> Result<(), Box> { + let log = delivery_log::ActiveModel { + id: Set(Uuid::new_v4()), + notification_id: Set(notification_id), + attempt_no: Set(attempt_no), + channel: Set(channel), + status: Set(status), + response_meta: Set(response_meta), + created_at: Set(chrono::Utc::now().fixed_offset()), + }; + log.insert(&self.db).await?; + Ok(()) + } + + /// 检查目标是否有周期性规则,如果所有当前周期的通知都已处理,则推进到下一周期 + async fn check_and_advance_recurrence( + &self, + target_type: TargetType, + target_id: Uuid, + ) -> Result<(), Box> { + // 检查是否还有该目标的待处理通知 + let pending_count = notification::Entity::find() + .filter(notification::Column::TargetType.eq(target_type.clone())) + .filter(notification::Column::TargetId.eq(target_id)) + .filter(notification::Column::Status.eq(NotificationStatus::Pending)) + .count(&self.db) + .await?; + + if pending_count > 0 { + // 还有未处理的通知,不需要推进周期 + return Ok(()); + } + + // 获取目标及其循环规则 + match target_type { + TargetType::Todo => { + self.advance_todo_recurrence(target_id).await?; + } + TargetType::ReminderTask => { + self.advance_reminder_task_recurrence(target_id).await?; + } + } + + Ok(()) + } + + /// 推进 Todo 到下一周期 + async fn advance_todo_recurrence( + &self, + todo_id: Uuid, + ) -> Result<(), Box> { + let todo_item = todo::Entity::find_by_id(todo_id).one(&self.db).await?; + + let todo_item = match todo_item { + Some(t) => t, + None => return Ok(()), // Todo 已被删除 + }; + + // 检查是否有循环规则 + let rule_id = match todo_item.recurrence_rule_id { + Some(id) => id, + None => return Ok(()), // 非周期性任务 + }; + + let rule = recurrence_rule::Entity::find_by_id(rule_id) + .one(&self.db) + .await?; + + let rule = match rule { + Some(r) => r, + None => return Ok(()), // 规则已被删除 + }; + + // 计算下一个 due_at + let next_due = match calculate_next_due(&rule, todo_item.due_at) { + Some(d) => d, + None => { + warn!("Failed to calculate next due for todo {}", todo_id); + return Ok(()); + } + }; + + info!( + "Advancing todo {} from {} to {}", + todo_id, todo_item.due_at, next_due + ); + + // 更新 Todo 的 due_at 并重置打卡状态 + let mut active: todo::ActiveModel = todo_item.into(); + active.due_at = Set(next_due); + active.is_checked_in = Set(false); // 重置打卡状态,开启下一周期的打卡 + active.updated_at = Set(chrono::Utc::now().fixed_offset()); + active.update(&self.db).await?; + + // 为下一周期生成通知 + self.generate_notifications(TargetType::Todo, todo_id) + .await?; + + Ok(()) + } + + /// 推进 ReminderTask 到下一周期 + async fn advance_reminder_task_recurrence( + &self, + task_id: Uuid, + ) -> Result<(), Box> { + let task = reminder_task::Entity::find_by_id(task_id) + .one(&self.db) + .await?; + + let task = match task { + Some(t) => t, + None => return Ok(()), // Task 已被删除 + }; + + // 检查是否有循环规则 + let rule_id = match task.recurrence_rule_id { + Some(id) => id, + None => return Ok(()), // 非周期性任务 + }; + + let rule = recurrence_rule::Entity::find_by_id(rule_id) + .one(&self.db) + .await?; + + let rule = match rule { + Some(r) => r, + None => return Ok(()), // 规则已被删除 + }; + + // 计算下一个 due_at + let next_due = match calculate_next_due(&rule, task.due_at) { + Some(d) => d, + None => { + warn!("Failed to calculate next due for reminder_task {}", task_id); + return Ok(()); + } + }; + + info!( + "Advancing reminder_task {} from {} to {}", + task_id, task.due_at, next_due + ); + + // 更新 ReminderTask 的 due_at + let mut active: reminder_task::ActiveModel = task.into(); + active.due_at = Set(next_due); + active.updated_at = Set(chrono::Utc::now().fixed_offset()); + active.update(&self.db).await?; + + // 为下一周期生成通知 + self.generate_notifications(TargetType::ReminderTask, task_id) + .await?; + + Ok(()) + } + + /// 获取通知内容 + async fn get_notification_content( + &self, + notif: ¬ification::Model, + ) -> Result<(String, String), Box> { + match notif.target_type { + TargetType::Todo => { + let todo = todo::Entity::find_by_id(notif.target_id) + .one(&self.db) + .await? + .ok_or("Todo not found")?; + Ok((todo.title, todo.description.unwrap_or_default())) + } + TargetType::ReminderTask => { + let task = reminder_task::Entity::find_by_id(notif.target_id) + .one(&self.db) + .await? + .ok_or("ReminderTask not found")?; + Ok((task.title, task.description.unwrap_or_default())) + } + } + } + + /// 发送 Bark 通知 + async fn send_bark_notification( + &self, + bark_url: &str, + title: &str, + subtitle: Option<&str>, + body: Option<&str>, + level: Option<&str>, + icon: Option<&str>, + is_markdown: bool, + ) -> Result<(), Box> { + // 构建 Bark 推送参数 + let mut payload = json!({ + "title": title, + "group": "notify", + }); + + // 添加可选参数 + if let Some(sub) = subtitle { + payload["subtitle"] = json!(sub); + } + + // 如果是 markdown 格式,使用 markdown 字段;否则使用 body 字段 + if is_markdown { + if let Some(content) = body { + payload["markdown"] = json!(content); + } + } else if let Some(content) = body { + payload["body"] = json!(content); + } + + // 添加推送级别 + if let Some(lvl) = level { + payload["level"] = json!(lvl); + } + + // 添加图标 + if let Some(ic) = icon { + payload["icon"] = json!(ic); + } + + let response = self + .bark_client + .post(bark_url) + .header("Content-Type", "application/json") + .json(&payload) + .timeout(Duration::from_secs(10)) + .send() + .await?; + + if !response.status().is_success() { + let status = response.status(); + let text = response.text().await.unwrap_or_default(); + return Err(format!("Bark API error: {} - {}", status, text).into()); + } + + info!("Bark notification sent successfully"); + Ok(()) + } + + /// 取消通知 + async fn cancel_notification( + &self, + notification_id: Uuid, + ) -> Result<(), Box> { + let notif = notification::Entity::find_by_id(notification_id) + .one(&self.db) + .await?; + + if let Some(notif) = notif { + if notif.status == NotificationStatus::Pending { + let mut active: notification::ActiveModel = notif.into(); + active.status = Set(NotificationStatus::Failed); + active.updated_at = Set(chrono::Utc::now().fixed_offset()); + active.update(&self.db).await?; + } + } + + Ok(()) + } + + /// 为目标生成通知 + /// 当创建或更新 Todo/ReminderTask 时调用 + pub async fn generate_notifications( + &self, + target_type: TargetType, + target_id: Uuid, + ) -> Result<(), Box> { + // 删除旧的 pending 通知 + notification::Entity::delete_many() + .filter(notification::Column::TargetType.eq(target_type.clone())) + .filter(notification::Column::TargetId.eq(target_id)) + .filter(notification::Column::Status.eq(NotificationStatus::Pending)) + .exec(&self.db) + .await?; + + // 获取目标的截止时间和接收者 + let (due_at, recipient_ids) = match target_type { + TargetType::Todo => { + let todo = todo::Entity::find_by_id(target_id) + .one(&self.db) + .await? + .ok_or("Todo not found")?; + (todo.due_at, vec![todo.owner_id]) + } + TargetType::ReminderTask => { + let task = reminder_task::Entity::find_by_id(target_id) + .one(&self.db) + .await? + .ok_or("ReminderTask not found")?; + + // 获取所有接收者 + use crate::entity::reminder_task_recipient; + let recipients = reminder_task_recipient::Entity::find() + .filter(reminder_task_recipient::Column::TaskId.eq(target_id)) + .all(&self.db) + .await?; + + let recipient_ids: Vec = recipients.into_iter().map(|r| r.user_id).collect(); + (task.due_at, recipient_ids) + } + }; + + // 获取提醒偏移配置 + let db_offsets = reminder_offset::Entity::find() + .filter(reminder_offset::Column::TargetType.eq(target_type.clone())) + .filter(reminder_offset::Column::TargetId.eq(target_id)) + .all(&self.db) + .await?; + + // 如果没有配置偏移,使用默认偏移 + // (offset_minutes, channel_inapp, channel_bark, offset_id) + let offsets: Vec<(i32, bool, bool, Option)> = if db_offsets.is_empty() { + let default = DefaultOffset::default(); + vec![( + default.offset_minutes, + default.channel_inapp, + default.channel_bark, + None, // No offset_id for default offset + )] + } else { + db_offsets + .iter() + .map(|o| { + ( + o.offset_minutes, + o.channel_inapp, + o.channel_bark, + Some(o.id), + ) + }) + .collect() + }; + + let now = chrono::Utc::now().fixed_offset(); + + // 为每个接收者和每个偏移生成通知 + for recipient_id in &recipient_ids { + // 获取接收者的通知偏好 + let user = user::Entity::find_by_id(*recipient_id) + .one(&self.db) + .await?; + + let (inapp_enabled, bark_enabled, has_bark_url) = match &user { + Some(u) => (u.inapp_enabled, u.bark_enabled, u.bark_url.is_some()), + None => continue, // 用户不存在,跳过 + }; + + for (offset_minutes, channel_inapp, channel_bark, offset_id) in &offsets { + let trigger_at = due_at - chrono::Duration::minutes(*offset_minutes as i64); + + // 跳过已过期的通知 + if trigger_at <= now { + continue; + } + + // 生成 inapp 通知(检查用户是否启用) + if *channel_inapp && inapp_enabled { + let notif = notification::ActiveModel { + id: Set(Uuid::new_v4()), + recipient_id: Set(*recipient_id), + target_type: Set(target_type.clone()), + target_id: Set(target_id), + trigger_at: Set(trigger_at), + channel: Set(ChannelType::Inapp), + status: Set(NotificationStatus::Pending), + locked_at: Set(None), + sent_at: Set(None), + read_at: Set(None), + created_at: Set(now), + updated_at: Set(now), + offset_id: Set(*offset_id), + }; + + if let Ok(created) = notif.insert(&self.db).await { + // 添加到时间轮 + let task = TimerTask { + id: Uuid::new_v4(), + notification_id: created.id, + trigger_at: trigger_at.timestamp(), + }; + self.time_wheel.add_task(task).await; + } + } + + // 生成 bark 通知(检查用户是否启用且有 URL) + if *channel_bark && bark_enabled && has_bark_url { + let notif = notification::ActiveModel { + id: Set(Uuid::new_v4()), + recipient_id: Set(*recipient_id), + target_type: Set(target_type.clone()), + target_id: Set(target_id), + trigger_at: Set(trigger_at), + channel: Set(ChannelType::Bark), + status: Set(NotificationStatus::Pending), + locked_at: Set(None), + sent_at: Set(None), + read_at: Set(None), + created_at: Set(now), + updated_at: Set(now), + offset_id: Set(*offset_id), + }; + + if let Ok(created) = notif.insert(&self.db).await { + // 添加到时间轮 + let task = TimerTask { + id: Uuid::new_v4(), + notification_id: created.id, + trigger_at: trigger_at.timestamp(), + }; + self.time_wheel.add_task(task).await; + } + } + } + } + + info!( + "Generated notifications for {:?}/{}", + target_type, target_id + ); + Ok(()) + } +} + +/// Worker 命令 +#[derive(Debug)] +pub enum WorkerCommand { + /// 调度一个通知 + ScheduleNotification { + notification_id: Uuid, + trigger_at: i64, + }, + /// 取消一个通知 + CancelNotification { notification_id: Uuid }, + /// 为目标生成通知 + GenerateNotifications { + target_type: TargetType, + target_id: Uuid, + }, +} diff --git a/backend_rust/uploads/avatars/6718b99c-eb5f-49dd-ae71-2ac763000eed.png b/backend_rust/uploads/avatars/6718b99c-eb5f-49dd-ae71-2ac763000eed.png new file mode 100644 index 0000000000000000000000000000000000000000..d5fcb5d4f6993ce5037d5f27234ed500d82d5b71 GIT binary patch literal 13476 zcmZ{L1z1$w+BP!`I`jY%l0!>3(hS|*Fmy;Wgmfw)pdgKONC=V=LkSM0fOJWRfOIOU z{KNac-#O?1uJg^zzV_N{J*;7C43x891IK$d=+JRT?`BW_uU1)hmPTe z-O8aKY@Oufv|Z$sYGIW9UZ~L*uXI zkWNVD2JPa`YU`ra7=hPy!?x@DFQTa=#ytcQh*{pzNFdEO>6;G`o#EzQk5_f`qbP%S zz9ccQX7&ssx_B|ujOC(rQ~VMR{KyxzUNt^vwJKzcM80TDBR~i%vAXonO-k_cgW!!V ztCSIwN^jm9M$G70Awe&fv`3nZzB;e|+Lquwdek$|^jZwm%0(}g5cz{v?-UcU9Sp!M zvmtq#PGE-ab&#!!oHial2EZM2{Sf`^Z*Q#PprL`mjt+w{umHpunCK7y{lx%KVqpCh z#=uYoQ2i^c3wZR83=jh&#t8%TkBlk$e)oBezR=qL+<^ta|B(Q27hwK7d?z~L_Xdo< zVI!1HyfHAynC~utiZ0U;1_m(CN#EGVSVLXR*29h4+Rnqqp4;CIai@hL;V*^`y4m|! zL;c-c-Mz*9B^myb5JQLWqInpge~I|GNHQ2}XhY>ZyzHUE+`Qbp3{p5yC{)7B&OuC9 zUhyAw^phlmqmK_ljEBe1&yU+rfZN0C2@jvBs3;FFKMy}Y7g~bLJHXw?+MmnaoAK{V z{wE)Kdv9AWCxnlahdcBxUuzqWr#_Ml40i?n>+^R#eViQrU6Q-^Kg~inkmoLihmV_= z=fA1h`#b#~YIiArtNqokzl)Q&%S_Bz*WTO1_32%;r1%5{CH^Yn|BL;11^?DC`L7!P z8T-FAw7i_`(Vg;l8UK{#lub+D`uVt|szMZuaip zcdZfNMJxW-l>e$}{C{fxN6J5%51gjxc51loUi8$ zdrf!Ur<~&(oE0Y1Tf=<<6y_&Ga39e#46w*2gY8m;1|?6!HXV+m7Zw)04{|n^uEp~c zHy6Ae@&Y!+1*9D|hu+9VVuD~8ptYCbF9gC?tK~rK!oqf?C1Z|Bvc=4?t4wT47xDzR zU+u}gl#e{5wt_HY?I>?G8QCs)^3|lsgR2SH%q}fBme)Ijxi53%E1@k$I2BvV_|z2< z-IHo^6`lCO9x<+ERaI3Djb|%4n*}sCgdkZk3@K~H^zEIo!GwsI!sl+ROO_ADFVntL zeO=>^7JW&?#NW{+m#mL!jEeGzhuU)BZahhT*|@J8tgrjJBkfRy=K`B9hZ$qlE4`WH zU5CM9jzABOnh7W_62hsk-y@m*W#RPUUYy(y=?I8&7QggkG zu9}U5!$qsMNONb8NzGn!R@S24F7|fx4J!@X4OK>fi`TI1x7tlw zUaxI5{YuX$BTq1<{AyZHcQX2e3%j>5=>p@gaJBBEZ(e z??f>o-FX!8IJ3EMP;hTn4g+ORIFngrsIULFlwAE0iIkX`s%I%gkinstca#Uf=F|r9 zdMEg!B|Qs&qokz7?YE*VMn5(TNv%Z7CsWs2{ceb-S3x32Gwi2~QJK4QHnC8Qz2@B=(dnd=Sg?DB=u+oa_-Ey!bMMmz7G0?HorZlQlI#I+cW1vWh z5_N-R$+#== z$0&eS>x67R^MQI$B!;y9yYEY9nBwO`lIFth3!#a~G=w`0>H28AxuQ}a`r`h`##19{ z`_oHdCP2y(tM=e7>R^F%wgcf{y()FsMrBDWixf;ZQqcI?B{+aYZJnBrIGhS*MZddX z)_y|Kw^-HSA>$+y5%?1va}NmN#)nLi$mUZcNC~lPx@C3nVI-rGrn3WbpM&t~ZvFg; z9|i&BkTzE3zBZpF8@}V@Id#4v7r=}E^q`0Z8$eb|(;oLTm%+1YZ{v|P!Tg9T1?9ye3|l&@q?wh4PN>Of>r+5>iSZrd+}2p?T%-l6dfXJ zP$1Dt@B3S8qPA)z)WyCg8#&4f02}2y1}bsqXA^nvx&UAa-N#ciJ#^T`9z4 z?z=r!=b&!l64v`dM1@O#&Gw{GXF032uToD}Ps(Ae_4RYPhAZpn`BH4e{XA~{r|MHe z23zW8%JpE!NFtozry6>(TBEYn1_BJAZZ62EYIvI^2fm7T8JS(xxU)#aOBp2Ufy1TJ z{C&a^jaz2v-z`~yRm)D^mxwPGK=?WON4uc2?4|c@mC<)T+w{gH20^3kVDJ?GL&=%s zP{(@|ru!-@SVS{C$Z|@SLi*9bpvi&%8m%S-HDw}Fks-v%x(^_Ge;9~tUolN4wgLpX zK9Sp%IQuBZju9Czs_*6^qIeY&f1&W>SY?j;^}YzSiwEIB3|KEJN)q+xi)d?V&79OC zo%(p-vXkYW5_($+;WFEO_|gShuv1=9SutGETKUOtAxn9Vb|xz;HwHWDzE~p(V0@b7TiMzXesRI*l%zujt-*?`*^8Di!wYynYAlZoIwtqY52M`qP2 zd8r1Xm9WnS^V)9m*%}%6%#nzycTQ^_565ltCRDxOuqNLJco45N70$hI=2-F>%`x)i zfx9?oSCdE`2Mznm;s<>tWt4pm(+5R5b5wklC!On#STjXlNLHEoGv6%IZsN|sy1YU9 z**b3h-U)9IkKBXU3>v?ld(rLovz){gKrw)YAf`p5)cgRqA0_^xiEXqqHh>_*>NT(}qSqDnsU=CTBHw2Q(a3uM2qGoi`9sBi>&|nt*wgGEHO5`LItUV1K6C^~FmHU1k{0v~?20BH9jzC6gSzYpR+< z>QPgZBn=HsUSq``Yue*&hhu<6lwgf+Q+>IrV@ZWJ2Ik$a47c%`_BeM*NChY&P{(4} zt4*)Dqu=1q*so8zSF}yyjJ1U56-Ej$HEodHJWnzo#qs#@Z^KYmQ%65VD>@LYD+E|k zRVnPz%-TdiYBVF5b`iWLsoCT4Pbw9NNvDhVdy*b*z2UcwBsa-wX0`N-kY{AF4SVcj zvsma?Sli8_F{A|G8{lY`=%S&qF0pzl`YZJlH_UxL;|IXaRrvz6t5!)Vl>doF`koLT`YdQzugs z1C;dor=R-Q%%1uC%ncFG)jJi_+7g(4p%8fy{R(~2`HUKc#n8zIFe;7p$PpGlXQfen z#~CO>{3d*1YH2!jwgF{5@3Y0P&@5v=lAjzXejDNV4e(gg^o!o8Y4tIKHG7xmmyL`4@MNH&Gl7drIFoCUW9C)wbXWYw|0X=2eoeF~fj9zR59Ay2O}+W|USO8@0CvOigqC`*g4GjjM2!nl zivX#rRKbUPXXL?D)=X)JyY7LFnQN;nlDRLYIRnps8dYV%B$U7}c2gaCvh;^gY25tP zcFeuDdB$qMV_TMjK+BB$AhM35p|wo6PvMfFRKvGAibLM!eAqK)Q&SIb+B( z;>&_LPXm`FmGB%0+vYnLT{ zC{~uD7)XTuA|Uv>?uE=1@=LeIsgi-4?L)1B+xcMi9$q!rd0Sin7UOoBfy>ucBH^2l z9YN0-n*0tAH2LP$=3QL;C8t_fvMOHXzb_sUb}ML_o~}L4{dh6Ynpap~lfUCrugpUX zaLO@G%{QlIQ$_|&2f|Thsl-ajB%dinUQdR#2)1hxN5I0*zt2%W_|RA>{WjzFn8D4< zsjxh-|9HEzQOz!r!vhc;s7?g!Cqno#$$FMiAT(P?0yQ zHfXtWo1QG@cS0c)$oc#7c7Tn8wf~Dj0 z^7z@?_91;7FdmQyOf9huY5i4dX411%xUMC|EUO&SycDu-5O`b@HvI&WZo>rS5x72S zWLrf$SpjQowOqqQqD?_s) zGHxNZk)SVh9%ZMs_`o}Lq<2zA%;_f%w5ntAt#X<6Aee!964U*hs zoYo%8lI(s&kG_H7UK_WgRhaPVNuc20tBO;#Pn|Pimt?@kOhoveq zaO3*zud4o4Za|5Us`u4tk>x6}lvtZIBa#rPNkkEJ1iJdd)!D?{@`U!bn9J9Ck~+ds zkhKW$HD7v}wM1kBUJu3x{vy^+`Kd7R#8FE=#PQeQCdKUDYR{O8d0c=NE(de_rG=o-s7qRY~v1a>8|OG99tS8AN5!V>VJI^E}2;L$w2V zHoSW~=1kHzL<0HksFjcUDf9DJtg(+Xt%!|?lGW@HkPiLc7Fb}JIOr_;+5WV z8T=ha(|lA~Rzd!Gww>J|3`S>E`vxisZrtF*s@Gqk3!f#6MJK zN8!=qfPIKW9(9I0gzUbo&Nuxd{8(6bqXLJk$*M;Tz-E`9TULuQu&@}W8+-km8~EXX z*ydTd6p%dlRyCwgjH~0%0M5R!^{8M(JptDa4bOW7eztxvldsr5KCV%_ik0bhk`Hg0-?F#3O$?-r!m(xFBJ~vpSF`=!Vm&%Qx!9{{jr;CKCC>)Io?^4sFbt{534HfZpPi zoyH?*&xpA@wWvd^pf~p-{Go`P%|rf!rJu}I)&4xR^?9RG(GzbiP`z3t^l)r*eB#I6 zG3wk5jn!&f$74H$K3UJX)IfnQx;(3ExjXdm^c99=axisS0J1!fdkv8R6VX7&dKf(3 zI*PkgIcSZYl<5+SC{CEV-X3{s;PPJld1fHEmIbSkt;dvbm2#(yQTAZ<0cb6ui;0$d zM?$#jHqzA0^zX5YzOYYBBan<7gsvlSg^9305^`2U7th&*xHPh*(JS$EjalVaT09fR zF1oQF$2_`(t7}S$lJ2Hzz&co$B#@1bO)&J?(zk@fVoCbjn%*Jn8;kRi~?s+?;=^1As|<1msY_ezga|8dh^V*bK`y6O>FR$YH@3nTC&H> z0KFFg;1bj8K}(GBfM6Y?;t6V36DXuYXAz|!d-7d^0HTZsZ0R0bSACg`_eJ4_VhA%_ z{)cB2FUl#HWOQ+TE`c_KoL#7~vfA3&;AK0`6lsW5*UCG&>jVQQGZEI@O#sp@rndma+Q#Lg}NQ0wTN&<;7;*F=OC(FC5}y` z&su|HrCg<0q#7i0kU>%YlpBzn&!*SX^S%B4K&xJ**cK8LIf!WwBtIioo{$)645MS& z`kDS3%cUNz6$GbXH-)`;M9ca*Ijd+&4v(~Pt-(P(l7N&sI&S(ALwW`_1wgJch>MHM zbpKN(7BJUz>f1U>dkiQme=HKw)MQvi+qAb_Hbwi=1PYani6dKM!~y%~h99^&nwp*A z6wX`*eCr(k5)ey#?8Wx!u^MPj=rDid^qT83LYWf#GeOl3H9K*n8lzdJS3WE#sLhv^ zpP#>BK2jzHBp+k=^w(YGnWRDI!Vyh|zYWAfFd_$k>SoaVQ;VO#2uxG#hUVR6hk!1O z-$B7nUw!x~NDAc?Vhe5fQv>)n>Z+#htx~?A&Decfj_!;=cw}MGE*9{9195F}e^g=7 zG(){3r(O9Y<%32?tT9+u3TQ9$u6?))T+io6?*+kiwL8e+&j_If@m&^FO(wLjBNp?0 z(h}{(pHb>B441{xNOMxj$wQ7PB1-$Leoya^2Et7}O$AuwqT-?OyB;*co~4IpuAj&| z#hf8|_0QyEyRbt*S#Cc(?N{CveFWA!ze87S6N&e()6#8R68)NN_d&tz#XVpi_SXtlX&#rj z;ET8YJe&a78s&2Ix=>$R#5BgGmIyOK`_&Fvb@H>lDtRwIJYOPGki^}RL?iW744Yme zsrma;*@J`8asuEYs#iMT4agdq*)GIL@5Np6c84pwakIoRFxD`L*C7HHBmAX0vk=wW zs$gLL_=p^|mef`HGk^SJN1z`APu9IvuQqP#at+BSHPs>S7IRr;X%-+|`?;_BakoM= zBC2?8Pv}79e6*An#jIS=zu_7H75a+yZ_X18<6F9wAnCG>3eI0f0;~{j2jf*;K-2>Y?r6qnmpq2rm5*)Z zucVvGPGKHutQxybe;JNb(@VhB`eAB58ffj)Q$^FOt=>63vCGMcM)az8`nPc@)Pd*; zOeVJc!#6_3N6m0_wCJY|h4?aAe|vtMLQu%)KS^V+9{?-?^;C!CvvB;0EaS-ug2;pr!T^&+dtG9pf*gzP!_@cM{$TEm%as3Vl-M z^P6;eiraMHR?i`fs%S%B=2wcQVAezpqH)so(N;@jU+6f>4N{xiA%Ph1x zd*?6JU6FFzL!sY`{+3p-`s8s?bR8BFZ@%fYG~(X$RTwXu5$%M@GsE>x>j&y6rr-c* z11*qnIYeC2XUi?{WmMhCSAmgI#zax>AsV<>4hD}up|i#VY$ay6YYh3&XV#+H7$~&h zRhP%%1?lfUOX3aR#7DLJx*~k`HJWMtA9k8ELMAiI$+i?Y;1ci$VQV+?_LGgljH{8* z+K%ew)yaOrl>v+2DOR^x28Bd zD^48UvrSy7a^4G}6%1VPZ@q}4Gd&{K*@G4|C^)G%H0c%Lm!lps(VeG=d{U>5#ENhP zzpt!#dpTn%XF&Jr(f1Wr@c~ zAc^d5u~HIAY^%D_ugET6^Oz5y2_m7456tw zGrl1`6*X0-UX>mIpb}K?<@MuJfBrK^6D&s;iCsz?6Qu8fi*C(Mo4}wmhdPDU10d#o z{L=fhL2)=ZO025s#B6kpxs*0+TIo@ht_#TD?L23>>ADI`klHulJ5G|n7QC`N&(yQ^ zo&|7#Qhguc*AW}_)^WnwPpZ2PqHfAGbJsBdH4yDXI&RZn#H1P=$8l;$#jFvnn)^Qj zrg~~JBVF!b=+MrgrN`D9cK2jttoA;U{7}hzK_~4=Y~`OCK8{yHQ->r^LA;AJb6N%l zV?Rn~fWgf{EaY0ntd?+@OGc$_er)k9>_=7FSwxlJGNURR3aoezeZ95bCqSjZJ+dpa zBwD5Y`L|T>i~cniUB_0|GU+|xq3I{BQfeJWK_XwAu{$XTPgo-Z&=WqzL(O*878xJN#W!7 zN{CvMYi|a$MD81UxJWD>o^#>)5zf3P>f(^L^weuh553)S**+89N}k&WDBVE7pqB!r z2}9FORaK1{@2^)%sLk}|&nh6Z%#DQdNY`YTswz&d&yMR=7`cV45={`$bCKT(@M)2y zE2y@kLa#wTv;ilB|Fb#DrNzBtq^#T;2TU*l($&@5{`{tY4Ew`J&1I2w*8v)-g$8{2 zBRoK(-}lDWjxla{FO~_%%%m`n!*Q#2^M~3DBxv&4n3EE9uNiy3?A__R?%~6=7hyn7 z)A%%&AH%V}tGWdD=H}g%RWw9X#LfMss@i(i7V@srSIft2RjLq1xQg*=s@q3S6HZ5v z;6CVZyzt9wFQbBv-_g8}Z9dw?BQ(%Jy_<&lI*)%Rw`&NAH&MVuIOT9jX(?7`YRbTt z)An|1pXJvtLjwfdz^_BYg?l**D`DM=)bdfb{YhDHx=`)B#{!_529lNEO`(Miur_O9 z$9aj#WM;LS7#hEUHi4Rs^J}q!TctzW-Wm@Y z+M5A`GYTAp1z`SEZqljfxnG4i7|`Y!)tg>csO^CZ`Nz7nh1e$Ai*|3o$WS|(|~ zDri8|#2bY`w9FN_TzD_q@f|gi0nWZ!bhD>$!?Z^9GyzTJf_?;-H&xEnnpw25z}dT} z+I)xB4;-S*-YB)A6*tjfY2{q?W#*L)lE0sH=TAjeADuV znf=v6W~^^!3unVqv>5~bknp#smy-w*&^Q|2{E-nZvtYPL#sI zR>pm?ab36XB>LKT1%U3M#{l<~PfgoIW4fUM@A^rSHfYx1z6wDppj*$l{A2j>JE7pa+^HT z+SeL_<5*C#`vz6R9_of>$GoaqB=-cOYfTMs530Zq)MI`pzt><1{P8GJ7G{Fbg!1pTI zS~0?_KIfOexCe}tg*6IIZMNspjo@YgMVO^`X_NbSItWPqM8M!AE5c;1E}pNkwWJL8 z;>`@)PCMGe*ZV^j`=6pmBK{xpKvnN-f>J!ks;-a!Q%R2~#!WFz9PDQ>6!B}^0E3&2 zH9uERtq$*pq1Bpc2)rxCey_bAwoqP zy~%$N2sa#CW|u=ht@zKBED`-HT!>Kfm97mJp1H&!A^& zw38`UB2sG>$OqR#LK;Tk{0o|V7WvRMq|~RU9we`wJ)I=pPwWudq6E(dvNlhHpV29Kp$(XC_yg{3IkvD&vUF5b7e#lai^^gWc96w$hL=@GZ%4%MylZe+XtTqTF(t0 z(q?hRAaipFGjt4iov8>cxa;T_Lg1JY-x;Bg%Y~Dai%JG~3nSI+mEF1vg{9WHn3wcB zJ;P!3Xe1cch*jgRL88{el!IWSaBl9~F`w63%bO9X>ym*(URVN89!}kBZ}NV}orf*d zPkJw|{Jq=9B-VY8%^+;HaqE+pYF{+Ev@O@!hDX}d_?Y%Ad}F!cpV;9B6E{yo=Pn-H z#v~52_!VpJEu8k7np6y*53u&^Mdm(0m0(oxIF#WWbMhE+HIh7XeI8B)^4D=v$WGnz zU$w2${4VVjtyKZ5^g9$WO#Z&ej0lIVLA0NmPVL(faT$22OswNnm7)o3Xw-V$UUbJ9khrYso_YI#8_WHUH6VDxv)NlQ@iaUVAA(2?Po-`?9x^)OhuZgvxiTV8C5~?=ymO-*c_=}4SF;sTV1;{y z7|z(vBcd}TLHV2i;a-uPN8MjQX+aP<7`TRwkUqk=6R&jfJY)=seBy36N{7@A&9t)1 zJ8R9)pt(h!+8%Ssn-; z7v|0;0Y7J%Y4+@tIJ46qo(hk|3_PVmL$IU+1glqPKKd$vrVHZk%9 zv2D=NY>fCRRte4a~Toru0k!ZW1$Dh{3hQ}ab=C-o6#sOPomLBuOC9gb1T8w}iO-%$FirSRF? zqsjJm$uC2)N=x)`u}8jh<>l`lW}+cxrq#B*Pqc5x1l4=l02YZqu~gO%$XX81OR%tl z)&;5Zys1$3a!Kt5=DB>6(idJQm!YW5Q#WNGONUlLI^KFU4`v(P8xv#X$n`;AMht|T zYOpf+bQ93cUq798E?zQ10lSIoHjjvW+EKpwV{UV8k|=UC1nHtb=I zEzJ(%L|KyqaLJspTC5l!T<)(v%~Dlr&B=h~Aj(VpTGZEKP$|t{XY^#_agfZ@lQo%* zF)OpT5MZ|yP?pjx+AxuzpNtg_G;`z}`%uqiS4*DG!sD>tn%SI8YilOU>zW&$YKF|X zi5z3kgB=ctDVp=q!&{Yi>KF}YgwifcRuTg~PqeV6SM_tgH>U5BtW*pwKe2&*RzQ22 zlM87LHupWx&|Kl|Cl$Rm3JMC&fuADzSBpQCJa}SbT}k%-HzhXr1JeJ4OY1K6>GE{z zdKe9U8;3qV;iTVqL!p`Vgo5uLA!Z4L)3EP!geQ0ff3qr&cnwYH182nGmhsF;l`l#0 z2{DI|X>H}BiZl2na`)o(EoYunJfo5$?8Z%ZP-R$;lG@N5re+Bu>Y*kz{0)h{NflEuvM0Jn3k5-$SN~UA{-B? z2631&+kHzZ)i}QH8C%d!+yqKx1v#V;b7m)fVBFTrQwWQRy2%ZvUv|1XUV>D7{=Bx& z9m%nzI`pjb4kE%k9@GuhHRbJWH7uv|-JSNJ%0B7pMUa8P4XD=H z2&O;8IpliuIt9^t$9|&~;$Fw#o@i`x8TF~2yrQR9`fzJ&4C3#2O-MHH z@XHogrOx1WT~sr{m3ner6Z(iz6{o)WM3PhINLEI44>NMmdgVA7-rE;F)F4R7rRyjd z--}3(p JWT +- `POST /api/invites` 创建邀请码 +- `GET /api/invites` 获取邀请码列表 +- `POST /api/invites/:id/revoke` + +### Todo +- `GET /api/todos` +- `POST /api/todos` +- `GET /api/todos/:id` +- `PUT /api/todos/:id` +- `DELETE /api/todos/:id` + +### 多用户提醒任务 +- `GET /api/reminder-tasks` +- `POST /api/reminder-tasks` +- `GET /api/reminder-tasks/:id` +- `PUT /api/reminder-tasks/:id` +- `DELETE /api/reminder-tasks/:id` + +### 用户与设置 +- `GET /api/users?query=` +- `GET /api/me` +- `PUT /api/me/settings` { timezone, barkUrl, inappEnabled, barkEnabled } + +### 通知中心 +- `GET /api/notifications?status=unread` +- `POST /api/notifications/:id/read` + +## Bark 推送设计 +### 调用形式 +- 采用 Bark 官方接口: + - GET: `https://bark.server/push/{title}/{body}?group=notify&icon=...` + - POST: JSON body `{ title, body, group, icon, url, badge, sound }` +### 发送内容 +- `title`: 任务标题 +- `body`: 触发时间 + 备注 +- `group`: `notify` +- 额外参数:`url` 指向站内通知详情 + +## 调度方案(可靠与幂等) +### 核心思想 +- 以 **通知表** 为唯一投递来源(幂等)。 +- 调度器只负责生成通知实例;投递 worker 只发送 `pending` 通知。 + +### 通知状态机 +- `pending` -> `queued` -> `sent` 或 `failed` + +### 触发流程 +1. 用户创建/更新任务 -> 生成 `next_due_at` +2. 生成通知实例:`trigger_at = due_at - offset` +3. Worker 扫描 `trigger_at <= now` 且 `status = pending`,锁定并投递 +4. 成功则更新 `sent`,失败则记录 `failed` 并按策略重试 + +## 关键页面与交互 +1. **登录/注册(邀请码)** + - 注册页要求邀请码与密码确认 +2. **Todo 列表** + - 列表 + 新增/编辑弹窗 + - 支持单次/周期选择、提前提醒策略 +3. **提醒任务** + - 支持多接收者选择、搜索用户 +4. **用户列表** + - 全部用户可见,支持搜索 +5. **个人设置** + - Bark 链接、站内通知偏好 +6. **通知中心** + - 未读/历史提醒,点击标记已读 + +## 工程结构与实现要点 +- `backend/`: REST API、调度 worker、Bark 集成 +- `frontend/`: Next.js UI,响应式布局 +- `docker-compose.yml`: 数据库与服务 + +## 示例伪代码 +### 计算下一次触发 +``` +function nextDueAt(dueAt, rule) { + switch (rule.type) { + case "monthly": + return addMonthWithClamp(dueAt, rule.interval); + case "yearly": + return addYearWithClamp(dueAt, rule.interval); + // hourly/daily/weekly... + } +} +``` diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..72b9ace --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,8 @@ +node_modules +.next +.env +.env.* +*.log +.git +.gitignore +README.md diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..d0d878e --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1 @@ +.next \ No newline at end of file diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..1edcf5b --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,45 @@ +# Build stage +FROM node:20-alpine AS builder + +WORKDIR /app + +# 构建参数(NEXT_PUBLIC_* 变量需要在构建时注入) +ARG NEXT_PUBLIC_API_URL +ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL} + +# Copy package files +COPY package*.json ./ + +# Install dependencies +RUN npm ci + +# Copy source code +COPY . . + +# Build Next.js app +RUN npm run build + +# Production stage +FROM node:20-alpine AS runner + +WORKDIR /app + +ENV NODE_ENV=production + +# Create non-root user +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs + +# Copy necessary files from builder +COPY --from=builder /app/public ./public +COPY --from=builder /app/.next/standalone ./ +COPY --from=builder /app/.next/static ./.next/static + +USER nextjs + +EXPOSE 3000 + +ENV PORT=3000 +ENV HOSTNAME="0.0.0.0" + +CMD ["node", "server.js"] diff --git a/frontend/components.json b/frontend/components.json new file mode 100644 index 0000000..f826c54 --- /dev/null +++ b/frontend/components.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "tailwind.config.ts", + "css": "src/app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "registries": {} +} diff --git a/frontend/next-env.d.ts b/frontend/next-env.d.ts new file mode 100644 index 0000000..40c3d68 --- /dev/null +++ b/frontend/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information. diff --git a/frontend/next.config.js b/frontend/next.config.js new file mode 100644 index 0000000..7a8c8da --- /dev/null +++ b/frontend/next.config.js @@ -0,0 +1,7 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + reactStrictMode: true, + output: 'standalone' +}; + +module.exports = nextConfig; diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..672bc68 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,2917 @@ +{ + "name": "notify-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "notify-frontend", + "version": "0.1.0", + "dependencies": { + "@hookform/resolvers": "^5.2.2", + "@radix-ui/react-alert-dialog": "^1.1.15", + "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-label": "^2.1.8", + "@radix-ui/react-popover": "^1.1.15", + "@radix-ui/react-scroll-area": "^1.2.10", + "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-separator": "^1.1.8", + "@radix-ui/react-slot": "^1.2.4", + "@tailwindcss/postcss": "^4.1.18", + "autoprefixer": "^10.4.23", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "date-fns": "^4.1.0", + "lucide-react": "^0.562.0", + "next": "^14.2.5", + "next-themes": "^0.4.6", + "postcss": "^8.5.6", + "react": "^18.3.1", + "react-day-picker": "^9.13.0", + "react-dom": "^18.3.1", + "react-hook-form": "^7.71.1", + "sonner": "^2.0.7", + "tailwind-merge": "^3.4.0", + "tailwindcss": "^4.1.18", + "tailwindcss-animate": "^1.0.7", + "zod": "^4.3.6" + }, + "devDependencies": { + "@types/node": "^20.14.2", + "@types/react": "^18.3.3", + "typescript": "^5.5.2" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmmirror.com/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@date-fns/tz": { + "version": "1.4.1", + "resolved": "https://registry.npmmirror.com/@date-fns/tz/-/tz-1.4.1.tgz", + "integrity": "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==", + "license": "MIT" + }, + "node_modules/@floating-ui/core": { + "version": "1.7.3", + "resolved": "https://registry.npmmirror.com/@floating-ui/core/-/core-1.7.3.tgz", + "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.4", + "resolved": "https://registry.npmmirror.com/@floating-ui/dom/-/dom-1.7.4.tgz", + "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.3", + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.6", + "resolved": "https://registry.npmmirror.com/@floating-ui/react-dom/-/react-dom-2.1.6.tgz", + "integrity": "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.4" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.10", + "resolved": "https://registry.npmmirror.com/@floating-ui/utils/-/utils-0.2.10.tgz", + "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", + "license": "MIT" + }, + "node_modules/@hookform/resolvers": { + "version": "5.2.2", + "resolved": "https://registry.npmmirror.com/@hookform/resolvers/-/resolvers-5.2.2.tgz", + "integrity": "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==", + "license": "MIT", + "dependencies": { + "@standard-schema/utils": "^0.3.0" + }, + "peerDependencies": { + "react-hook-form": "^7.55.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmmirror.com/@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.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@next/env": { + "version": "14.2.35", + "resolved": "https://registry.npmmirror.com/@next/env/-/env-14.2.35.tgz", + "integrity": "sha512-DuhvCtj4t9Gwrx80dmz2F4t/zKQ4ktN8WrMwOuVzkJfBilwAwGr6v16M5eI8yCuZ63H9TTuEU09Iu2HqkzFPVQ==", + "license": "MIT" + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "14.2.33", + "resolved": "https://registry.npmmirror.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.33.tgz", + "integrity": "sha512-HqYnb6pxlsshoSTubdXKu15g3iivcbsMXg4bYpjL2iS/V6aQot+iyF4BUc2qA/J/n55YtvE4PHMKWBKGCF/+wA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "14.2.33", + "resolved": "https://registry.npmmirror.com/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.33.tgz", + "integrity": "sha512-8HGBeAE5rX3jzKvF593XTTFg3gxeU4f+UWnswa6JPhzaR6+zblO5+fjltJWIZc4aUalqTclvN2QtTC37LxvZAA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "14.2.33", + "resolved": "https://registry.npmmirror.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.33.tgz", + "integrity": "sha512-JXMBka6lNNmqbkvcTtaX8Gu5by9547bukHQvPoLe9VRBx1gHwzf5tdt4AaezW85HAB3pikcvyqBToRTDA4DeLw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "14.2.33", + "resolved": "https://registry.npmmirror.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.33.tgz", + "integrity": "sha512-Bm+QulsAItD/x6Ih8wGIMfRJy4G73tu1HJsrccPW6AfqdZd0Sfm5Imhgkgq2+kly065rYMnCOxTBvmvFY1BKfg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "14.2.33", + "resolved": "https://registry.npmmirror.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.33.tgz", + "integrity": "sha512-FnFn+ZBgsVMbGDsTqo8zsnRzydvsGV8vfiWwUo1LD8FTmPTdV+otGSWKc4LJec0oSexFnCYVO4hX8P8qQKaSlg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "14.2.33", + "resolved": "https://registry.npmmirror.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.33.tgz", + "integrity": "sha512-345tsIWMzoXaQndUTDv1qypDRiebFxGYx9pYkhwY4hBRaOLt8UGfiWKr9FSSHs25dFIf8ZqIFaPdy5MljdoawA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "14.2.33", + "resolved": "https://registry.npmmirror.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.33.tgz", + "integrity": "sha512-nscpt0G6UCTkrT2ppnJnFsYbPDQwmum4GNXYTeoTIdsmMydSKFz9Iny2jpaRupTb+Wl298+Rh82WKzt9LCcqSQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-ia32-msvc": { + "version": "14.2.33", + "resolved": "https://registry.npmmirror.com/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.33.tgz", + "integrity": "sha512-pc9LpGNKhJ0dXQhZ5QMmYxtARwwmWLpeocFmVG5Z0DzWq5Uf0izcI8tLc+qOpqxO1PWqZ5A7J1blrUIKrIFc7Q==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "14.2.33", + "resolved": "https://registry.npmmirror.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.33.tgz", + "integrity": "sha512-nOjfZMy8B94MdisuzZo9/57xuFVLHJaDj5e/xrduJp9CV2/HrfxTRH2fbyLe+K9QT41WBLUd4iXX3R7jBp0EUg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@radix-ui/number": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/@radix-ui/number/-/number-1.1.1.tgz", + "integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==", + "license": "MIT" + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmmirror.com/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-alert-dialog": { + "version": "1.1.15", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.15.tgz", + "integrity": "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dialog": "1.1.15", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox": { + "version": "1.3.3", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-checkbox/-/react-checkbox-1.3.3.tgz", + "integrity": "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.15", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", + "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.11", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", + "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.7", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", + "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-portal": { + "version": "1.1.9", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", + "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", + "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.3", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", + "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-id/-/react-id-1.1.1.tgz", + "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-label": { + "version": "2.1.8", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-label/-/react-label-2.1.8.tgz", + "integrity": "sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-label/node_modules/@radix-ui/react-primitive": { + "version": "2.1.4", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", + "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover": { + "version": "1.1.15", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-popover/-/react-popover-1.1.15.tgz", + "integrity": "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.11", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", + "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.7", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", + "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-popper": { + "version": "1.2.8", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", + "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-rect": "1.1.1", + "@radix-ui/react-use-size": "1.1.1", + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-popper/node_modules/@radix-ui/react-arrow": { + "version": "1.1.7", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", + "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-portal": { + "version": "1.1.9", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", + "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area": { + "version": "1.2.10", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.10.tgz", + "integrity": "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select": { + "version": "2.2.6", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-select/-/react-select-2.2.6.tgz", + "integrity": "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-collection": { + "version": "1.1.7", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", + "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.11", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", + "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.7", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", + "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-popper": { + "version": "1.2.8", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", + "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-rect": "1.1.1", + "@radix-ui/react-use-size": "1.1.1", + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-popper/node_modules/@radix-ui/react-arrow": { + "version": "1.1.7", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", + "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-portal": { + "version": "1.1.9", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", + "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.3", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz", + "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-separator": { + "version": "1.1.8", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-separator/-/react-separator-1.1.8.tgz", + "integrity": "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-primitive": { + "version": "2.1.4", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", + "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.4", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", + "integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", + "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.2", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", + "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-previous": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz", + "integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", + "integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==", + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", + "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/rect": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/@radix-ui/rect/-/rect-1.1.1.tgz", + "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmmirror.com/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, + "node_modules/@swc/counter": { + "version": "0.1.3", + "resolved": "https://registry.npmmirror.com/@swc/counter/-/counter-0.1.3.tgz", + "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", + "license": "Apache-2.0" + }, + "node_modules/@swc/helpers": { + "version": "0.5.5", + "resolved": "https://registry.npmmirror.com/@swc/helpers/-/helpers-0.5.5.tgz", + "integrity": "sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==", + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.1.18", + "resolved": "https://registry.npmmirror.com/@tailwindcss/node/-/node-4.1.18.tgz", + "integrity": "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "enhanced-resolve": "^5.18.3", + "jiti": "^2.6.1", + "lightningcss": "1.30.2", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.1.18" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.1.18", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide/-/oxide-4.1.18.tgz", + "integrity": "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==", + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.1.18", + "@tailwindcss/oxide-darwin-arm64": "4.1.18", + "@tailwindcss/oxide-darwin-x64": "4.1.18", + "@tailwindcss/oxide-freebsd-x64": "4.1.18", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", + "@tailwindcss/oxide-linux-x64-musl": "4.1.18", + "@tailwindcss/oxide-wasm32-wasi": "4.1.18", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.1.18", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.18.tgz", + "integrity": "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.1.18", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.18.tgz", + "integrity": "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.1.18", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.18.tgz", + "integrity": "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.1.18", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.18.tgz", + "integrity": "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.1.18", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.18.tgz", + "integrity": "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.1.18", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.18.tgz", + "integrity": "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.1.18", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.18.tgz", + "integrity": "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.1.18", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.18.tgz", + "integrity": "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.1.18", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.18.tgz", + "integrity": "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.1.18", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.18.tgz", + "integrity": "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.0", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.1.18", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz", + "integrity": "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.1.18", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.18.tgz", + "integrity": "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/postcss": { + "version": "4.1.18", + "resolved": "https://registry.npmmirror.com/@tailwindcss/postcss/-/postcss-4.1.18.tgz", + "integrity": "sha512-Ce0GFnzAOuPyfV5SxjXGn0CubwGcuDB0zcdaPuCSzAa/2vII24JTkH+I6jcbXLb1ctjZMZZI6OjDaLPJQL1S0g==", + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "@tailwindcss/node": "4.1.18", + "@tailwindcss/oxide": "4.1.18", + "postcss": "^8.4.41", + "tailwindcss": "4.1.18" + } + }, + "node_modules/@types/node": { + "version": "20.19.30", + "resolved": "https://registry.npmmirror.com/@types/node/-/node-20.19.30.tgz", + "integrity": "sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmmirror.com/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.27", + "resolved": "https://registry.npmmirror.com/@types/react/-/react-18.3.27.tgz", + "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmmirror.com/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/autoprefixer": { + "version": "10.4.23", + "resolved": "https://registry.npmmirror.com/autoprefixer/-/autoprefixer-10.4.23.tgz", + "integrity": "sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.1", + "caniuse-lite": "^1.0.30001760", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.9.15", + "resolved": "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.9.15.tgz", + "integrity": "sha512-kX8h7K2srmDyYnXRIppo4AH/wYgzWVCs+eKr3RusRSQ5PvRYoEFmR/I0PbdTjKFAoKqp5+kbxnNTFO9jOfSVJg==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmmirror.com/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmmirror.com/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001765", + "resolved": "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001765.tgz", + "integrity": "sha512-LWcNtSyZrakjECqmpP4qdg0MMGdN368D7X8XvvAqOcqMv0RxnlqVKZl2V6/mBR68oYMxOZPLw/gO7DuisMHUvQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmmirror.com/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmmirror.com/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/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-jalali": { + "version": "4.1.0-0", + "resolved": "https://registry.npmmirror.com/date-fns-jalali/-/date-fns-jalali-4.1.0-0.tgz", + "integrity": "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==", + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.267", + "resolved": "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", + "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==", + "license": "ISC" + }, + "node_modules/enhanced-resolve": { + "version": "5.18.4", + "resolved": "https://registry.npmmirror.com/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz", + "integrity": "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmmirror.com/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmmirror.com/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmmirror.com/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/lightningcss": { + "version": "1.30.2", + "resolved": "https://registry.npmmirror.com/lightningcss/-/lightningcss-1.30.2.tgz", + "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.30.2", + "lightningcss-darwin-arm64": "1.30.2", + "lightningcss-darwin-x64": "1.30.2", + "lightningcss-freebsd-x64": "1.30.2", + "lightningcss-linux-arm-gnueabihf": "1.30.2", + "lightningcss-linux-arm64-gnu": "1.30.2", + "lightningcss-linux-arm64-musl": "1.30.2", + "lightningcss-linux-x64-gnu": "1.30.2", + "lightningcss-linux-x64-musl": "1.30.2", + "lightningcss-win32-arm64-msvc": "1.30.2", + "lightningcss-win32-x64-msvc": "1.30.2" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmmirror.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz", + "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmmirror.com/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz", + "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmmirror.com/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz", + "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmmirror.com/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz", + "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.30.2", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz", + "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz", + "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz", + "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz", + "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz", + "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmmirror.com/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz", + "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmmirror.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz", + "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lucide-react": { + "version": "0.562.0", + "resolved": "https://registry.npmmirror.com/lucide-react/-/lucide-react-0.562.0.tgz", + "integrity": "sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmmirror.com/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/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmmirror.com/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/next": { + "version": "14.2.35", + "resolved": "https://registry.npmmirror.com/next/-/next-14.2.35.tgz", + "integrity": "sha512-KhYd2Hjt/O1/1aZVX3dCwGXM1QmOV4eNM2UTacK5gipDdPN/oHHK/4oVGy7X8GMfPMsUTUEmGlsy0EY1YGAkig==", + "license": "MIT", + "dependencies": { + "@next/env": "14.2.35", + "@swc/helpers": "0.5.5", + "busboy": "1.6.0", + "caniuse-lite": "^1.0.30001579", + "graceful-fs": "^4.2.11", + "postcss": "8.4.31", + "styled-jsx": "5.1.1" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": ">=18.17.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "14.2.33", + "@next/swc-darwin-x64": "14.2.33", + "@next/swc-linux-arm64-gnu": "14.2.33", + "@next/swc-linux-arm64-musl": "14.2.33", + "@next/swc-linux-x64-gnu": "14.2.33", + "@next/swc-linux-x64-musl": "14.2.33", + "@next/swc-win32-arm64-msvc": "14.2.33", + "@next/swc-win32-ia32-msvc": "14.2.33", + "@next/swc-win32-x64-msvc": "14.2.33" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.41.2", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/next-themes": { + "version": "0.4.6", + "resolved": "https://registry.npmmirror.com/next-themes/-/next-themes-0.4.6.tgz", + "integrity": "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" + } + }, + "node_modules/next/node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "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.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmmirror.com/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "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-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "license": "MIT" + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmmirror.com/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-day-picker": { + "version": "9.13.0", + "resolved": "https://registry.npmmirror.com/react-day-picker/-/react-day-picker-9.13.0.tgz", + "integrity": "sha512-euzj5Hlq+lOHqI53NiuNhCP8HWgsPf/bBAVijR50hNaY1XwjKjShAnIe8jm8RD2W9IJUvihDIZ+KrmqfFzNhFQ==", + "license": "MIT", + "dependencies": { + "@date-fns/tz": "^1.4.1", + "date-fns": "^4.1.0", + "date-fns-jalali": "^4.1.0-0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/gpbl" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-hook-form": { + "version": "7.71.1", + "resolved": "https://registry.npmmirror.com/react-hook-form/-/react-hook-form-7.71.1.tgz", + "integrity": "sha512-9SUJKCGKo8HUSsCO+y0CtqkqI5nNuaDqTxyqPsZPqIwudpj4rCrAz/jZV+jn57bx5gtZKOh3neQu94DXMc+w5w==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, + "node_modules/react-remove-scroll": { + "version": "2.7.2", + "resolved": "https://registry.npmmirror.com/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmmirror.com/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmmirror.com/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmmirror.com/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/sonner": { + "version": "2.0.7", + "resolved": "https://registry.npmmirror.com/sonner/-/sonner-2.0.7.tgz", + "integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==", + "license": "MIT", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/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/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/styled-jsx": { + "version": "5.1.1", + "resolved": "https://registry.npmmirror.com/styled-jsx/-/styled-jsx-5.1.1.tgz", + "integrity": "sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/tailwind-merge": { + "version": "3.4.0", + "resolved": "https://registry.npmmirror.com/tailwind-merge/-/tailwind-merge-3.4.0.tgz", + "integrity": "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "4.1.18", + "resolved": "https://registry.npmmirror.com/tailwindcss/-/tailwindcss-4.1.18.tgz", + "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", + "license": "MIT" + }, + "node_modules/tailwindcss-animate": { + "version": "1.0.7", + "resolved": "https://registry.npmmirror.com/tailwindcss-animate/-/tailwindcss-animate-1.0.7.tgz", + "integrity": "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==", + "license": "MIT", + "peerDependencies": { + "tailwindcss": ">=3.0.0 || insiders" + } + }, + "node_modules/tapable": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmmirror.com/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmmirror.com/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmmirror.com/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmmirror.com/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..b539d6f --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,45 @@ +{ + "name": "notify-frontend", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start" + }, + "dependencies": { + "@hookform/resolvers": "^5.2.2", + "@radix-ui/react-alert-dialog": "^1.1.15", + "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-label": "^2.1.8", + "@radix-ui/react-popover": "^1.1.15", + "@radix-ui/react-scroll-area": "^1.2.10", + "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-separator": "^1.1.8", + "@radix-ui/react-slot": "^1.2.4", + "@tailwindcss/postcss": "^4.1.18", + "autoprefixer": "^10.4.23", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "date-fns": "^4.1.0", + "lucide-react": "^0.562.0", + "next": "^14.2.5", + "next-themes": "^0.4.6", + "postcss": "^8.5.6", + "react": "^18.3.1", + "react-day-picker": "^9.13.0", + "react-dom": "^18.3.1", + "react-hook-form": "^7.71.1", + "sonner": "^2.0.7", + "tailwind-merge": "^3.4.0", + "tailwindcss": "^4.1.18", + "tailwindcss-animate": "^1.0.7", + "zod": "^4.3.6" + }, + "devDependencies": { + "@types/node": "^20.14.2", + "@types/react": "^18.3.3", + "typescript": "^5.5.2" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..de8ec71 --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + "@tailwindcss/postcss": {}, + autoprefixer: {}, + }, +}; diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css new file mode 100644 index 0000000..51ff795 --- /dev/null +++ b/frontend/src/app/globals.css @@ -0,0 +1,182 @@ +@import "tailwindcss"; + +@plugin "tailwindcss-animate"; + +@custom-variant dark (&:is(.dark *)); + +@layer base { + :root { + --background: 220 20% 97%; + --foreground: 220 20% 10%; + --card: 0 0% 100%; + --card-foreground: 220 20% 10%; + --popover: 0 0% 100%; + --popover-foreground: 220 20% 10%; + --primary: 220 80% 55%; + --primary-foreground: 0 0% 100%; + --secondary: 220 15% 94%; + --secondary-foreground: 220 20% 20%; + --muted: 220 15% 94%; + --muted-foreground: 220 10% 50%; + --accent: 220 15% 94%; + --accent-foreground: 220 20% 20%; + --destructive: 0 72% 55%; + --destructive-foreground: 0 0% 100%; + --border: 220 15% 92%; + --input: 220 15% 90%; + --ring: 220 80% 55%; + --radius: 0.5rem; + --sidebar: 0 0% 100%; + --sidebar-foreground: 220 20% 20%; + --sidebar-primary: 220 80% 55%; + --sidebar-primary-foreground: 0 0% 100%; + --sidebar-accent: 220 15% 96%; + --sidebar-accent-foreground: 220 20% 20%; + --sidebar-border: 220 15% 94%; + --sidebar-ring: 220 80% 55%; + } + + html { + font-size: 28px; + } + + * { + border-color: hsl(var(--border) / 0.6); + } + + body { + background-color: hsl(var(--background)); + color: hsl(var(--foreground)); + } +} + +@theme inline { + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --radius-2xl: calc(var(--radius) + 8px); + --radius-3xl: calc(var(--radius) + 12px); + --radius-4xl: calc(var(--radius) + 16px); + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); +} + +:root { + --radius: 0.625rem; + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); +} + +.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.556 0 0); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + + body { + @apply bg-background text-foreground; + } +} + +@layer utilities { + .scrollbar-hide { + -ms-overflow-style: none; + scrollbar-width: none; + } + + .scrollbar-hide::-webkit-scrollbar { + display: none; + } +} \ No newline at end of file diff --git a/frontend/src/app/invites/page.tsx b/frontend/src/app/invites/page.tsx new file mode 100644 index 0000000..d272c5e --- /dev/null +++ b/frontend/src/app/invites/page.tsx @@ -0,0 +1,301 @@ +"use client"; + +import { useEffect, useState } from "react"; + +import AppShell from "@/components/AppShell"; +import Avatar from "@/components/ui/avatar"; +import { Check, Copy, Eye, Users } from "lucide-react"; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { api } from "@/lib/api"; +import { useTranslation } from "@/lib/i18n"; + +type RegisteredUser = { + id: string; + username: string; + avatar?: string | null; + createdAt: string; +}; + +type Invite = { + id: string; + code: string; + creator_id: string; + max_uses: number; + used_count: number; + expires_at: string; + revoked_at: string | null; + created_at: string; +}; + +type InviteWithUsers = { + id: string; + code: string; + creatorId: string; + maxUses: number; + usedCount: number; + expiresAt: string; + revokedAt: string | null; + createdAt: string; + registeredUsers: RegisteredUser[]; +}; + +const InvitesPage = () => { + const t = useTranslation(); + const [invites, setInvites] = useState([]); + const [maxUses, setMaxUses] = useState(5); + const [expiresInDays, setExpiresInDays] = useState(7); + const [selectedInvite, setSelectedInvite] = useState(null); + const [detailsOpen, setDetailsOpen] = useState(false); + const [copiedId, setCopiedId] = useState(null); + + const load = async () => { + const data = await api.getInvites(); + setInvites(data as Invite[]); + }; + + useEffect(() => { + load().catch(() => null); + }, []); + + const createInvite = async (event: React.FormEvent) => { + event.preventDefault(); + await api.createInvite({ maxUses, expiresInDays }); + setMaxUses(5); + setExpiresInDays(7); + await load(); + }; + + const revokeInvite = async (id: string) => { + await api.revokeInvite(id); + await load(); + }; + + const viewDetails = async (id: string) => { + const data = await api.getInvite(id); + setSelectedInvite(data as InviteWithUsers); + setDetailsOpen(true); + }; + + const copyCode = async (code: string, id: string) => { + await navigator.clipboard.writeText(code); + setCopiedId(id); + setTimeout(() => setCopiedId(null), 2000); + }; + + const formatDateTime = (dateStr: string): string => { + const d = new Date(dateStr); + const pad = (n: number) => n.toString().padStart(2, "0"); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`; + }; + + const getInviteStatus = (invite: Invite): { key: string; color: string } => { + if (invite.revoked_at) { + return { key: "statusRevoked", color: "bg-red-100 text-red-700" }; + } + if (new Date(invite.expires_at) < new Date()) { + return { key: "statusExpired", color: "bg-slate-100 text-slate-700" }; + } + if (invite.used_count >= invite.max_uses) { + return { key: "statusExhausted", color: "bg-amber-100 text-amber-700" }; + } + return { key: "statusActive", color: "bg-green-100 text-green-700" }; + }; + + return ( + +
+ + + {t("createInvite")} + {t("createInviteDesc")} + + +
+
+ + setMaxUses(Number(event.target.value))} + /> +
+
+ + setExpiresInDays(Number(event.target.value))} + /> +
+ +
+
+
+ + + + {t("myInvites")} + {t("myInvitesDesc")} + + +
+ {invites.map((invite) => { + const status = getInviteStatus(invite); + const isActive = status.key === "statusActive"; + return ( +
+
+
+ + {invite.code} + + + + {t(status.key as keyof ReturnType)} + +
+
+ + + {invite.used_count}/{invite.max_uses} + + {t("expiresAt")}: {formatDateTime(invite.expires_at)} +
+
+
+ + {isActive && ( + + + + + + + {t("revokeInvite")} + + {t("revokeInviteDesc", { code: invite.code })} + + + + {t("cancel")} + revokeInvite(invite.id)} + > + {t("revoke")} + + + + + )} +
+
+ ); + })} + {invites.length === 0 && ( +
+ {t("noInvites")} +
+ )} +
+
+
+
+ + {/* Details Dialog */} + + + + + + {selectedInvite?.code} + + + + {t("registeredUsers")} + + +
+ {selectedInvite?.registeredUsers && selectedInvite.registeredUsers.length > 0 ? ( + selectedInvite.registeredUsers.map((user) => ( +
+ +
+
{user.username}
+
+ {formatDateTime(user.createdAt)} +
+
+
+ )) + ) : ( +
+ {t("noRegisteredUsers")} +
+ )} +
+
+
+
+ ); +}; + +export default InvitesPage; diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx new file mode 100644 index 0000000..a01e259 --- /dev/null +++ b/frontend/src/app/layout.tsx @@ -0,0 +1,20 @@ +import "./globals.css"; + +import { I18nProvider } from "@/lib/i18n"; + +export const metadata = { + title: "Notify", + description: "简洁提醒应用", +}; + +const RootLayout = ({ children }: { children: React.ReactNode }) => { + return ( + + + {children} + + + ); +}; + +export default RootLayout; diff --git a/frontend/src/app/login/page.tsx b/frontend/src/app/login/page.tsx new file mode 100644 index 0000000..aa35b39 --- /dev/null +++ b/frontend/src/app/login/page.tsx @@ -0,0 +1,81 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; + +import LanguageSwitcher from "@/components/LanguageSwitcher"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { api } from "@/lib/api"; +import { setToken } from "@/lib/auth"; +import { useTranslation } from "@/lib/i18n"; + +const LoginPage = () => { + const t = useTranslation(); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(""); + + const onSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + setError(""); + try { + const result = await api.login({ username, password }); + setToken(result.token); + window.location.href = "/todos"; + } catch (err) { + setError(t("loginFailed")); + } + }; + + return ( +
+
+ +
+ + + {t("login")} + {t("loginWelcome")} + + +
+
+ + setUsername(event.target.value)} + /> +
+
+ + setPassword(event.target.value)} + /> +
+ {error &&
{error}
} + + + {t("noAccount")} + +
+
+
+
+ ); +}; + +export default LoginPage; diff --git a/frontend/src/app/notifications/page.tsx b/frontend/src/app/notifications/page.tsx new file mode 100644 index 0000000..fdc6d16 --- /dev/null +++ b/frontend/src/app/notifications/page.tsx @@ -0,0 +1,94 @@ +"use client"; + +import { useEffect, useState } from "react"; + +import AppShell from "@/components/AppShell"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { api } from "@/lib/api"; +import { useTranslation } from "@/lib/i18n"; +import { useNotification } from "@/lib/notification-context"; + +type Notification = { + id: string; + triggerAt: string; + status: string; + channel: string; + readAt?: string | null; +}; + +const NotificationsPage = () => { + const t = useTranslation(); + const [notifications, setNotifications] = useState([]); + const { refreshUnreadCount } = useNotification(); + + const load = async () => { + const data = (await api.getNotifications()) as Notification[]; + setNotifications(data); + }; + + useEffect(() => { + load().catch(() => null); + }, []); + + const markRead = async (id: string) => { + await api.markNotificationRead(id); + await load(); + await refreshUnreadCount(); + }; + + const markAllRead = async () => { + await api.markAllNotificationsRead(); + await load(); + await refreshUnreadCount(); + }; + + return ( + + + +
+ {t("notifications")} + {t("notificationsDesc")} +
+
+ + {notifications.length > 0 && ( + + )} +
+
+ +
+ {notifications.map((item) => ( +
+
+
+ {t("triggerTime")}:{new Date(item.triggerAt).toLocaleString()} +
+
{t("channel")}:{item.channel}
+
+ +
+ ))} + {notifications.length === 0 && ( +
+ {t("noNotification")} +
+ )} +
+
+
+
+ ); +}; + +export default NotificationsPage; diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx new file mode 100644 index 0000000..092b7b3 --- /dev/null +++ b/frontend/src/app/page.tsx @@ -0,0 +1,23 @@ +"use client"; + +import { useEffect } from "react"; + +import { getToken } from "@/lib/auth"; +import { useTranslation } from "@/lib/i18n"; + +const HomePage = () => { + const t = useTranslation(); + + useEffect(() => { + const token = getToken(); + window.location.href = token ? "/todos" : "/login"; + }, []); + + return ( +
+ {t("loading")} +
+ ); +}; + +export default HomePage; diff --git a/frontend/src/app/register/page.tsx b/frontend/src/app/register/page.tsx new file mode 100644 index 0000000..b2068bb --- /dev/null +++ b/frontend/src/app/register/page.tsx @@ -0,0 +1,91 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; + +import LanguageSwitcher from "@/components/LanguageSwitcher"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { api } from "@/lib/api"; +import { setToken } from "@/lib/auth"; +import { useTranslation } from "@/lib/i18n"; + +const RegisterPage = () => { + const t = useTranslation(); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [inviteCode, setInviteCode] = useState(""); + const [error, setError] = useState(""); + + const onSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + setError(""); + try { + const result = await api.register({ username, password, inviteCode }); + setToken(result.token); + window.location.href = "/todos"; + } catch (err) { + setError(t("registerFailed")); + } + }; + + return ( +
+
+ +
+ + + {t("registerTitle")} + {t("registerDesc")} + + +
+
+ + setInviteCode(event.target.value)} + /> +
+
+ + setUsername(event.target.value)} + /> +
+
+ + setPassword(event.target.value)} + /> +
+ {error &&
{error}
} + + + {t("hasAccount")} + +
+
+
+
+ ); +}; + +export default RegisterPage; diff --git a/frontend/src/app/reminders/page.tsx b/frontend/src/app/reminders/page.tsx new file mode 100644 index 0000000..b863ee5 --- /dev/null +++ b/frontend/src/app/reminders/page.tsx @@ -0,0 +1,467 @@ +"use client"; + +import { useEffect, useState } from "react"; + +import AppShell from "@/components/AppShell"; +import Avatar from "@/components/ui/avatar"; +import { X } from "lucide-react"; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Checkbox } from "@/components/ui/checkbox"; +import { DateTimePicker } from "@/components/ui/datetime-picker"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { RecurrencePicker, type RecurrenceRule as RecurrenceRuleInput } from "@/components/ui/recurrence-picker"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { api } from "@/lib/api"; +import { useTranslation } from "@/lib/i18n"; + +type RecurrenceType = "hourly" | "daily" | "weekly" | "monthly" | "yearly"; + +type RecurrenceRule = { + id: string; + type: RecurrenceType; + interval: number; + byWeekday?: number | null; + byMonthday?: number | null; +}; + +type User = { id: string; username: string; avatar?: string | null }; +type Task = { id: string; title: string; dueAt: string; recurrenceRule?: RecurrenceRule | null }; + +const RemindersPage = () => { + const t = useTranslation(); + const [tasks, setTasks] = useState([]); + const [users, setUsers] = useState([]); + const [title, setTitle] = useState(""); + const [content, setContent] = useState(""); + const [dueAt, setDueAt] = useState(undefined); + const [offsetMinutes, setOffsetMinutes] = useState(0); + const [showAdvanceReminder, setShowAdvanceReminder] = useState(false); + const [recipientIds, setRecipientIds] = useState([]); + const [isRecurring, setIsRecurring] = useState(false); + const [recurrenceRule, setRecurrenceRule] = useState({ type: "daily", interval: 1 }); + + // Bark settings + const [showBarkSettings, setShowBarkSettings] = useState(false); + const [barkTitle, setBarkTitle] = useState(""); + const [barkSubtitle, setBarkSubtitle] = useState(""); + const [barkUseMarkdown, setBarkUseMarkdown] = useState(false); + const [barkMarkdownContent, setBarkMarkdownContent] = useState(""); + const [barkLevel, setBarkLevel] = useState(""); + const [barkIcon, setBarkIcon] = useState(""); + + const load = async () => { + const [tasksData, usersData] = await Promise.all([api.getReminderTasks(), api.getUsers()]); + setTasks(tasksData as Task[]); + setUsers(usersData as User[]); + }; + + useEffect(() => { + load().catch(() => null); + }, []); + + const addRecipient = (id: string) => { + setRecipientIds((prev) => (prev.includes(id) ? prev : [...prev, id])); + }; + + const removeRecipient = (id: string) => { + setRecipientIds((prev) => prev.filter((item) => item !== id)); + }; + + const createTask = async (event: React.FormEvent) => { + event.preventDefault(); + if (!dueAt || recipientIds.length === 0) return; + await api.createReminderTask({ + title, + content: barkUseMarkdown && barkMarkdownContent ? barkMarkdownContent : content, + dueAt: dueAt.toISOString(), + recipientIds, + offsets: [{ + offsetMinutes, + channelInapp: true, + channelBark: true, + ...(barkTitle && { barkTitle }), + ...(barkSubtitle && { barkSubtitle }), + ...(barkUseMarkdown && barkMarkdownContent && { barkBodyMarkdown: barkMarkdownContent }), + ...(barkLevel && { barkLevel }), + ...(barkIcon && { barkIcon }), + }], + ...(isRecurring && { + recurrenceRule: { + type: recurrenceRule.type, + interval: recurrenceRule.interval, + by_weekday: recurrenceRule.byWeekday, + by_monthday: recurrenceRule.byMonthday, + }, + }), + }); + setTitle(""); + setContent(""); + setDueAt(undefined); + setOffsetMinutes(0); + setShowAdvanceReminder(false); + setRecipientIds([]); + setIsRecurring(false); + setRecurrenceRule({ type: "daily", interval: 1 }); + // Reset Bark settings + setShowBarkSettings(false); + setBarkTitle(""); + setBarkSubtitle(""); + setBarkUseMarkdown(false); + setBarkMarkdownContent(""); + setBarkLevel(""); + setBarkIcon(""); + await load(); + }; + + const deleteTask = async (id: string) => { + await api.deleteReminderTask(id); + await load(); + }; + + const formatDateTime = (dateStr: string): string => { + const d = new Date(dateStr); + const pad = (n: number) => n.toString().padStart(2, "0"); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`; + }; + + const getRecurrenceLabel = (rule: RecurrenceRule): string => { + const interval = rule.interval || 1; + if (rule.type === "weekly" && interval === 2) { + return t("modeBiweekly"); + } + switch (rule.type) { + case "daily": return t("modeDaily"); + case "weekly": return t("modeWeekly"); + case "monthly": return t("modeMonthly"); + case "yearly": return t("modeYearly"); + default: return rule.type; + } + }; + + return ( + +
+ + + {t("createReminder")} + {t("createReminderDesc")} + + +
+
+ + setTitle(event.target.value)} + /> +
+
+ +