@cachly-dev/mcp-server 0.10.55 → 0.10.57
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/eas-build-android.yml +58 -0
- package/.github/workflows/eas-update.yml +55 -0
- package/.github/workflows/publish.yml +2 -2
- package/BENCH.md +91 -0
- package/CHANGELOG.md +44 -0
- package/README.md +6 -34
- package/STRATEGY.md +228 -0
- package/apps/travel-chaos-organizer/.env.example +49 -0
- package/apps/travel-chaos-organizer/DEPLOY.md +328 -0
- package/apps/travel-chaos-organizer/Makefile +57 -0
- package/apps/travel-chaos-organizer/README.md +159 -0
- package/apps/travel-chaos-organizer/backend/.env.example +10 -0
- package/apps/travel-chaos-organizer/backend/Dockerfile +25 -0
- package/apps/travel-chaos-organizer/backend/alembic/env.py +56 -0
- package/apps/travel-chaos-organizer/backend/alembic/script.py.mako +24 -0
- package/apps/travel-chaos-organizer/backend/alembic/versions/.gitkeep +0 -0
- package/apps/travel-chaos-organizer/backend/alembic/versions/0001_stripe_customer_id.py +24 -0
- package/apps/travel-chaos-organizer/backend/alembic/versions/0002_telegram_chat_id.py +24 -0
- package/apps/travel-chaos-organizer/backend/alembic.ini +39 -0
- package/apps/travel-chaos-organizer/backend/app/__init__.py +0 -0
- package/apps/travel-chaos-organizer/backend/app/auth/__init__.py +0 -0
- package/apps/travel-chaos-organizer/backend/app/auth/keycloak.py +81 -0
- package/apps/travel-chaos-organizer/backend/app/config.py +66 -0
- package/apps/travel-chaos-organizer/backend/app/db/__init__.py +0 -0
- package/apps/travel-chaos-organizer/backend/app/db/database.py +20 -0
- package/apps/travel-chaos-organizer/backend/app/db/migrations/001_init.sql +71 -0
- package/apps/travel-chaos-organizer/backend/app/db/migrations/002_telemetry_freemium.sql +31 -0
- package/apps/travel-chaos-organizer/backend/app/db/migrations/003_telegram_chat_id.sql +4 -0
- package/apps/travel-chaos-organizer/backend/app/db/migrations/004_stripe_and_email.sql +5 -0
- package/apps/travel-chaos-organizer/backend/app/limiter.py +4 -0
- package/apps/travel-chaos-organizer/backend/app/logging_config.py +25 -0
- package/apps/travel-chaos-organizer/backend/app/main.py +77 -0
- package/apps/travel-chaos-organizer/backend/app/models/__init__.py +0 -0
- package/apps/travel-chaos-organizer/backend/app/models/schemas.py +142 -0
- package/apps/travel-chaos-organizer/backend/app/routers/__init__.py +0 -0
- package/apps/travel-chaos-organizer/backend/app/routers/admin.py +237 -0
- package/apps/travel-chaos-organizer/backend/app/routers/cache.py +13 -0
- package/apps/travel-chaos-organizer/backend/app/routers/events.py +29 -0
- package/apps/travel-chaos-organizer/backend/app/routers/export.py +122 -0
- package/apps/travel-chaos-organizer/backend/app/routers/inbox.py +122 -0
- package/apps/travel-chaos-organizer/backend/app/routers/items.py +130 -0
- package/apps/travel-chaos-organizer/backend/app/routers/mail.py +108 -0
- package/apps/travel-chaos-organizer/backend/app/routers/parse.py +303 -0
- package/apps/travel-chaos-organizer/backend/app/routers/payments.py +179 -0
- package/apps/travel-chaos-organizer/backend/app/routers/trips.py +134 -0
- package/apps/travel-chaos-organizer/backend/app/routers/users.py +169 -0
- package/apps/travel-chaos-organizer/backend/app/routers/waitlist.py +37 -0
- package/apps/travel-chaos-organizer/backend/app/services/__init__.py +0 -0
- package/apps/travel-chaos-organizer/backend/app/services/cache.py +131 -0
- package/apps/travel-chaos-organizer/backend/app/services/drip.py +128 -0
- package/apps/travel-chaos-organizer/backend/app/services/email.py +61 -0
- package/apps/travel-chaos-organizer/backend/app/services/errors.py +44 -0
- package/apps/travel-chaos-organizer/backend/app/services/notifier.py +10 -0
- package/apps/travel-chaos-organizer/backend/app/services/ollama.py +101 -0
- package/apps/travel-chaos-organizer/backend/app/services/parser.py +22 -0
- package/apps/travel-chaos-organizer/backend/app/services/quota.py +85 -0
- package/apps/travel-chaos-organizer/backend/app/services/sentry.py +11 -0
- package/apps/travel-chaos-organizer/backend/app/services/telemetry.py +35 -0
- package/apps/travel-chaos-organizer/backend/pytest.ini +4 -0
- package/apps/travel-chaos-organizer/backend/requirements-dev.txt +6 -0
- package/apps/travel-chaos-organizer/backend/requirements.txt +25 -0
- package/apps/travel-chaos-organizer/backend/tests/__init__.py +0 -0
- package/apps/travel-chaos-organizer/backend/tests/conftest.py +223 -0
- package/apps/travel-chaos-organizer/backend/tests/test_admin.py +99 -0
- package/apps/travel-chaos-organizer/backend/tests/test_cache.py +142 -0
- package/apps/travel-chaos-organizer/backend/tests/test_drip.py +32 -0
- package/apps/travel-chaos-organizer/backend/tests/test_e2e_chain.py +121 -0
- package/apps/travel-chaos-organizer/backend/tests/test_export.py +48 -0
- package/apps/travel-chaos-organizer/backend/tests/test_inbox.py +107 -0
- package/apps/travel-chaos-organizer/backend/tests/test_items.py +138 -0
- package/apps/travel-chaos-organizer/backend/tests/test_mail.py +113 -0
- package/apps/travel-chaos-organizer/backend/tests/test_ollama.py +145 -0
- package/apps/travel-chaos-organizer/backend/tests/test_parse_url.py +98 -0
- package/apps/travel-chaos-organizer/backend/tests/test_parser.py +72 -0
- package/apps/travel-chaos-organizer/backend/tests/test_payments.py +163 -0
- package/apps/travel-chaos-organizer/backend/tests/test_quota.py +46 -0
- package/apps/travel-chaos-organizer/backend/tests/test_search.py +67 -0
- package/apps/travel-chaos-organizer/backend/tests/test_trips.py +123 -0
- package/apps/travel-chaos-organizer/backend/tests/test_users.py +52 -0
- package/apps/travel-chaos-organizer/backups/.gitkeep +0 -0
- package/apps/travel-chaos-organizer/caddy/Caddyfile +27 -0
- package/apps/travel-chaos-organizer/docker-compose.node1.yml +31 -0
- package/apps/travel-chaos-organizer/docker-compose.override.yml +14 -0
- package/apps/travel-chaos-organizer/docker-compose.yml +128 -0
- package/apps/travel-chaos-organizer/frontend/.env.example +13 -0
- package/apps/travel-chaos-organizer/frontend/app/(app)/_layout.tsx +80 -0
- package/apps/travel-chaos-organizer/frontend/app/(app)/inbox.tsx +166 -0
- package/apps/travel-chaos-organizer/frontend/app/(app)/search.tsx +130 -0
- package/apps/travel-chaos-organizer/frontend/app/(app)/settings.tsx +381 -0
- package/apps/travel-chaos-organizer/frontend/app/(app)/trips/[id].tsx +346 -0
- package/apps/travel-chaos-organizer/frontend/app/(app)/trips.tsx +363 -0
- package/apps/travel-chaos-organizer/frontend/app/(app)/upgrade/cancel.tsx +34 -0
- package/apps/travel-chaos-organizer/frontend/app/(app)/upgrade/success.tsx +35 -0
- package/apps/travel-chaos-organizer/frontend/app/(auth)/login.tsx +91 -0
- package/apps/travel-chaos-organizer/frontend/app/_layout.tsx +71 -0
- package/apps/travel-chaos-organizer/frontend/app/auth.tsx +29 -0
- package/apps/travel-chaos-organizer/frontend/app/index.tsx +16 -0
- package/apps/travel-chaos-organizer/frontend/app/onboarding.tsx +119 -0
- package/apps/travel-chaos-organizer/frontend/app.config.ts +54 -0
- package/apps/travel-chaos-organizer/frontend/assets/adaptive-icon.svg +66 -0
- package/apps/travel-chaos-organizer/frontend/assets/icon.svg +79 -0
- package/apps/travel-chaos-organizer/frontend/assets/splash.svg +15 -0
- package/apps/travel-chaos-organizer/frontend/components/BottomSheet.tsx +102 -0
- package/apps/travel-chaos-organizer/frontend/components/ConfettiCannon.tsx +107 -0
- package/apps/travel-chaos-organizer/frontend/components/ConfirmDialog.tsx +109 -0
- package/apps/travel-chaos-organizer/frontend/components/ErrorBoundary.tsx +45 -0
- package/apps/travel-chaos-organizer/frontend/components/FileUploadButton.tsx +19 -0
- package/apps/travel-chaos-organizer/frontend/components/ImportSheet.tsx +155 -0
- package/apps/travel-chaos-organizer/frontend/components/OfflineBadge.tsx +55 -0
- package/apps/travel-chaos-organizer/frontend/components/QRCodeViewer.tsx +80 -0
- package/apps/travel-chaos-organizer/frontend/components/ScoreRing.tsx +39 -0
- package/apps/travel-chaos-organizer/frontend/components/Screen.tsx +39 -0
- package/apps/travel-chaos-organizer/frontend/components/Skeleton.tsx +70 -0
- package/apps/travel-chaos-organizer/frontend/components/SwipeableInboxItem.tsx +121 -0
- package/apps/travel-chaos-organizer/frontend/components/SwipeableTimelineItem.tsx +70 -0
- package/apps/travel-chaos-organizer/frontend/components/TimelineItem.tsx +73 -0
- package/apps/travel-chaos-organizer/frontend/components/TimelineItemDetail.tsx +130 -0
- package/apps/travel-chaos-organizer/frontend/components/Toast.tsx +60 -0
- package/apps/travel-chaos-organizer/frontend/components/ToastContext.tsx +42 -0
- package/apps/travel-chaos-organizer/frontend/components/TripCard.tsx +146 -0
- package/apps/travel-chaos-organizer/frontend/components/TripQRModal.tsx +70 -0
- package/apps/travel-chaos-organizer/frontend/components/UpgradeBanner.tsx +44 -0
- package/apps/travel-chaos-organizer/frontend/eas.json +22 -0
- package/apps/travel-chaos-organizer/frontend/hooks/useInbox.ts +24 -0
- package/apps/travel-chaos-organizer/frontend/hooks/useNetworkSync.ts +60 -0
- package/apps/travel-chaos-organizer/frontend/hooks/useTrips.ts +112 -0
- package/apps/travel-chaos-organizer/frontend/lib/api.ts +223 -0
- package/apps/travel-chaos-organizer/frontend/lib/auth.ts +93 -0
- package/apps/travel-chaos-organizer/frontend/lib/db.ts +86 -0
- package/apps/travel-chaos-organizer/frontend/lib/fileCache.ts +54 -0
- package/apps/travel-chaos-organizer/frontend/lib/haptics.ts +13 -0
- package/apps/travel-chaos-organizer/frontend/lib/notifications.ts +112 -0
- package/apps/travel-chaos-organizer/frontend/lib/offlineQueue.ts +145 -0
- package/apps/travel-chaos-organizer/frontend/lib/quota.tsx +43 -0
- package/apps/travel-chaos-organizer/frontend/lib/sentry.ts +28 -0
- package/apps/travel-chaos-organizer/frontend/lib/shareIntent.ts +44 -0
- package/apps/travel-chaos-organizer/frontend/package-lock.json +13221 -0
- package/apps/travel-chaos-organizer/frontend/package.json +44 -0
- package/apps/travel-chaos-organizer/frontend/tsconfig.json +23 -0
- package/apps/travel-chaos-organizer/keycloak/realm-export.json +59 -0
- package/apps/travel-chaos-organizer/landing/index.html +773 -0
- package/apps/travel-chaos-organizer/scripts/backup.sh +14 -0
- package/apps/travel-chaos-organizer/scripts/configure-env.sh +82 -0
- package/apps/travel-chaos-organizer/scripts/deploy.sh +54 -0
- package/apps/travel-chaos-organizer/scripts/health-check.sh +36 -0
- package/apps/travel-chaos-organizer/scripts/migrate.sh +7 -0
- package/apps/travel-chaos-organizer/scripts/server-setup.sh +85 -0
- package/apps/travel-chaos-organizer/scripts/smoke-test.sh +29 -0
- package/apps/travel-chaos-organizer/scripts/verify-tracking.sh +71 -0
- package/apps/travel-chaos-organizer/telegram-bot/.env.example +3 -0
- package/apps/travel-chaos-organizer/telegram-bot/Dockerfile +6 -0
- package/apps/travel-chaos-organizer/telegram-bot/bot.py +504 -0
- package/apps/travel-chaos-organizer/telegram-bot/requirements.txt +3 -0
- package/dist/packages/telegram-notify/client.d.ts +19 -0
- package/dist/packages/telegram-notify/client.js +75 -0
- package/dist/packages/telegram-notify/client.js.map +1 -0
- package/dist/src/__tests__/auth.test.js.map +1 -0
- package/dist/{__tests__ → src/__tests__}/brain-flow.test.js +141 -0
- package/dist/src/__tests__/brain-flow.test.js.map +1 -0
- package/dist/src/__tests__/confidence.test.js.map +1 -0
- package/dist/src/__tests__/e2e/api-auth.test.js.map +1 -0
- package/dist/src/__tests__/e2e/api-health.test.js.map +1 -0
- package/dist/src/__tests__/e2e/cli.test.js.map +1 -0
- package/dist/src/__tests__/e2e/config.js.map +1 -0
- package/dist/src/__tests__/e2e/funnel.test.js.map +1 -0
- package/dist/src/__tests__/e2e/mcp-tools.test.js.map +1 -0
- package/dist/src/__tests__/e2e/provisioning.test.js.map +1 -0
- package/dist/{__tests__ → src/__tests__}/handlers-unit.test.js +0 -2
- package/dist/src/__tests__/handlers-unit.test.js.map +1 -0
- package/dist/src/__tests__/namespace.test.js.map +1 -0
- package/dist/src/__tests__/rerank.test.js +115 -0
- package/dist/src/__tests__/rerank.test.js.map +1 -0
- package/dist/src/auth.js.map +1 -0
- package/dist/src/bench/cachly-bench.d.ts +35 -0
- package/dist/src/bench/cachly-bench.js +182 -0
- package/dist/src/bench/cachly-bench.js.map +1 -0
- package/dist/src/bench/fixtures.d.ts +37 -0
- package/dist/src/bench/fixtures.js +225 -0
- package/dist/src/bench/fixtures.js.map +1 -0
- package/dist/src/ckg.js.map +1 -0
- package/dist/src/confidence.js.map +1 -0
- package/dist/src/embeddings.js.map +1 -0
- package/dist/src/handlers/advanced.js.map +1 -0
- package/dist/{handlers → src/handlers}/brain.js +335 -168
- package/dist/src/handlers/brain.js.map +1 -0
- package/dist/src/handlers/cache.js.map +1 -0
- package/dist/{handlers → src/handlers}/context.js +9 -4
- package/dist/src/handlers/context.js.map +1 -0
- package/dist/{handlers → src/handlers}/fedbrain.d.ts +6 -0
- package/dist/{handlers → src/handlers}/fedbrain.js +19 -7
- package/dist/src/handlers/fedbrain.js.map +1 -0
- package/dist/src/handlers/instances.js.map +1 -0
- package/dist/{handlers → src/handlers}/roadmap.js +7 -3
- package/dist/src/handlers/roadmap.js.map +1 -0
- package/dist/src/handlers/syndicate.js.map +1 -0
- package/dist/src/handlers/tco.d.ts +16 -0
- package/dist/src/handlers/tco.js +303 -0
- package/dist/src/handlers/tco.js.map +1 -0
- package/dist/{handlers → src/handlers}/team.js +57 -7
- package/dist/src/handlers/team.js.map +1 -0
- package/dist/{index.js → src/index.js} +209 -420
- package/dist/src/index.js.map +1 -0
- package/dist/src/namespace.js.map +1 -0
- package/dist/src/notifier.d.ts +5 -0
- package/dist/src/notifier.js +6 -0
- package/dist/src/notifier.js.map +1 -0
- package/dist/src/rerank.d.ts +55 -0
- package/dist/src/rerank.js +111 -0
- package/dist/src/rerank.js.map +1 -0
- package/dist/src/search.js.map +1 -0
- package/dist/src/search.test.d.ts +1 -0
- package/dist/src/search.test.js.map +1 -0
- package/dist/{tools.d.ts → src/tools.d.ts} +226 -0
- package/dist/{tools.js → src/tools.js} +196 -0
- package/dist/src/tools.js.map +1 -0
- package/dist/src/utils.js.map +1 -0
- package/package.json +8 -7
- package/packages/telegram-notify/SPLIT_PLAN.md +106 -0
- package/packages/telegram-notify/client.ts +88 -0
- package/packages/telegram-notify/emojis.json +22 -0
- package/packages/telegram-notify/pyproject.toml +17 -0
- package/packages/telegram-notify/telegram_notify/__init__.py +3 -0
- package/packages/telegram-notify/telegram_notify/client.py +87 -0
- package/packages/telegram-notify/telegram_notify/emojis.json +22 -0
- package/scripts/generate-icons.js +28 -0
- package/server.json +3 -3
- package/smithery.yaml +21 -8
- package/src/__tests__/brain-flow.test.ts +174 -0
- package/src/__tests__/handlers-unit.test.ts +1 -2
- package/src/__tests__/rerank.test.ts +130 -0
- package/src/bench/cachly-bench.ts +217 -0
- package/src/bench/fixtures.ts +256 -0
- package/src/handlers/brain.ts +345 -172
- package/src/handlers/context.ts +9 -4
- package/src/handlers/fedbrain.ts +21 -7
- package/src/handlers/roadmap.ts +7 -3
- package/src/handlers/tco.ts +347 -0
- package/src/handlers/team.ts +67 -7
- package/src/index.ts +224 -406
- package/src/notifier.ts +5 -0
- package/src/rerank.ts +148 -0
- package/src/tools.ts +210 -0
- package/tsconfig.json +2 -3
- package/dist/__tests__/auth.test.js.map +0 -1
- package/dist/__tests__/brain-flow.test.js.map +0 -1
- package/dist/__tests__/brain-predict-failures.test.d.ts +0 -17
- package/dist/__tests__/brain-predict-failures.test.js +0 -339
- package/dist/__tests__/brain-predict-failures.test.js.map +0 -1
- package/dist/__tests__/confidence.test.js.map +0 -1
- package/dist/__tests__/e2e/api-auth.test.js.map +0 -1
- package/dist/__tests__/e2e/api-health.test.js.map +0 -1
- package/dist/__tests__/e2e/cli.test.js.map +0 -1
- package/dist/__tests__/e2e/config.js.map +0 -1
- package/dist/__tests__/e2e/funnel.test.js.map +0 -1
- package/dist/__tests__/e2e/mcp-tools.test.js.map +0 -1
- package/dist/__tests__/e2e/provisioning.test.js.map +0 -1
- package/dist/__tests__/handlers-unit.test.js.map +0 -1
- package/dist/__tests__/namespace.test.js.map +0 -1
- package/dist/auth.js.map +0 -1
- package/dist/ckg.js.map +0 -1
- package/dist/confidence.js.map +0 -1
- package/dist/embeddings.js.map +0 -1
- package/dist/handlers/advanced.js.map +0 -1
- package/dist/handlers/brain.js.map +0 -1
- package/dist/handlers/cache.js.map +0 -1
- package/dist/handlers/context.js.map +0 -1
- package/dist/handlers/fedbrain.js.map +0 -1
- package/dist/handlers/instances.js.map +0 -1
- package/dist/handlers/roadmap.js.map +0 -1
- package/dist/handlers/syndicate.js.map +0 -1
- package/dist/handlers/team.js.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/namespace.js.map +0 -1
- package/dist/search.js.map +0 -1
- package/dist/search.test.js.map +0 -1
- package/dist/tools.js.map +0 -1
- package/dist/utils.js.map +0 -1
- package/src/__tests__/brain-predict-failures.test.ts +0 -430
- /package/dist/{__tests__ → src/__tests__}/auth.test.d.ts +0 -0
- /package/dist/{__tests__ → src/__tests__}/auth.test.js +0 -0
- /package/dist/{__tests__ → src/__tests__}/brain-flow.test.d.ts +0 -0
- /package/dist/{__tests__ → src/__tests__}/confidence.test.d.ts +0 -0
- /package/dist/{__tests__ → src/__tests__}/confidence.test.js +0 -0
- /package/dist/{__tests__ → src/__tests__}/e2e/api-auth.test.d.ts +0 -0
- /package/dist/{__tests__ → src/__tests__}/e2e/api-auth.test.js +0 -0
- /package/dist/{__tests__ → src/__tests__}/e2e/api-health.test.d.ts +0 -0
- /package/dist/{__tests__ → src/__tests__}/e2e/api-health.test.js +0 -0
- /package/dist/{__tests__ → src/__tests__}/e2e/cli.test.d.ts +0 -0
- /package/dist/{__tests__ → src/__tests__}/e2e/cli.test.js +0 -0
- /package/dist/{__tests__ → src/__tests__}/e2e/config.d.ts +0 -0
- /package/dist/{__tests__ → src/__tests__}/e2e/config.js +0 -0
- /package/dist/{__tests__ → src/__tests__}/e2e/funnel.test.d.ts +0 -0
- /package/dist/{__tests__ → src/__tests__}/e2e/funnel.test.js +0 -0
- /package/dist/{__tests__ → src/__tests__}/e2e/mcp-tools.test.d.ts +0 -0
- /package/dist/{__tests__ → src/__tests__}/e2e/mcp-tools.test.js +0 -0
- /package/dist/{__tests__ → src/__tests__}/e2e/provisioning.test.d.ts +0 -0
- /package/dist/{__tests__ → src/__tests__}/e2e/provisioning.test.js +0 -0
- /package/dist/{__tests__ → src/__tests__}/handlers-unit.test.d.ts +0 -0
- /package/dist/{__tests__ → src/__tests__}/namespace.test.d.ts +0 -0
- /package/dist/{__tests__ → src/__tests__}/namespace.test.js +0 -0
- /package/dist/{search.test.d.ts → src/__tests__/rerank.test.d.ts} +0 -0
- /package/dist/{auth.d.ts → src/auth.d.ts} +0 -0
- /package/dist/{auth.js → src/auth.js} +0 -0
- /package/dist/{ckg.d.ts → src/ckg.d.ts} +0 -0
- /package/dist/{ckg.js → src/ckg.js} +0 -0
- /package/dist/{confidence.d.ts → src/confidence.d.ts} +0 -0
- /package/dist/{confidence.js → src/confidence.js} +0 -0
- /package/dist/{embeddings.d.ts → src/embeddings.d.ts} +0 -0
- /package/dist/{embeddings.js → src/embeddings.js} +0 -0
- /package/dist/{handlers → src/handlers}/advanced.d.ts +0 -0
- /package/dist/{handlers → src/handlers}/advanced.js +0 -0
- /package/dist/{handlers → src/handlers}/brain.d.ts +0 -0
- /package/dist/{handlers → src/handlers}/cache.d.ts +0 -0
- /package/dist/{handlers → src/handlers}/cache.js +0 -0
- /package/dist/{handlers → src/handlers}/context.d.ts +0 -0
- /package/dist/{handlers → src/handlers}/instances.d.ts +0 -0
- /package/dist/{handlers → src/handlers}/instances.js +0 -0
- /package/dist/{handlers → src/handlers}/roadmap.d.ts +0 -0
- /package/dist/{handlers → src/handlers}/syndicate.d.ts +0 -0
- /package/dist/{handlers → src/handlers}/syndicate.js +0 -0
- /package/dist/{handlers → src/handlers}/team.d.ts +0 -0
- /package/dist/{index.d.ts → src/index.d.ts} +0 -0
- /package/dist/{namespace.d.ts → src/namespace.d.ts} +0 -0
- /package/dist/{namespace.js → src/namespace.js} +0 -0
- /package/dist/{search.d.ts → src/search.d.ts} +0 -0
- /package/dist/{search.js → src/search.js} +0 -0
- /package/dist/{search.test.js → src/search.test.js} +0 -0
- /package/dist/{utils.d.ts → src/utils.d.ts} +0 -0
- /package/dist/{utils.js → src/utils.js} +0 -0
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Admin endpoints — Basic Auth protected, no JWT required.
|
|
3
|
+
Mount at /admin (no /api/v1 prefix).
|
|
4
|
+
"""
|
|
5
|
+
import os
|
|
6
|
+
import secrets
|
|
7
|
+
from typing import Annotated
|
|
8
|
+
from fastapi import APIRouter, Depends, HTTPException
|
|
9
|
+
from fastapi.responses import HTMLResponse
|
|
10
|
+
from fastapi.security import HTTPBasic, HTTPBasicCredentials
|
|
11
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
12
|
+
from sqlalchemy import text
|
|
13
|
+
from app.db.database import get_db
|
|
14
|
+
from app.config import get_settings
|
|
15
|
+
|
|
16
|
+
router = APIRouter(prefix="/admin", tags=["admin"])
|
|
17
|
+
security = HTTPBasic()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _check_auth(credentials: Annotated[HTTPBasicCredentials, Depends(security)]) -> None:
|
|
21
|
+
s = get_settings()
|
|
22
|
+
ok_user = secrets.compare_digest(credentials.username.encode(), s.admin_user.encode())
|
|
23
|
+
ok_pass = secrets.compare_digest(credentials.password.encode(), s.admin_password.encode())
|
|
24
|
+
if not (ok_user and ok_pass):
|
|
25
|
+
raise HTTPException(status_code=401, headers={"WWW-Authenticate": "Basic"})
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@router.get("", response_class=HTMLResponse, include_in_schema=False,
|
|
29
|
+
dependencies=[Depends(_check_auth)])
|
|
30
|
+
async def admin_dashboard():
|
|
31
|
+
html = """<!DOCTYPE html>
|
|
32
|
+
<html lang="de">
|
|
33
|
+
<head>
|
|
34
|
+
<meta charset="UTF-8">
|
|
35
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
36
|
+
<title>TCO Admin</title>
|
|
37
|
+
<style>
|
|
38
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
39
|
+
body { font-family: system-ui, sans-serif; background: #0f0f1a; color: #ccc; }
|
|
40
|
+
#login { display: flex; flex-direction: column; gap: 12px; max-width: 320px; margin: 15vh auto; padding: 32px; background: #1a1a2e; border-radius: 16px; }
|
|
41
|
+
#login h1 { color: #fff; font-size: 20px; }
|
|
42
|
+
input { padding: 10px 14px; border-radius: 8px; border: 1px solid #2a2a4a; background: #0f0f1a; color: #fff; font-size: 14px; }
|
|
43
|
+
button { padding: 10px; background: #4f46e5; color: #fff; border: none; border-radius: 8px; cursor: pointer; font-weight: 700; }
|
|
44
|
+
button:hover { background: #4338ca; }
|
|
45
|
+
#dash { display: none; padding: 24px; max-width: 1100px; margin: 0 auto; }
|
|
46
|
+
h2 { color: #fff; font-size: 22px; margin-bottom: 20px; }
|
|
47
|
+
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 16px; margin-bottom: 32px; }
|
|
48
|
+
.card { background: #1a1a2e; border-radius: 12px; padding: 20px; text-align: center; }
|
|
49
|
+
.card .n { font-size: 36px; font-weight: 800; color: #4f46e5; }
|
|
50
|
+
.card .l { font-size: 12px; color: #6666aa; margin-top: 4px; }
|
|
51
|
+
.section { background: #1a1a2e; border-radius: 12px; padding: 20px; margin-bottom: 24px; }
|
|
52
|
+
.section h3 { color: #fff; margin-bottom: 14px; font-size: 15px; }
|
|
53
|
+
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
|
54
|
+
th { text-align: left; color: #6666aa; padding: 6px 8px; border-bottom: 1px solid #2a2a4a; font-weight: 600; }
|
|
55
|
+
td { padding: 6px 8px; border-bottom: 1px solid #1a1a2e; color: #ccc; max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
56
|
+
.badge { display: inline-block; padding: 2px 8px; border-radius: 6px; font-size: 11px; font-weight: 700; }
|
|
57
|
+
.badge-pro { background: #4f46e522; color: #818cf8; }
|
|
58
|
+
.badge-free { background: #ffffff11; color: #6666aa; }
|
|
59
|
+
.top-bar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 28px; }
|
|
60
|
+
.top-bar h2 { margin: 0; }
|
|
61
|
+
#logout { background: transparent; color: #6666aa; font-size: 13px; padding: 6px 12px; border: 1px solid #2a2a4a; }
|
|
62
|
+
#err { color: #ff8888; font-size: 13px; }
|
|
63
|
+
#plan-form { display: flex; gap: 8px; margin-top: 6px; flex-wrap: wrap; }
|
|
64
|
+
#plan-form input { flex: 1; min-width: 120px; }
|
|
65
|
+
#plan-form select { padding: 10px; background: #0f0f1a; border: 1px solid #2a2a4a; color: #fff; border-radius: 8px; }
|
|
66
|
+
</style>
|
|
67
|
+
</head>
|
|
68
|
+
<body>
|
|
69
|
+
<div id="login">
|
|
70
|
+
<h1>✈️ TCO Admin</h1>
|
|
71
|
+
<input id="u" placeholder="Username" autocomplete="username">
|
|
72
|
+
<input id="p" type="password" placeholder="Password" autocomplete="current-password">
|
|
73
|
+
<button onclick="doLogin()">Login</button>
|
|
74
|
+
<span id="err"></span>
|
|
75
|
+
</div>
|
|
76
|
+
<div id="dash">
|
|
77
|
+
<div class="top-bar"><h2>✈️ TCO Admin</h2><button id="logout" onclick="doLogout()">Logout</button></div>
|
|
78
|
+
<div class="cards" id="cards"></div>
|
|
79
|
+
<div class="section">
|
|
80
|
+
<h3>Plan-Upgrade</h3>
|
|
81
|
+
<div id="plan-form">
|
|
82
|
+
<input id="pu-uid" placeholder="User ID (Keycloak sub)">
|
|
83
|
+
<select id="pu-plan"><option value="pro">pro</option><option value="free">free</option></select>
|
|
84
|
+
<input id="pu-days" placeholder="Tage (leer = unbegrenzt)" type="number" style="max-width:180px">
|
|
85
|
+
<button onclick="upgradePlan()">Setzen</button>
|
|
86
|
+
</div>
|
|
87
|
+
</div>
|
|
88
|
+
<div class="section"><h3>Top Events</h3><table id="t-events"><thead><tr><th>Event</th><th>Count</th><th>Zuletzt</th></tr></thead><tbody></tbody></table></div>
|
|
89
|
+
<div class="section"><h3>Benutzer</h3><table id="t-users"><thead><tr><th>ID</th><th>Plan</th><th>Events</th><th>Seit</th></tr></thead><tbody></tbody></table></div>
|
|
90
|
+
<div class="section"><h3>Waitlist</h3><table id="t-wl"><thead><tr><th>Email</th><th>Source</th><th>Datum</th></tr></thead><tbody></tbody></table></div>
|
|
91
|
+
</div>
|
|
92
|
+
<script>
|
|
93
|
+
let _creds = '';
|
|
94
|
+
function b64(u,p){ return btoa(u+':'+p); }
|
|
95
|
+
function fmt(s){ return s ? new Date(s).toLocaleString('de') : '-'; }
|
|
96
|
+
async function api(path, opts={}){
|
|
97
|
+
const r = await fetch(path, { headers: { Authorization: 'Basic '+_creds }, ...opts });
|
|
98
|
+
if (!r.ok) throw new Error(r.status);
|
|
99
|
+
return r.json();
|
|
100
|
+
}
|
|
101
|
+
async function doLogin(){
|
|
102
|
+
_creds = b64(document.getElementById('u').value, document.getElementById('p').value);
|
|
103
|
+
try {
|
|
104
|
+
const h = await api('/admin/health');
|
|
105
|
+
document.getElementById('login').style.display='none';
|
|
106
|
+
document.getElementById('dash').style.display='block';
|
|
107
|
+
load(h);
|
|
108
|
+
} catch { document.getElementById('err').textContent='Falsche Zugangsdaten'; _creds=''; }
|
|
109
|
+
}
|
|
110
|
+
function doLogout(){ _creds=''; document.getElementById('dash').style.display='none'; document.getElementById('login').style.display='flex'; }
|
|
111
|
+
async function load(health){
|
|
112
|
+
const c = document.getElementById('cards');
|
|
113
|
+
c.innerHTML = [
|
|
114
|
+
[health.events_total,'Events gesamt'],
|
|
115
|
+
[health.waitlist_total,'Waitlist'],
|
|
116
|
+
[health.users_total,'Benutzer'],
|
|
117
|
+
[health.pro_users,'Pro-User'],
|
|
118
|
+
].map(([n,l])=>`<div class="card"><div class="n">${n??0}</div><div class="l">${l}</div></div>`).join('');
|
|
119
|
+
const [ev, users, wl] = await Promise.all([api('/admin/events/summary'), api('/admin/users'), api('/admin/waitlist')]);
|
|
120
|
+
const tb = (id, rows) => { document.querySelector('#'+id+' tbody').innerHTML = rows; };
|
|
121
|
+
tb('t-events', ev.events.map(e=>`<tr><td>${e.event_name}</td><td>${e.count}</td><td>${fmt(e.last_seen)}</td></tr>`).join(''));
|
|
122
|
+
tb('t-users', users.users.map(u=>`<tr><td title="${u.id}">${u.id.slice(0,12)}…</td><td><span class="badge badge-${u.plan}">${u.plan}</span></td><td>${u.event_count}</td><td>${fmt(u.created_at)}</td></tr>`).join(''));
|
|
123
|
+
tb('t-wl', wl.signups.map(s=>`<tr><td>${s.email}</td><td>${s.source||'-'}</td><td>${fmt(s.created_at)}</td></tr>`).join(''));
|
|
124
|
+
}
|
|
125
|
+
async function upgradePlan(){
|
|
126
|
+
const uid = document.getElementById('pu-uid').value.trim();
|
|
127
|
+
const plan = document.getElementById('pu-plan').value;
|
|
128
|
+
const days = document.getElementById('pu-days').value;
|
|
129
|
+
if (!uid) { alert('User ID fehlt'); return; }
|
|
130
|
+
const q = days ? `?plan=${plan}&expires_days=${days}` : `?plan=${plan}`;
|
|
131
|
+
try { await api('/admin/users/'+uid+'/plan'+q, {method:'PATCH'}); alert('Plan gesetzt'); }
|
|
132
|
+
catch { alert('Fehler'); }
|
|
133
|
+
}
|
|
134
|
+
document.getElementById('p').addEventListener('keydown', e => { if(e.key==='Enter') doLogin(); });
|
|
135
|
+
</script>
|
|
136
|
+
</body>
|
|
137
|
+
</html>"""
|
|
138
|
+
return HTMLResponse(html)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@router.get("/events/summary", dependencies=[Depends(_check_auth)])
|
|
142
|
+
async def events_summary(db: Annotated[AsyncSession, Depends(get_db)]):
|
|
143
|
+
rows = await db.execute(text("""
|
|
144
|
+
SELECT event_name, COUNT(*) as count,
|
|
145
|
+
MAX(created_at) as last_seen
|
|
146
|
+
FROM events
|
|
147
|
+
GROUP BY event_name
|
|
148
|
+
ORDER BY count DESC
|
|
149
|
+
LIMIT 50
|
|
150
|
+
"""))
|
|
151
|
+
return {"events": [dict(r._mapping) for r in rows.fetchall()]}
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@router.get("/events/recent", dependencies=[Depends(_check_auth)])
|
|
155
|
+
async def events_recent(db: Annotated[AsyncSession, Depends(get_db)], limit: int = 50):
|
|
156
|
+
rows = await db.execute(text("""
|
|
157
|
+
SELECT id, user_id, event_name, properties, platform, app_version, created_at
|
|
158
|
+
FROM events ORDER BY created_at DESC LIMIT :limit
|
|
159
|
+
"""), {"limit": min(limit, 200)})
|
|
160
|
+
return {"events": [dict(r._mapping) for r in rows.fetchall()]}
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@router.get("/waitlist", dependencies=[Depends(_check_auth)])
|
|
164
|
+
async def waitlist(db: Annotated[AsyncSession, Depends(get_db)]):
|
|
165
|
+
rows = await db.execute(text(
|
|
166
|
+
"SELECT id, email, source, created_at FROM waitlist ORDER BY created_at DESC"
|
|
167
|
+
))
|
|
168
|
+
return {"signups": [dict(r._mapping) for r in rows.fetchall()]}
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@router.get("/health", dependencies=[Depends(_check_auth)])
|
|
172
|
+
async def admin_health(db: Annotated[AsyncSession, Depends(get_db)]):
|
|
173
|
+
event_count = await db.execute(text("SELECT COUNT(*) FROM events"))
|
|
174
|
+
waitlist_count = await db.execute(text("SELECT COUNT(*) FROM waitlist"))
|
|
175
|
+
user_count = await db.execute(text("SELECT COUNT(*) FROM users"))
|
|
176
|
+
from datetime import datetime, timezone
|
|
177
|
+
pro_count = await db.execute(
|
|
178
|
+
text("SELECT COUNT(*) FROM users WHERE plan != 'free' AND (plan_expires_at IS NULL OR plan_expires_at > :now)"),
|
|
179
|
+
{"now": datetime.now(timezone.utc)},
|
|
180
|
+
)
|
|
181
|
+
return {
|
|
182
|
+
"events_total": event_count.scalar(),
|
|
183
|
+
"waitlist_total": waitlist_count.scalar(),
|
|
184
|
+
"users_total": user_count.scalar(),
|
|
185
|
+
"pro_users": pro_count.scalar(),
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@router.get("/users", dependencies=[Depends(_check_auth)])
|
|
190
|
+
async def list_users(db: Annotated[AsyncSession, Depends(get_db)], limit: int = 100):
|
|
191
|
+
rows = await db.execute(text("""
|
|
192
|
+
SELECT u.id, u.email, u.plan, u.plan_expires_at, u.created_at,
|
|
193
|
+
COUNT(e.id) AS event_count
|
|
194
|
+
FROM users u
|
|
195
|
+
LEFT JOIN events e ON e.user_id = u.id
|
|
196
|
+
GROUP BY u.id, u.email, u.plan, u.plan_expires_at, u.created_at
|
|
197
|
+
ORDER BY u.created_at DESC
|
|
198
|
+
LIMIT :limit
|
|
199
|
+
"""), {"limit": min(limit, 500)})
|
|
200
|
+
return {"users": [dict(r._mapping) for r in rows.fetchall()]}
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
@router.patch("/users/{uid}/plan", dependencies=[Depends(_check_auth)])
|
|
204
|
+
async def set_user_plan(
|
|
205
|
+
uid: str,
|
|
206
|
+
plan: str,
|
|
207
|
+
db: Annotated[AsyncSession, Depends(get_db)],
|
|
208
|
+
expires_days: int | None = None,
|
|
209
|
+
):
|
|
210
|
+
if plan not in ("free", "pro"):
|
|
211
|
+
raise HTTPException(status_code=400, detail="plan must be 'free' or 'pro'")
|
|
212
|
+
from datetime import datetime, timedelta, timezone
|
|
213
|
+
now = datetime.now(timezone.utc)
|
|
214
|
+
expires_at = (now + timedelta(days=expires_days)) if expires_days else None
|
|
215
|
+
await db.execute(
|
|
216
|
+
text("""
|
|
217
|
+
INSERT INTO users (id, plan, plan_expires_at, updated_at)
|
|
218
|
+
VALUES (:uid, :plan, :expires_at, :now)
|
|
219
|
+
ON CONFLICT (id) DO UPDATE
|
|
220
|
+
SET plan = EXCLUDED.plan,
|
|
221
|
+
plan_expires_at = EXCLUDED.plan_expires_at,
|
|
222
|
+
updated_at = EXCLUDED.updated_at
|
|
223
|
+
"""),
|
|
224
|
+
{"uid": uid, "plan": plan, "expires_at": expires_at, "now": now},
|
|
225
|
+
)
|
|
226
|
+
await db.commit()
|
|
227
|
+
return {"ok": True, "uid": uid, "plan": plan}
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
@router.post("/drip/run", dependencies=[Depends(_check_auth)])
|
|
231
|
+
async def run_drip_sequence(
|
|
232
|
+
db: Annotated[AsyncSession, Depends(get_db)],
|
|
233
|
+
dry_run: bool = False,
|
|
234
|
+
):
|
|
235
|
+
from app.services.drip import run_drip
|
|
236
|
+
result = await run_drip(db, dry_run=dry_run)
|
|
237
|
+
return result
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Cache stats endpoint — exposes Cachly Redis metrics to the frontend and MCP tools."""
|
|
2
|
+
from fastapi import APIRouter, Depends
|
|
3
|
+
from typing import Annotated
|
|
4
|
+
from app.auth.keycloak import user_id
|
|
5
|
+
from app.services.cache import cache_stats
|
|
6
|
+
|
|
7
|
+
router = APIRouter(prefix="/cache", tags=["cache"])
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@router.get("/stats")
|
|
11
|
+
async def get_cache_stats(_uid: Annotated[str, Depends(user_id)]):
|
|
12
|
+
"""Return Cachly Redis cache statistics for the current deployment."""
|
|
13
|
+
return await cache_stats()
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from typing import Annotated
|
|
2
|
+
from fastapi import APIRouter, Depends, Request
|
|
3
|
+
from pydantic import BaseModel
|
|
4
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
5
|
+
from app.auth.keycloak import user_id
|
|
6
|
+
from app.db.database import get_db
|
|
7
|
+
from app.services import telemetry
|
|
8
|
+
from app.limiter import limiter
|
|
9
|
+
|
|
10
|
+
router = APIRouter(prefix="/events", tags=["events"])
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class EventBody(BaseModel):
|
|
14
|
+
event_name: str
|
|
15
|
+
properties: dict | None = None
|
|
16
|
+
platform: str | None = None
|
|
17
|
+
app_version: str | None = None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@limiter.limit("30/minute")
|
|
21
|
+
@router.post("")
|
|
22
|
+
async def log_event(
|
|
23
|
+
request: Request,
|
|
24
|
+
body: EventBody,
|
|
25
|
+
uid: Annotated[str, Depends(user_id)],
|
|
26
|
+
db: Annotated[AsyncSession, Depends(get_db)],
|
|
27
|
+
):
|
|
28
|
+
await telemetry.track(db, uid, body.event_name, body.properties, body.platform, body.app_version)
|
|
29
|
+
return {"ok": True}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Export trip as PDF itinerary.
|
|
3
|
+
GET /api/v1/trips/{trip_id}/export/pdf
|
|
4
|
+
"""
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
from uuid import UUID
|
|
7
|
+
from fastapi import APIRouter, Depends, HTTPException
|
|
8
|
+
from fastapi.responses import StreamingResponse
|
|
9
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
10
|
+
from sqlalchemy import text
|
|
11
|
+
from io import BytesIO
|
|
12
|
+
from app.auth.keycloak import user_id
|
|
13
|
+
from app.db.database import get_db
|
|
14
|
+
|
|
15
|
+
router = APIRouter(tags=["export"])
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@router.get("/trips/{trip_id}/export/pdf")
|
|
19
|
+
async def export_trip_pdf(
|
|
20
|
+
trip_id: UUID,
|
|
21
|
+
uid: Annotated[str, Depends(user_id)],
|
|
22
|
+
db: Annotated[AsyncSession, Depends(get_db)],
|
|
23
|
+
):
|
|
24
|
+
trip_row = await db.execute(
|
|
25
|
+
text("SELECT * FROM trips WHERE id = :id AND user_id = :uid"),
|
|
26
|
+
{"id": str(trip_id), "uid": uid},
|
|
27
|
+
)
|
|
28
|
+
trip = trip_row.fetchone()
|
|
29
|
+
if not trip:
|
|
30
|
+
raise HTTPException(status_code=404, detail="Trip not found")
|
|
31
|
+
trip = dict(trip._mapping)
|
|
32
|
+
|
|
33
|
+
items_row = await db.execute(
|
|
34
|
+
text("SELECT * FROM trip_items WHERE trip_id = :id ORDER BY event_at ASC NULLS LAST"),
|
|
35
|
+
{"id": str(trip_id)},
|
|
36
|
+
)
|
|
37
|
+
items = [dict(r._mapping) for r in items_row.fetchall()]
|
|
38
|
+
|
|
39
|
+
from app.services import telemetry
|
|
40
|
+
await telemetry.track(db, uid, "pdf_export", {"trip_id": str(trip_id), "item_count": len(items)})
|
|
41
|
+
|
|
42
|
+
pdf_bytes = _build_pdf(trip, items)
|
|
43
|
+
filename = f"trip-{trip['name'].replace(' ', '_')[:40]}.pdf"
|
|
44
|
+
return StreamingResponse(
|
|
45
|
+
BytesIO(pdf_bytes),
|
|
46
|
+
media_type="application/pdf",
|
|
47
|
+
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _build_pdf(trip: dict, items: list[dict]) -> bytes:
|
|
52
|
+
from reportlab.lib.pagesizes import A4
|
|
53
|
+
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
|
|
54
|
+
from reportlab.lib.units import cm
|
|
55
|
+
from reportlab.lib import colors
|
|
56
|
+
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, HRFlowable
|
|
57
|
+
from reportlab.lib.enums import TA_LEFT
|
|
58
|
+
|
|
59
|
+
buf = BytesIO()
|
|
60
|
+
doc = SimpleDocTemplate(buf, pagesize=A4, leftMargin=2*cm, rightMargin=2*cm, topMargin=2*cm, bottomMargin=2*cm)
|
|
61
|
+
styles = getSampleStyleSheet()
|
|
62
|
+
|
|
63
|
+
PURPLE = colors.HexColor("#4f46e5")
|
|
64
|
+
GRAY = colors.HexColor("#666688")
|
|
65
|
+
|
|
66
|
+
title_style = ParagraphStyle("title", parent=styles["Heading1"], textColor=PURPLE, fontSize=22, spaceAfter=4)
|
|
67
|
+
sub_style = ParagraphStyle("sub", parent=styles["Normal"], textColor=GRAY, fontSize=10, spaceAfter=16)
|
|
68
|
+
h3_style = ParagraphStyle("h3", parent=styles["Heading3"], textColor=PURPLE, fontSize=12, spaceBefore=12, spaceAfter=4)
|
|
69
|
+
body_style = ParagraphStyle("body", parent=styles["Normal"], fontSize=9, leading=13)
|
|
70
|
+
label_style = ParagraphStyle("label", parent=styles["Normal"], fontSize=8, textColor=GRAY)
|
|
71
|
+
|
|
72
|
+
story = []
|
|
73
|
+
story.append(Paragraph(f"✈ {trip['name']}", title_style))
|
|
74
|
+
|
|
75
|
+
date_range = ""
|
|
76
|
+
if trip.get("start_date") and trip.get("end_date"):
|
|
77
|
+
date_range = f"{trip['start_date']} – {trip['end_date']}"
|
|
78
|
+
elif trip.get("start_date"):
|
|
79
|
+
date_range = str(trip["start_date"])
|
|
80
|
+
if date_range:
|
|
81
|
+
story.append(Paragraph(date_range, sub_style))
|
|
82
|
+
if trip.get("description"):
|
|
83
|
+
story.append(Paragraph(trip["description"], body_style))
|
|
84
|
+
story.append(Spacer(1, 0.4*cm))
|
|
85
|
+
story.append(HRFlowable(width="100%", color=PURPLE, thickness=1))
|
|
86
|
+
story.append(Spacer(1, 0.3*cm))
|
|
87
|
+
|
|
88
|
+
if not items:
|
|
89
|
+
story.append(Paragraph("Keine Einträge vorhanden.", body_style))
|
|
90
|
+
else:
|
|
91
|
+
TYPE_EMOJI = {"flight": "✈", "train": "🚆", "bus": "🚌", "hotel": "🏨",
|
|
92
|
+
"rental_car": "🚗", "activity": "🎯", "transfer": "🚕",
|
|
93
|
+
"document": "📄", "other": "📌"}
|
|
94
|
+
for item in items:
|
|
95
|
+
emoji = TYPE_EMOJI.get(item.get("type", "other"), "📌")
|
|
96
|
+
story.append(Paragraph(f"{emoji} {item.get('title', '–')}", h3_style))
|
|
97
|
+
|
|
98
|
+
meta = []
|
|
99
|
+
if item.get("event_at"):
|
|
100
|
+
meta.append(["Datum/Zeit", str(item["event_at"])[:16]])
|
|
101
|
+
if item.get("provider"):
|
|
102
|
+
meta.append(["Anbieter", item["provider"]])
|
|
103
|
+
if item.get("booking_ref"):
|
|
104
|
+
meta.append(["Buchungs-Nr.", item["booking_ref"]])
|
|
105
|
+
|
|
106
|
+
if meta:
|
|
107
|
+
tbl = Table(meta, colWidths=[3.5*cm, 12*cm])
|
|
108
|
+
tbl.setStyle(TableStyle([
|
|
109
|
+
("TEXTCOLOR", (0,0), (0,-1), GRAY),
|
|
110
|
+
("FONTSIZE", (0,0), (-1,-1), 8),
|
|
111
|
+
("BOTTOMPADDING", (0,0), (-1,-1), 2),
|
|
112
|
+
("TOPPADDING", (0,0), (-1,-1), 2),
|
|
113
|
+
]))
|
|
114
|
+
story.append(tbl)
|
|
115
|
+
story.append(Spacer(1, 0.2*cm))
|
|
116
|
+
|
|
117
|
+
story.append(Spacer(1, 1*cm))
|
|
118
|
+
story.append(Paragraph("Erstellt mit Travel Chaos Organizer · tco.app", label_style))
|
|
119
|
+
|
|
120
|
+
doc.build(story)
|
|
121
|
+
buf.seek(0)
|
|
122
|
+
return buf.read()
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
from typing import Annotated
|
|
4
|
+
from uuid import UUID
|
|
5
|
+
from fastapi import APIRouter, Depends, HTTPException, status
|
|
6
|
+
from sqlalchemy import text
|
|
7
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
8
|
+
from app.auth.keycloak import user_id
|
|
9
|
+
from app.db.database import get_db
|
|
10
|
+
from app.models.schemas import InboxItemOut, InboxAssign
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _safe_dt(val: str | None) -> datetime | None:
|
|
14
|
+
"""Convert an ISO 8601 string to datetime, returning None on failure."""
|
|
15
|
+
if not val or not isinstance(val, str):
|
|
16
|
+
return None
|
|
17
|
+
try:
|
|
18
|
+
return datetime.fromisoformat(val.replace("Z", "+00:00"))
|
|
19
|
+
except ValueError:
|
|
20
|
+
return None
|
|
21
|
+
|
|
22
|
+
router = APIRouter(prefix="/inbox", tags=["inbox"])
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _parse_inbox_row(row: dict) -> dict:
|
|
26
|
+
if isinstance(row.get("parsed_data"), str):
|
|
27
|
+
try:
|
|
28
|
+
row["parsed_data"] = json.loads(row["parsed_data"])
|
|
29
|
+
except (json.JSONDecodeError, TypeError):
|
|
30
|
+
row["parsed_data"] = None
|
|
31
|
+
return row
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@router.get("", response_model=list[InboxItemOut])
|
|
35
|
+
async def list_inbox(
|
|
36
|
+
uid: Annotated[str, Depends(user_id)],
|
|
37
|
+
db: Annotated[AsyncSession, Depends(get_db)],
|
|
38
|
+
status_filter: str = "pending",
|
|
39
|
+
):
|
|
40
|
+
result = await db.execute(
|
|
41
|
+
text("SELECT * FROM chaos_inbox WHERE user_id = :uid AND status = :status ORDER BY created_at DESC"),
|
|
42
|
+
{"uid": uid, "status": status_filter},
|
|
43
|
+
)
|
|
44
|
+
return [_parse_inbox_row(dict(r._mapping)) for r in result.fetchall()]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@router.post("/{inbox_id}/assign", response_model=dict)
|
|
48
|
+
async def assign_to_trip(
|
|
49
|
+
inbox_id: UUID,
|
|
50
|
+
body: InboxAssign,
|
|
51
|
+
uid: Annotated[str, Depends(user_id)],
|
|
52
|
+
db: Annotated[AsyncSession, Depends(get_db)],
|
|
53
|
+
):
|
|
54
|
+
iid = str(inbox_id)
|
|
55
|
+
row = await db.execute(
|
|
56
|
+
text("SELECT * FROM chaos_inbox WHERE id = :id AND user_id = :uid"),
|
|
57
|
+
{"id": iid, "uid": uid},
|
|
58
|
+
)
|
|
59
|
+
inbox_item = row.fetchone()
|
|
60
|
+
if not inbox_item:
|
|
61
|
+
raise HTTPException(status_code=404, detail="Inbox item not found")
|
|
62
|
+
|
|
63
|
+
# Verify the target trip belongs to this user (prevents assigning to other users' trips)
|
|
64
|
+
trip_check = await db.execute(
|
|
65
|
+
text("SELECT id FROM trips WHERE id = :tid AND user_id = :uid"),
|
|
66
|
+
{"tid": str(body.trip_id), "uid": uid},
|
|
67
|
+
)
|
|
68
|
+
if not trip_check.fetchone():
|
|
69
|
+
raise HTTPException(status_code=403, detail="Trip not found or access denied")
|
|
70
|
+
|
|
71
|
+
item = dict(inbox_item._mapping)
|
|
72
|
+
raw_pd = item.get("parsed_data") or {}
|
|
73
|
+
parsed = json.loads(raw_pd) if isinstance(raw_pd, str) else raw_pd
|
|
74
|
+
title = parsed.get("title", "Imported item") if isinstance(parsed, dict) else "Imported item"
|
|
75
|
+
|
|
76
|
+
result = await db.execute(
|
|
77
|
+
text("""
|
|
78
|
+
INSERT INTO trip_items (trip_id, user_id, type, title, raw_text, parsed_data,
|
|
79
|
+
event_at, event_end_at, booking_ref, provider)
|
|
80
|
+
VALUES (:trip_id, :uid, :type, :title, :raw, :pd,
|
|
81
|
+
:event_at, :event_end_at, :booking_ref, :provider)
|
|
82
|
+
RETURNING id, title, event_at
|
|
83
|
+
"""),
|
|
84
|
+
{
|
|
85
|
+
"trip_id": str(body.trip_id), "uid": uid, "type": body.type, "title": title,
|
|
86
|
+
"raw": item.get("raw_content"), "pd": json.dumps(parsed),
|
|
87
|
+
"event_at": _safe_dt(parsed.get("event_at") if isinstance(parsed, dict) else None),
|
|
88
|
+
"event_end_at": _safe_dt(parsed.get("event_end_at") if isinstance(parsed, dict) else None),
|
|
89
|
+
"booking_ref": parsed.get("booking_ref") if isinstance(parsed, dict) else None,
|
|
90
|
+
"provider": parsed.get("provider") if isinstance(parsed, dict) else None,
|
|
91
|
+
},
|
|
92
|
+
)
|
|
93
|
+
row = result.fetchone()
|
|
94
|
+
trip_item_id = row[0]
|
|
95
|
+
item_title = row[1]
|
|
96
|
+
event_at = row[2].isoformat() if row[2] else None
|
|
97
|
+
|
|
98
|
+
await db.execute(
|
|
99
|
+
text("UPDATE chaos_inbox SET status = 'assigned', updated_at = CURRENT_TIMESTAMP WHERE id = :id"),
|
|
100
|
+
{"id": iid},
|
|
101
|
+
)
|
|
102
|
+
await db.execute(
|
|
103
|
+
text("UPDATE attachments SET trip_item_id = :tid, inbox_id = NULL WHERE inbox_id = :iid"),
|
|
104
|
+
{"tid": trip_item_id, "iid": iid},
|
|
105
|
+
)
|
|
106
|
+
await db.commit()
|
|
107
|
+
return {"trip_item_id": str(trip_item_id), "title": item_title, "event_at": event_at}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@router.delete("/{inbox_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
111
|
+
async def reject_inbox_item(
|
|
112
|
+
inbox_id: UUID,
|
|
113
|
+
uid: Annotated[str, Depends(user_id)],
|
|
114
|
+
db: Annotated[AsyncSession, Depends(get_db)],
|
|
115
|
+
):
|
|
116
|
+
result = await db.execute(
|
|
117
|
+
text("UPDATE chaos_inbox SET status = 'rejected', updated_at = CURRENT_TIMESTAMP WHERE id = :id AND user_id = :uid"),
|
|
118
|
+
{"id": str(inbox_id), "uid": uid},
|
|
119
|
+
)
|
|
120
|
+
await db.commit()
|
|
121
|
+
if result.rowcount == 0:
|
|
122
|
+
raise HTTPException(status_code=404, detail="Inbox item not found")
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from typing import Annotated
|
|
3
|
+
from uuid import UUID
|
|
4
|
+
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
|
5
|
+
from sqlalchemy import text
|
|
6
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
7
|
+
from app.auth.keycloak import user_id
|
|
8
|
+
from app.db.database import get_db
|
|
9
|
+
from app.limiter import limiter
|
|
10
|
+
from app.models.schemas import TripItemCreate, TripItemOut, TripItemUpdate
|
|
11
|
+
|
|
12
|
+
router = APIRouter(prefix="/trips/{trip_id}/items", tags=["items"])
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
async def _assert_trip_owner(trip_id: str, uid: str, db: AsyncSession) -> None:
|
|
16
|
+
result = await db.execute(
|
|
17
|
+
text("SELECT id FROM trips WHERE id = :id AND user_id = :uid"),
|
|
18
|
+
{"id": trip_id, "uid": uid},
|
|
19
|
+
)
|
|
20
|
+
if not result.fetchone():
|
|
21
|
+
raise HTTPException(status_code=404, detail="Trip not found")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _parse_item_row(row: dict) -> dict:
|
|
25
|
+
"""Deserialize parsed_data from JSON string (SQLite) or dict (PostgreSQL)."""
|
|
26
|
+
if isinstance(row.get("parsed_data"), str):
|
|
27
|
+
try:
|
|
28
|
+
row["parsed_data"] = json.loads(row["parsed_data"])
|
|
29
|
+
except (json.JSONDecodeError, TypeError):
|
|
30
|
+
row["parsed_data"] = None
|
|
31
|
+
return row
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@router.get("", response_model=list[TripItemOut])
|
|
35
|
+
async def list_items(
|
|
36
|
+
trip_id: UUID,
|
|
37
|
+
uid: Annotated[str, Depends(user_id)],
|
|
38
|
+
db: Annotated[AsyncSession, Depends(get_db)],
|
|
39
|
+
):
|
|
40
|
+
tid = str(trip_id)
|
|
41
|
+
await _assert_trip_owner(tid, uid, db)
|
|
42
|
+
result = await db.execute(
|
|
43
|
+
text("SELECT * FROM trip_items WHERE trip_id = :tid ORDER BY event_at ASC"),
|
|
44
|
+
{"tid": tid},
|
|
45
|
+
)
|
|
46
|
+
return [_parse_item_row(dict(r._mapping)) for r in result.fetchall()]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@limiter.limit("30/minute")
|
|
50
|
+
@router.post("", response_model=TripItemOut, status_code=status.HTTP_201_CREATED)
|
|
51
|
+
async def create_item(
|
|
52
|
+
request: Request,
|
|
53
|
+
trip_id: UUID,
|
|
54
|
+
body: TripItemCreate,
|
|
55
|
+
uid: Annotated[str, Depends(user_id)],
|
|
56
|
+
db: Annotated[AsyncSession, Depends(get_db)],
|
|
57
|
+
):
|
|
58
|
+
tid = str(trip_id)
|
|
59
|
+
await _assert_trip_owner(tid, uid, db)
|
|
60
|
+
pd_json = json.dumps(body.parsed_data) if body.parsed_data else None
|
|
61
|
+
result = await db.execute(
|
|
62
|
+
text("""
|
|
63
|
+
INSERT INTO trip_items
|
|
64
|
+
(trip_id, user_id, type, title, raw_text, parsed_data,
|
|
65
|
+
event_at, event_end_at, booking_ref, provider)
|
|
66
|
+
VALUES
|
|
67
|
+
(:trip_id, :uid, :type, :title, :raw_text, :parsed_data,
|
|
68
|
+
:event_at, :event_end_at, :booking_ref, :provider)
|
|
69
|
+
RETURNING *
|
|
70
|
+
"""),
|
|
71
|
+
{
|
|
72
|
+
"trip_id": tid, "uid": uid, "type": body.type, "title": body.title,
|
|
73
|
+
"raw_text": body.raw_text, "parsed_data": pd_json,
|
|
74
|
+
"event_at": body.event_at, "event_end_at": body.event_end_at,
|
|
75
|
+
"booking_ref": body.booking_ref, "provider": body.provider,
|
|
76
|
+
},
|
|
77
|
+
)
|
|
78
|
+
await db.commit()
|
|
79
|
+
return _parse_item_row(dict(result.fetchone()._mapping))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@limiter.limit("30/minute")
|
|
83
|
+
@router.patch("/{item_id}", response_model=TripItemOut)
|
|
84
|
+
async def update_item(
|
|
85
|
+
request: Request,
|
|
86
|
+
trip_id: UUID,
|
|
87
|
+
item_id: UUID,
|
|
88
|
+
body: TripItemUpdate,
|
|
89
|
+
uid: Annotated[str, Depends(user_id)],
|
|
90
|
+
db: Annotated[AsyncSession, Depends(get_db)],
|
|
91
|
+
):
|
|
92
|
+
tid, iid = str(trip_id), str(item_id)
|
|
93
|
+
await _assert_trip_owner(tid, uid, db)
|
|
94
|
+
updates = body.model_dump(exclude_none=True)
|
|
95
|
+
if not updates:
|
|
96
|
+
raise HTTPException(status_code=400, detail="Nothing to update")
|
|
97
|
+
if "parsed_data" in updates:
|
|
98
|
+
updates["parsed_data"] = json.dumps(updates["parsed_data"])
|
|
99
|
+
|
|
100
|
+
set_clauses = ", ".join(f"{k} = :{k}" for k in updates)
|
|
101
|
+
updates.update({"item_id": iid, "trip_id": tid})
|
|
102
|
+
result = await db.execute(
|
|
103
|
+
text(f"UPDATE trip_items SET {set_clauses}, updated_at = CURRENT_TIMESTAMP WHERE id = :item_id AND trip_id = :trip_id RETURNING *"),
|
|
104
|
+
updates,
|
|
105
|
+
)
|
|
106
|
+
row = result.fetchone()
|
|
107
|
+
if not row:
|
|
108
|
+
raise HTTPException(status_code=404, detail="Item not found")
|
|
109
|
+
await db.commit()
|
|
110
|
+
return _parse_item_row(dict(row._mapping))
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@limiter.limit("30/minute")
|
|
114
|
+
@router.delete("/{item_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
115
|
+
async def delete_item(
|
|
116
|
+
request: Request,
|
|
117
|
+
trip_id: UUID,
|
|
118
|
+
item_id: UUID,
|
|
119
|
+
uid: Annotated[str, Depends(user_id)],
|
|
120
|
+
db: Annotated[AsyncSession, Depends(get_db)],
|
|
121
|
+
):
|
|
122
|
+
tid, iid = str(trip_id), str(item_id)
|
|
123
|
+
await _assert_trip_owner(tid, uid, db)
|
|
124
|
+
result = await db.execute(
|
|
125
|
+
text("DELETE FROM trip_items WHERE id = :item_id AND trip_id = :trip_id"),
|
|
126
|
+
{"item_id": iid, "trip_id": tid},
|
|
127
|
+
)
|
|
128
|
+
await db.commit()
|
|
129
|
+
if result.rowcount == 0:
|
|
130
|
+
raise HTTPException(status_code=404, detail="Item not found")
|