@cachly-dev/mcp-server 0.10.56 → 0.10.58
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/{handlers → src/handlers}/advanced.d.ts +1 -1
- package/dist/{handlers → src/handlers}/advanced.js +1 -2
- package/dist/src/handlers/advanced.js.map +1 -0
- package/dist/{handlers → src/handlers}/brain.js +361 -191
- 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.d.ts +1 -1
- package/dist/{handlers → src/handlers}/roadmap.js +8 -4
- 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 +58 -7
- package/dist/src/handlers/team.js.map +1 -0
- package/dist/{index.js → src/index.js} +207 -429
- 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/{search.js → src/search.js} +2 -2
- package/dist/src/search.js.map +1 -0
- package/dist/src/search.test.d.ts +1 -0
- package/dist/{search.test.js → src/search.test.js} +1 -1
- 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/eslint.config.js +1 -1
- 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 +27 -16
- package/src/__tests__/auth.test.ts +1 -1
- package/src/__tests__/brain-flow.test.ts +175 -1
- package/src/__tests__/e2e/api-health.test.ts +1 -1
- 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/advanced.ts +2 -6
- package/src/handlers/brain.ts +371 -194
- package/src/handlers/cache.ts +1 -2
- package/src/handlers/context.ts +10 -6
- package/src/handlers/fedbrain.ts +21 -12
- package/src/handlers/roadmap.ts +8 -4
- package/src/handlers/syndicate.ts +7 -4
- package/src/handlers/tco.ts +345 -0
- package/src/handlers/team.ts +70 -10
- package/src/index.ts +193 -419
- package/src/notifier.ts +5 -0
- package/src/rerank.ts +148 -0
- package/src/search.test.ts +1 -1
- package/src/search.ts +2 -2
- package/src/tools.ts +210 -0
- package/tsconfig.json +2 -3
- package/vitest.config.ts +2 -0
- 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}/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}/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/{utils.d.ts → src/utils.d.ts} +0 -0
- /package/dist/{utils.js → src/utils.js} +0 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Mail import endpoint — paste raw email text or forward as plain text.
|
|
3
|
+
Gmail / Outlook share-to-app flows send the body as plain text;
|
|
4
|
+
we strip headers and run it through the same Ollama parsing pipeline.
|
|
5
|
+
Results are Cachly-cached by content hash when CACHLY_REDIS_URL is configured.
|
|
6
|
+
"""
|
|
7
|
+
import json
|
|
8
|
+
import re
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
from typing import Annotated
|
|
11
|
+
from uuid import UUID
|
|
12
|
+
|
|
13
|
+
from fastapi import APIRouter, Body, Depends, Query
|
|
14
|
+
from pydantic import ValidationError
|
|
15
|
+
from sqlalchemy import text
|
|
16
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
17
|
+
|
|
18
|
+
from app.auth.keycloak import user_id
|
|
19
|
+
from app.config import get_settings
|
|
20
|
+
from app.db.database import get_db
|
|
21
|
+
from app.models.schemas import ParseResponse, ParsedTravelData
|
|
22
|
+
from app.services import ollama as ollama_svc
|
|
23
|
+
|
|
24
|
+
router = APIRouter(prefix="/mail", tags=["mail"])
|
|
25
|
+
settings = get_settings()
|
|
26
|
+
|
|
27
|
+
_HEADER_RE = re.compile(
|
|
28
|
+
r"^(From|To|Cc|Bcc|Subject|Date|Message-ID|MIME-Version|Content-Type"
|
|
29
|
+
r"|Content-Transfer-Encoding|Delivered-To|Received|Return-Path|X-[^:]+)"
|
|
30
|
+
r":\s.*$",
|
|
31
|
+
re.MULTILINE | re.IGNORECASE,
|
|
32
|
+
)
|
|
33
|
+
_BLANK_LINES_RE = re.compile(r"\n{3,}")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _safe_dt(val: str | None) -> datetime | None:
|
|
37
|
+
"""Parse an Ollama-produced datetime string to a proper datetime object.
|
|
38
|
+
asyncpg requires a datetime object (not a string) for TIMESTAMPTZ columns.
|
|
39
|
+
Returns None on missing or unparseable input."""
|
|
40
|
+
if val is None:
|
|
41
|
+
return None
|
|
42
|
+
try:
|
|
43
|
+
return datetime.fromisoformat(val.replace("Z", "+00:00"))
|
|
44
|
+
except (ValueError, AttributeError):
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _build_parsed(parsed_dict: dict) -> ParsedTravelData:
|
|
49
|
+
"""Build a ParsedTravelData, falling back to safe defaults on ValidationError.
|
|
50
|
+
Matches the same pattern as parse.py to avoid a double-fail scenario."""
|
|
51
|
+
try:
|
|
52
|
+
return ParsedTravelData(**{k: parsed_dict.get(k) for k in ParsedTravelData.model_fields})
|
|
53
|
+
except ValidationError:
|
|
54
|
+
return ParsedTravelData(
|
|
55
|
+
type="other",
|
|
56
|
+
title=str(parsed_dict.get("title") or "E-Mail"),
|
|
57
|
+
booking_ref=parsed_dict.get("booking_ref"),
|
|
58
|
+
provider=parsed_dict.get("provider"),
|
|
59
|
+
event_at=parsed_dict.get("event_at"),
|
|
60
|
+
raw_summary=parsed_dict.get("raw_summary"),
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def strip_email_headers(raw: str) -> str:
|
|
65
|
+
cleaned = _HEADER_RE.sub("", raw)
|
|
66
|
+
cleaned = _BLANK_LINES_RE.sub("\n\n", cleaned)
|
|
67
|
+
return cleaned.strip()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@router.post("/import", response_model=ParseResponse)
|
|
71
|
+
async def import_mail(
|
|
72
|
+
uid: Annotated[str, Depends(user_id)],
|
|
73
|
+
db: Annotated[AsyncSession, Depends(get_db)],
|
|
74
|
+
raw_email: str = Body(..., media_type="text/plain", description="Raw email body (paste or share)"),
|
|
75
|
+
trip_id: UUID | None = Query(None, description="Assign directly to this trip (optional)"),
|
|
76
|
+
):
|
|
77
|
+
body_text = strip_email_headers(raw_email)
|
|
78
|
+
parsed_dict, _was_cached = await ollama_svc.parse_text(body_text)
|
|
79
|
+
parsed = _build_parsed(parsed_dict)
|
|
80
|
+
|
|
81
|
+
if trip_id:
|
|
82
|
+
await db.execute(
|
|
83
|
+
text("""
|
|
84
|
+
INSERT INTO trip_items
|
|
85
|
+
(trip_id, user_id, type, title, raw_text, parsed_data,
|
|
86
|
+
event_at, event_end_at, booking_ref, provider)
|
|
87
|
+
VALUES (:trip_id, :uid, :type, :title, :raw, :pd,
|
|
88
|
+
:event_at, :event_end_at, :booking_ref, :provider)
|
|
89
|
+
"""),
|
|
90
|
+
{
|
|
91
|
+
"trip_id": str(trip_id), "uid": uid, "type": parsed.type, "title": parsed.title,
|
|
92
|
+
"raw": body_text, "pd": json.dumps(parsed.model_dump()),
|
|
93
|
+
"event_at": _safe_dt(parsed.event_at),
|
|
94
|
+
"event_end_at": _safe_dt(parsed.event_end_at),
|
|
95
|
+
"booking_ref": parsed.booking_ref, "provider": parsed.provider,
|
|
96
|
+
},
|
|
97
|
+
)
|
|
98
|
+
else:
|
|
99
|
+
await db.execute(
|
|
100
|
+
text("""
|
|
101
|
+
INSERT INTO chaos_inbox (user_id, raw_content, parsed_data, source)
|
|
102
|
+
VALUES (:uid, :raw, :pd, 'mail')
|
|
103
|
+
"""),
|
|
104
|
+
{"uid": uid, "raw": body_text, "pd": json.dumps(parsed.model_dump())},
|
|
105
|
+
)
|
|
106
|
+
await db.commit()
|
|
107
|
+
|
|
108
|
+
return ParseResponse(parsed=parsed, raw_text=body_text, model_used=settings.ollama_model)
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Parse endpoint — accepts file upload, raw text, or a public URL.
|
|
3
|
+
Returns structured travel data via Ollama AI.
|
|
4
|
+
Results are deduplicated via Cachly Redis when CACHLY_REDIS_URL is configured.
|
|
5
|
+
"""
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from typing import Annotated
|
|
8
|
+
from uuid import UUID
|
|
9
|
+
import aiofiles
|
|
10
|
+
import os
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile, status
|
|
13
|
+
from pydantic import BaseModel, ValidationError
|
|
14
|
+
from sqlalchemy import text
|
|
15
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
16
|
+
from app.auth.keycloak import user_id, user_id_or_bot
|
|
17
|
+
from app.config import get_settings
|
|
18
|
+
from app.db.database import get_db
|
|
19
|
+
from app.models.schemas import ParseResponse, ParsedTravelData
|
|
20
|
+
from app.services import ollama as ollama_svc
|
|
21
|
+
from app.services.parser import extract_text_from_pdf, is_image, is_pdf, is_text
|
|
22
|
+
from app.services.cache import cachly_configured
|
|
23
|
+
from app.services import quota
|
|
24
|
+
from app.limiter import limiter
|
|
25
|
+
|
|
26
|
+
router = APIRouter(prefix="/parse", tags=["parse"])
|
|
27
|
+
settings = get_settings()
|
|
28
|
+
|
|
29
|
+
# ── helpers ────────────────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
def _safe_dt(val: str | None) -> datetime | None:
|
|
32
|
+
"""Parse an ISO 8601 datetime string from Ollama to a proper datetime.
|
|
33
|
+
Returns None if the value is missing, empty, or not parseable.
|
|
34
|
+
asyncpg requires a proper datetime object or None for TIMESTAMPTZ columns.
|
|
35
|
+
"""
|
|
36
|
+
if not val or not isinstance(val, str):
|
|
37
|
+
return None
|
|
38
|
+
try:
|
|
39
|
+
# Python 3.11+ fromisoformat handles most ISO 8601 variants.
|
|
40
|
+
# Replace trailing Z (UTC) so older code paths also work.
|
|
41
|
+
return datetime.fromisoformat(val.replace("Z", "+00:00"))
|
|
42
|
+
except ValueError:
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _build_parsed(parsed_dict: dict) -> ParsedTravelData:
|
|
47
|
+
"""Construct ParsedTravelData from Ollama output.
|
|
48
|
+
Falls back to safe defaults if Ollama returns an unexpected 'type' value
|
|
49
|
+
(e.g. 'taxi', 'parking') that is not in the Literal enum — which would
|
|
50
|
+
otherwise raise an unhandled Pydantic ValidationError and return a 500.
|
|
51
|
+
"""
|
|
52
|
+
try:
|
|
53
|
+
return ParsedTravelData(**{k: parsed_dict.get(k) for k in ParsedTravelData.model_fields})
|
|
54
|
+
except ValidationError:
|
|
55
|
+
return ParsedTravelData(
|
|
56
|
+
type="other",
|
|
57
|
+
title=str(parsed_dict.get("title") or "Dokument"),
|
|
58
|
+
booking_ref=parsed_dict.get("booking_ref"),
|
|
59
|
+
provider=parsed_dict.get("provider"),
|
|
60
|
+
event_at=parsed_dict.get("event_at"),
|
|
61
|
+
raw_summary=parsed_dict.get("raw_summary"),
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
async def _save_file(content: bytes, filename: str, uid: str) -> str:
|
|
66
|
+
dest_dir = Path(settings.upload_dir) / uid
|
|
67
|
+
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
68
|
+
dest = dest_dir / filename
|
|
69
|
+
async with aiofiles.open(dest, "wb") as f:
|
|
70
|
+
await f.write(content)
|
|
71
|
+
return str(dest)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
async def _insert_item_from_parsed(
|
|
75
|
+
uid: str, trip_id: UUID, parsed: ParsedTravelData,
|
|
76
|
+
raw_text: str | None, file_path: str | None,
|
|
77
|
+
mime: str | None, filename: str | None,
|
|
78
|
+
db: AsyncSession,
|
|
79
|
+
) -> None:
|
|
80
|
+
import json
|
|
81
|
+
result = await db.execute(
|
|
82
|
+
text("""
|
|
83
|
+
INSERT INTO trip_items
|
|
84
|
+
(trip_id, user_id, type, title, raw_text, parsed_data,
|
|
85
|
+
event_at, event_end_at, booking_ref, provider)
|
|
86
|
+
VALUES (:trip_id, :uid, :type, :title, :raw_text, :pd,
|
|
87
|
+
:event_at, :event_end_at, :booking_ref, :provider)
|
|
88
|
+
RETURNING id
|
|
89
|
+
"""),
|
|
90
|
+
{
|
|
91
|
+
"trip_id": str(trip_id), "uid": uid, "type": parsed.type, "title": parsed.title,
|
|
92
|
+
"raw_text": raw_text, "pd": json.dumps(parsed.model_dump()),
|
|
93
|
+
# _safe_dt converts Ollama's freeform date strings to proper datetime objects.
|
|
94
|
+
# asyncpg / PostgreSQL rejects non-ISO strings for TIMESTAMPTZ columns,
|
|
95
|
+
# which would crash the INSERT and roll back the whole transaction.
|
|
96
|
+
"event_at": _safe_dt(parsed.event_at), "event_end_at": _safe_dt(parsed.event_end_at),
|
|
97
|
+
"booking_ref": parsed.booking_ref, "provider": parsed.provider,
|
|
98
|
+
},
|
|
99
|
+
)
|
|
100
|
+
item_id = result.fetchone()[0]
|
|
101
|
+
|
|
102
|
+
if file_path:
|
|
103
|
+
await db.execute(
|
|
104
|
+
text("""
|
|
105
|
+
INSERT INTO attachments (trip_item_id, user_id, file_path, file_name, mime_type)
|
|
106
|
+
VALUES (:item_id, :uid, :fp, :fn, :mime)
|
|
107
|
+
"""),
|
|
108
|
+
# file_name is NOT NULL in the schema. Use a fallback to avoid a constraint
|
|
109
|
+
# violation that would roll back the trip_item insert in the same transaction.
|
|
110
|
+
{"item_id": item_id, "uid": uid, "fp": file_path, "fn": filename or "attachment", "mime": mime or "application/octet-stream"},
|
|
111
|
+
)
|
|
112
|
+
await db.commit()
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
async def _insert_inbox(
|
|
116
|
+
uid: str, raw_text: str | None, parsed_dict: dict,
|
|
117
|
+
file_path: str | None, mime: str | None, filename: str | None,
|
|
118
|
+
db: AsyncSession,
|
|
119
|
+
) -> dict:
|
|
120
|
+
import json
|
|
121
|
+
result = await db.execute(
|
|
122
|
+
text("""
|
|
123
|
+
INSERT INTO chaos_inbox (user_id, raw_content, parsed_data, source)
|
|
124
|
+
VALUES (:uid, :raw, :pd, :source)
|
|
125
|
+
RETURNING id
|
|
126
|
+
"""),
|
|
127
|
+
{"uid": uid, "raw": raw_text, "pd": json.dumps(parsed_dict), "source": filename or "text"},
|
|
128
|
+
)
|
|
129
|
+
inbox_id = result.fetchone()[0]
|
|
130
|
+
|
|
131
|
+
if file_path:
|
|
132
|
+
await db.execute(
|
|
133
|
+
text("""
|
|
134
|
+
INSERT INTO attachments (inbox_id, user_id, file_path, file_name, mime_type)
|
|
135
|
+
VALUES (:inbox_id, :uid, :fp, :fn, :mime)
|
|
136
|
+
"""),
|
|
137
|
+
{"inbox_id": inbox_id, "uid": uid, "fp": file_path, "fn": filename or "attachment", "mime": mime or "application/octet-stream"},
|
|
138
|
+
)
|
|
139
|
+
await db.commit()
|
|
140
|
+
return {"inbox_id": str(inbox_id)}
|
|
141
|
+
|
|
142
|
+
# ── endpoints ──────────────────────────────────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
@limiter.limit("10/minute")
|
|
145
|
+
@router.post("/file", response_model=ParseResponse)
|
|
146
|
+
async def parse_file(
|
|
147
|
+
request: Request,
|
|
148
|
+
uid: Annotated[str, Depends(user_id_or_bot)],
|
|
149
|
+
db: Annotated[AsyncSession, Depends(get_db)],
|
|
150
|
+
file: UploadFile = File(...),
|
|
151
|
+
trip_id: UUID | None = Form(None),
|
|
152
|
+
):
|
|
153
|
+
await quota.check_parse(db, uid)
|
|
154
|
+
_s = get_settings()
|
|
155
|
+
content = await file.read()
|
|
156
|
+
if len(content) > _s.max_upload_bytes:
|
|
157
|
+
raise HTTPException(status_code=413, detail=f"File too large. Max {_s.max_upload_bytes // 1024 // 1024} MB.")
|
|
158
|
+
mime = file.content_type or "application/octet-stream"
|
|
159
|
+
raw_text: str | None = None
|
|
160
|
+
parsed_dict: dict = {}
|
|
161
|
+
|
|
162
|
+
was_cached = False
|
|
163
|
+
if is_pdf(mime):
|
|
164
|
+
raw_text = extract_text_from_pdf(content)
|
|
165
|
+
parsed_dict, was_cached = await ollama_svc.parse_text(raw_text)
|
|
166
|
+
elif is_image(mime):
|
|
167
|
+
parsed_dict, raw_text, was_cached = await ollama_svc.parse_image(content, mime)
|
|
168
|
+
elif is_text(mime):
|
|
169
|
+
raw_text = content.decode("utf-8", errors="replace")
|
|
170
|
+
parsed_dict, was_cached = await ollama_svc.parse_text(raw_text)
|
|
171
|
+
else:
|
|
172
|
+
raise HTTPException(status_code=415, detail=f"Unsupported file type: {mime}")
|
|
173
|
+
|
|
174
|
+
file_path = await _save_file(content, file.filename or "upload", uid)
|
|
175
|
+
parsed = _build_parsed(parsed_dict)
|
|
176
|
+
|
|
177
|
+
if trip_id:
|
|
178
|
+
await _insert_item_from_parsed(uid, trip_id, parsed, raw_text, file_path, mime, file.filename, db)
|
|
179
|
+
else:
|
|
180
|
+
await _insert_inbox(uid, raw_text, parsed_dict, file_path, mime, file.filename, db)
|
|
181
|
+
|
|
182
|
+
from app.services import telemetry, notifier as notifier_svc
|
|
183
|
+
await telemetry.track(db, uid, "parse_file", {"mime": mime, "trip_id": str(trip_id) if trip_id else None, "cached": was_cached})
|
|
184
|
+
if not was_cached:
|
|
185
|
+
dest = f"Trip zugewiesen" if trip_id else "Chaos Inbox"
|
|
186
|
+
await notifier_svc.notify_user(db, uid, f"✅ *{parsed.title or 'Dokument'}* geparst\nTyp: {parsed.type} · {dest}")
|
|
187
|
+
return ParseResponse(parsed=parsed, raw_text=raw_text, model_used=settings.ollama_model)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@limiter.limit("20/minute")
|
|
191
|
+
@router.post("/text", response_model=ParseResponse)
|
|
192
|
+
async def parse_text_endpoint(
|
|
193
|
+
request: Request,
|
|
194
|
+
uid: Annotated[str, Depends(user_id_or_bot)],
|
|
195
|
+
db: Annotated[AsyncSession, Depends(get_db)],
|
|
196
|
+
raw_text: str = Form(...),
|
|
197
|
+
trip_id: UUID | None = Form(None),
|
|
198
|
+
):
|
|
199
|
+
await quota.check_parse(db, uid)
|
|
200
|
+
parsed_dict, was_cached = await ollama_svc.parse_text(raw_text)
|
|
201
|
+
parsed = _build_parsed(parsed_dict)
|
|
202
|
+
|
|
203
|
+
if trip_id:
|
|
204
|
+
await _insert_item_from_parsed(uid, trip_id, parsed, raw_text, None, None, None, db)
|
|
205
|
+
else:
|
|
206
|
+
await _insert_inbox(uid, raw_text, parsed_dict, None, None, None, db)
|
|
207
|
+
|
|
208
|
+
from app.services import telemetry
|
|
209
|
+
await telemetry.track(db, uid, "parse_text", {"mime": None, "trip_id": str(trip_id) if trip_id else None, "cached": was_cached})
|
|
210
|
+
return ParseResponse(parsed=parsed, raw_text=raw_text, model_used=settings.ollama_model)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
class ParseUrlRequest(BaseModel):
|
|
214
|
+
url: str
|
|
215
|
+
trip_id: UUID | None = None
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
@limiter.limit("5/minute")
|
|
219
|
+
@router.post("/url")
|
|
220
|
+
async def parse_url(
|
|
221
|
+
request: Request,
|
|
222
|
+
body: ParseUrlRequest,
|
|
223
|
+
uid: Annotated[str, Depends(user_id)],
|
|
224
|
+
db: Annotated[AsyncSession, Depends(get_db)],
|
|
225
|
+
):
|
|
226
|
+
"""
|
|
227
|
+
Fetch a public URL and parse its content with Ollama.
|
|
228
|
+
Useful for online booking confirmation pages.
|
|
229
|
+
Cached in Cachly Redis when CACHLY_REDIS_URL is configured.
|
|
230
|
+
"""
|
|
231
|
+
import httpx as _httpx
|
|
232
|
+
from html.parser import HTMLParser
|
|
233
|
+
|
|
234
|
+
class _TextExtractor(HTMLParser):
|
|
235
|
+
def __init__(self):
|
|
236
|
+
super().__init__()
|
|
237
|
+
self._parts: list[str] = []
|
|
238
|
+
self._skip = False
|
|
239
|
+
def handle_starttag(self, tag, attrs):
|
|
240
|
+
if tag in ("script", "style", "nav", "footer"):
|
|
241
|
+
self._skip = True
|
|
242
|
+
def handle_endtag(self, tag):
|
|
243
|
+
if tag in ("script", "style", "nav", "footer"):
|
|
244
|
+
self._skip = False
|
|
245
|
+
def handle_data(self, data):
|
|
246
|
+
if not self._skip and data.strip():
|
|
247
|
+
self._parts.append(data.strip())
|
|
248
|
+
def text(self) -> str:
|
|
249
|
+
return " ".join(self._parts)[:8000]
|
|
250
|
+
|
|
251
|
+
# SSRF protection: only allow http/https, block private/loopback IP ranges
|
|
252
|
+
import ipaddress as _ip
|
|
253
|
+
try:
|
|
254
|
+
_parsed = _httpx.URL(body.url)
|
|
255
|
+
if _parsed.scheme not in ("http", "https"):
|
|
256
|
+
raise HTTPException(status_code=422, detail="Only http/https URLs are allowed")
|
|
257
|
+
import socket as _socket
|
|
258
|
+
try:
|
|
259
|
+
_addr = _socket.gethostbyname(_parsed.host)
|
|
260
|
+
_net = _ip.ip_address(_addr)
|
|
261
|
+
if _net.is_private or _net.is_loopback or _net.is_link_local or _net.is_reserved:
|
|
262
|
+
raise HTTPException(status_code=422, detail="URL resolves to a private/internal address")
|
|
263
|
+
except HTTPException:
|
|
264
|
+
raise
|
|
265
|
+
except Exception:
|
|
266
|
+
pass # DNS failure handled below
|
|
267
|
+
except HTTPException:
|
|
268
|
+
raise
|
|
269
|
+
except Exception:
|
|
270
|
+
raise HTTPException(status_code=422, detail="Invalid URL")
|
|
271
|
+
|
|
272
|
+
try:
|
|
273
|
+
async with _httpx.AsyncClient(timeout=20, follow_redirects=True) as client:
|
|
274
|
+
resp = await client.get(body.url, headers={"User-Agent": "Mozilla/5.0 TCO/0.1"})
|
|
275
|
+
resp.raise_for_status()
|
|
276
|
+
except HTTPException:
|
|
277
|
+
raise
|
|
278
|
+
except Exception:
|
|
279
|
+
raise HTTPException(status_code=422, detail="Could not fetch URL")
|
|
280
|
+
|
|
281
|
+
await quota.check_parse(db, uid)
|
|
282
|
+
ct = resp.headers.get("content-type", "")
|
|
283
|
+
if "html" in ct:
|
|
284
|
+
extractor = _TextExtractor()
|
|
285
|
+
extractor.feed(resp.text)
|
|
286
|
+
raw_text = extractor.text()
|
|
287
|
+
elif "pdf" in ct:
|
|
288
|
+
raw_text = extract_text_from_pdf(resp.content)
|
|
289
|
+
else:
|
|
290
|
+
raw_text = resp.text[:8000]
|
|
291
|
+
|
|
292
|
+
parsed_dict, was_cached = await ollama_svc.parse_text(raw_text)
|
|
293
|
+
parsed = _build_parsed(parsed_dict)
|
|
294
|
+
|
|
295
|
+
from app.services import telemetry
|
|
296
|
+
if body.trip_id:
|
|
297
|
+
await _insert_item_from_parsed(uid, body.trip_id, parsed, raw_text, None, None, body.url, db)
|
|
298
|
+
await telemetry.track(db, uid, "parse_url", {"mime": None, "trip_id": str(body.trip_id) if body.trip_id else None, "cached": was_cached})
|
|
299
|
+
return ParseResponse(parsed=parsed, raw_text=raw_text, model_used=settings.ollama_model)
|
|
300
|
+
else:
|
|
301
|
+
result = await _insert_inbox(uid, raw_text, parsed_dict, None, None, body.url, db)
|
|
302
|
+
await telemetry.track(db, uid, "parse_url", {"mime": None, "trip_id": None, "cached": was_cached})
|
|
303
|
+
return {**ParseResponse(parsed=parsed, raw_text=raw_text, model_used=settings.ollama_model).model_dump(), **result}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Stripe payment endpoints.
|
|
3
|
+
POST /api/v1/payments/checkout — create Stripe Checkout session for Pro upgrade
|
|
4
|
+
POST /api/v1/payments/webhook — handle Stripe webhook events
|
|
5
|
+
"""
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
from fastapi import APIRouter, Depends, HTTPException, Request
|
|
8
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
9
|
+
from sqlalchemy import text
|
|
10
|
+
from datetime import datetime, timedelta, timezone
|
|
11
|
+
from app.auth.keycloak import user_id
|
|
12
|
+
from app.db.database import get_db
|
|
13
|
+
from app.config import get_settings
|
|
14
|
+
from app.limiter import limiter
|
|
15
|
+
|
|
16
|
+
router = APIRouter(prefix="/payments", tags=["payments"])
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@limiter.limit("5/minute")
|
|
20
|
+
@router.post("/checkout")
|
|
21
|
+
async def create_checkout(
|
|
22
|
+
request: Request,
|
|
23
|
+
uid: Annotated[str, Depends(user_id)],
|
|
24
|
+
db: Annotated[AsyncSession, Depends(get_db)],
|
|
25
|
+
):
|
|
26
|
+
s = get_settings()
|
|
27
|
+
if not s.stripe_secret_key:
|
|
28
|
+
raise HTTPException(status_code=503, detail="Payments not configured")
|
|
29
|
+
|
|
30
|
+
import stripe
|
|
31
|
+
stripe.api_key = s.stripe_secret_key
|
|
32
|
+
|
|
33
|
+
# Get user email from DB if available
|
|
34
|
+
row = await db.execute(text("SELECT email FROM users WHERE id = :uid"), {"uid": uid})
|
|
35
|
+
r = row.fetchone()
|
|
36
|
+
customer_email = r[0] if r and r[0] else None
|
|
37
|
+
|
|
38
|
+
session = stripe.checkout.Session.create(
|
|
39
|
+
mode="subscription",
|
|
40
|
+
line_items=[{"price": s.stripe_pro_price_id, "quantity": 1}],
|
|
41
|
+
success_url=s.stripe_success_url + "?session_id={CHECKOUT_SESSION_ID}",
|
|
42
|
+
cancel_url=s.stripe_cancel_url,
|
|
43
|
+
client_reference_id=uid,
|
|
44
|
+
**({"customer_email": customer_email} if customer_email else {}),
|
|
45
|
+
metadata={"user_id": uid},
|
|
46
|
+
)
|
|
47
|
+
from app.services import telemetry
|
|
48
|
+
await telemetry.track(db, uid, "stripe_checkout_initiated", {"price_id": s.stripe_pro_price_id})
|
|
49
|
+
return {"checkout_url": session.url, "session_id": session.id}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@router.post("/webhook")
|
|
53
|
+
async def stripe_webhook(request: Request, db: Annotated[AsyncSession, Depends(get_db)]):
|
|
54
|
+
s = get_settings()
|
|
55
|
+
if not s.stripe_secret_key:
|
|
56
|
+
return {"ok": True}
|
|
57
|
+
|
|
58
|
+
import stripe
|
|
59
|
+
stripe.api_key = s.stripe_secret_key
|
|
60
|
+
|
|
61
|
+
payload = await request.body()
|
|
62
|
+
sig = request.headers.get("stripe-signature", "")
|
|
63
|
+
if not sig:
|
|
64
|
+
raise HTTPException(status_code=400, detail="Missing Stripe-Signature header")
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
event = stripe.Webhook.construct_event(payload, sig, s.stripe_webhook_secret)
|
|
68
|
+
except Exception:
|
|
69
|
+
raise HTTPException(status_code=400, detail="Invalid webhook signature")
|
|
70
|
+
|
|
71
|
+
if event["type"] == "checkout.session.completed":
|
|
72
|
+
obj = event["data"]["object"]
|
|
73
|
+
uid = obj.get("metadata", {}).get("user_id") or obj.get("client_reference_id")
|
|
74
|
+
if uid:
|
|
75
|
+
expires_at = datetime.now(timezone.utc) + timedelta(days=365)
|
|
76
|
+
await db.execute(
|
|
77
|
+
text("""
|
|
78
|
+
INSERT INTO users (id, plan, plan_expires_at, updated_at)
|
|
79
|
+
VALUES (:uid, 'pro', :exp, :now)
|
|
80
|
+
ON CONFLICT (id) DO UPDATE
|
|
81
|
+
SET plan = 'pro', plan_expires_at = EXCLUDED.plan_expires_at,
|
|
82
|
+
updated_at = EXCLUDED.updated_at
|
|
83
|
+
"""),
|
|
84
|
+
{"uid": uid, "exp": expires_at, "now": datetime.now(timezone.utc)},
|
|
85
|
+
)
|
|
86
|
+
await db.commit()
|
|
87
|
+
# Store Stripe customer ID for future subscription management
|
|
88
|
+
stripe_cust = obj.get("customer")
|
|
89
|
+
if stripe_cust:
|
|
90
|
+
await db.execute(
|
|
91
|
+
text("UPDATE users SET stripe_customer_id = :cid WHERE id = :uid"),
|
|
92
|
+
{"cid": stripe_cust, "uid": uid},
|
|
93
|
+
)
|
|
94
|
+
await db.commit()
|
|
95
|
+
from app.services import telemetry as tel_svc
|
|
96
|
+
from app.services import notifier as notifier_svc
|
|
97
|
+
await tel_svc.track(db, uid, "stripe_payment_completed", {"amount": obj.get("amount_total"), "currency": obj.get("currency")})
|
|
98
|
+
await notifier_svc.notify("tco", "stripe_payment", {"user_id": uid, "amount": obj.get("amount_total"), "currency": obj.get("currency")})
|
|
99
|
+
await notifier_svc.notify_user(db, uid, "🎉 *Du bist jetzt Pro!*\n\nAlle Features sind freigeschaltet. Viel Spaß beim Reisen ✈️")
|
|
100
|
+
|
|
101
|
+
elif event["type"] in ("customer.subscription.deleted", "customer.subscription.paused"):
|
|
102
|
+
cust_id = event["data"]["object"].get("customer")
|
|
103
|
+
if cust_id:
|
|
104
|
+
result = await db.execute(
|
|
105
|
+
text("SELECT id FROM users WHERE stripe_customer_id = :cid"),
|
|
106
|
+
{"cid": cust_id},
|
|
107
|
+
)
|
|
108
|
+
row = result.fetchone()
|
|
109
|
+
if row:
|
|
110
|
+
downgrade_uid = row[0]
|
|
111
|
+
await db.execute(
|
|
112
|
+
text("""
|
|
113
|
+
UPDATE users SET plan = 'free', plan_expires_at = NULL, updated_at = :now
|
|
114
|
+
WHERE id = :uid
|
|
115
|
+
"""),
|
|
116
|
+
{"uid": downgrade_uid, "now": datetime.now(timezone.utc)},
|
|
117
|
+
)
|
|
118
|
+
await db.commit()
|
|
119
|
+
from app.services import telemetry as tel_svc, notifier as notifier_svc
|
|
120
|
+
await tel_svc.track(db, downgrade_uid, "stripe_subscription_cancelled", {"customer": cust_id})
|
|
121
|
+
await notifier_svc.notify_user(db, downgrade_uid, "ℹ️ *Dein Pro-Abo wurde beendet.*\n\nDu bist jetzt wieder im Free-Plan. Deine Daten bleiben erhalten.")
|
|
122
|
+
await notifier_svc.notify("tco", "stripe_payment", {"event": "cancelled", "user_id": downgrade_uid[:8]})
|
|
123
|
+
|
|
124
|
+
elif event["type"] == "invoice.payment_succeeded":
|
|
125
|
+
# Extend plan by 1 year on renewal
|
|
126
|
+
sub = event["data"]["object"].get("subscription")
|
|
127
|
+
cust_id = event["data"]["object"].get("customer")
|
|
128
|
+
if sub and cust_id:
|
|
129
|
+
result = await db.execute(
|
|
130
|
+
text("SELECT id, plan_expires_at FROM users WHERE stripe_customer_id = :cid"),
|
|
131
|
+
{"cid": cust_id},
|
|
132
|
+
)
|
|
133
|
+
row = result.fetchone()
|
|
134
|
+
if row:
|
|
135
|
+
renewal_uid = row[0]
|
|
136
|
+
new_expiry = datetime.now(timezone.utc) + timedelta(days=366)
|
|
137
|
+
await db.execute(
|
|
138
|
+
text("""
|
|
139
|
+
UPDATE users SET plan = 'pro', plan_expires_at = :exp, updated_at = :now
|
|
140
|
+
WHERE id = :uid
|
|
141
|
+
"""),
|
|
142
|
+
{"uid": renewal_uid, "exp": new_expiry, "now": datetime.now(timezone.utc)},
|
|
143
|
+
)
|
|
144
|
+
await db.commit()
|
|
145
|
+
|
|
146
|
+
elif event["type"] == "invoice.payment_failed":
|
|
147
|
+
cust_id = event["data"]["object"].get("customer")
|
|
148
|
+
attempt_count = event["data"]["object"].get("attempt_count", 1)
|
|
149
|
+
if cust_id:
|
|
150
|
+
result = await db.execute(
|
|
151
|
+
text("SELECT id, email FROM users WHERE stripe_customer_id = :cid"),
|
|
152
|
+
{"cid": cust_id},
|
|
153
|
+
)
|
|
154
|
+
row = result.fetchone()
|
|
155
|
+
if row:
|
|
156
|
+
failed_uid, user_email = row[0], row[1]
|
|
157
|
+
if attempt_count >= 3:
|
|
158
|
+
# Too many failures — downgrade to free immediately
|
|
159
|
+
await db.execute(
|
|
160
|
+
text("""
|
|
161
|
+
UPDATE users SET plan = 'free', plan_expires_at = NULL, updated_at = :now
|
|
162
|
+
WHERE id = :uid
|
|
163
|
+
"""),
|
|
164
|
+
{"uid": failed_uid, "now": datetime.now(timezone.utc)},
|
|
165
|
+
)
|
|
166
|
+
await db.commit()
|
|
167
|
+
# else: keep pro, Stripe will retry (grace period)
|
|
168
|
+
from app.services import telemetry as tel_svc, notifier as notifier_svc
|
|
169
|
+
await tel_svc.track(db, failed_uid, "stripe_payment_failed", {"customer": cust_id, "attempt_count": attempt_count})
|
|
170
|
+
await notifier_svc.notify("tco", "stripe_payment", {"event": "payment_failed", "user_id": failed_uid[:8], "attempt": attempt_count})
|
|
171
|
+
if attempt_count < 3:
|
|
172
|
+
await notifier_svc.notify_user(db, failed_uid, f"⚠️ *Zahlung fehlgeschlagen* (Versuch {attempt_count}/3)\n\nBitte aktualisiere deine Zahlungsmethode in Stripe. Dein Pro-Zugang bleibt noch aktiv.")
|
|
173
|
+
else:
|
|
174
|
+
await notifier_svc.notify_user(db, failed_uid, "🔴 *Pro-Zugang deaktiviert*\n\nNach 3 fehlgeschlagenen Zahlungsversuchen wurde dein Abo beendet. Bitte erneuere dein Abo in der App.")
|
|
175
|
+
if user_email and attempt_count == 1:
|
|
176
|
+
# TODO: send payment failure email via notifier_svc or a dedicated email service
|
|
177
|
+
pass
|
|
178
|
+
|
|
179
|
+
return {"ok": True}
|