@cachly-dev/mcp-server 0.10.48 → 0.10.50
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/BENCH.md +80 -0
- package/README.md +5 -33
- package/STRATEGY.md +218 -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 +98 -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 +34 -0
- package/dist/src/bench/fixtures.js +199 -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 +261 -87
- 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/src/handlers/team.js.map +1 -0
- package/dist/{index.js → src/index.js} +153 -218
- 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 +50 -0
- package/dist/src/rerank.js +92 -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} +196 -0
- package/dist/{tools.js → src/tools.js} +178 -0
- package/dist/src/tools.js.map +1 -0
- package/dist/src/utils.js.map +1 -0
- package/package.json +4 -3
- 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/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 +111 -0
- package/src/bench/cachly-bench.ts +217 -0
- package/src/bench/fixtures.ts +226 -0
- package/src/handlers/brain.ts +276 -92
- 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/index.ts +139 -220
- package/src/notifier.ts +5 -0
- package/src/rerank.ts +123 -0
- package/src/tools.ts +191 -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/{handlers → src/handlers}/team.js +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,134 @@
|
|
|
1
|
+
from typing import Annotated
|
|
2
|
+
from uuid import UUID
|
|
3
|
+
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
|
4
|
+
from sqlalchemy import text
|
|
5
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
6
|
+
from app.auth.keycloak import user_id, user_id_or_bot
|
|
7
|
+
from app.db.database import get_db
|
|
8
|
+
from app.models.schemas import TripCreate, TripOut, TripUpdate
|
|
9
|
+
from app.services import quota
|
|
10
|
+
from app.limiter import limiter
|
|
11
|
+
|
|
12
|
+
router = APIRouter(prefix="/trips", tags=["trips"])
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@router.get("", response_model=list[TripOut])
|
|
16
|
+
async def list_trips(
|
|
17
|
+
uid: Annotated[str, Depends(user_id_or_bot)],
|
|
18
|
+
db: Annotated[AsyncSession, Depends(get_db)],
|
|
19
|
+
):
|
|
20
|
+
result = await db.execute(
|
|
21
|
+
text("SELECT * FROM trips WHERE user_id = :uid ORDER BY start_date ASC, created_at DESC"),
|
|
22
|
+
{"uid": uid},
|
|
23
|
+
)
|
|
24
|
+
return [dict(r._mapping) for r in result.fetchall()]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@router.get("/search", response_model=list[TripOut])
|
|
28
|
+
async def search_trips(
|
|
29
|
+
uid: Annotated[str, Depends(user_id)],
|
|
30
|
+
db: Annotated[AsyncSession, Depends(get_db)],
|
|
31
|
+
q: str = "",
|
|
32
|
+
):
|
|
33
|
+
if not q.strip():
|
|
34
|
+
return []
|
|
35
|
+
pattern = f"%{q.strip()}%"
|
|
36
|
+
result = await db.execute(
|
|
37
|
+
text("""
|
|
38
|
+
SELECT DISTINCT t.* FROM trips t
|
|
39
|
+
LEFT JOIN trip_items ti ON ti.trip_id = t.id
|
|
40
|
+
WHERE t.user_id = :uid
|
|
41
|
+
AND (
|
|
42
|
+
t.name LIKE :p OR t.description LIKE :p
|
|
43
|
+
OR ti.title LIKE :p OR ti.provider LIKE :p
|
|
44
|
+
OR ti.booking_ref LIKE :p
|
|
45
|
+
)
|
|
46
|
+
ORDER BY t.start_date ASC, t.created_at DESC
|
|
47
|
+
"""),
|
|
48
|
+
{"uid": uid, "p": pattern},
|
|
49
|
+
)
|
|
50
|
+
rows = [dict(r._mapping) for r in result.fetchall()]
|
|
51
|
+
from app.services import telemetry
|
|
52
|
+
await telemetry.track(db, uid, "trip_search", {"q": q.strip()[:100], "result_count": len(rows)})
|
|
53
|
+
return rows
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@limiter.limit("30/minute")
|
|
57
|
+
@router.post("", response_model=TripOut, status_code=status.HTTP_201_CREATED)
|
|
58
|
+
async def create_trip(
|
|
59
|
+
request: Request,
|
|
60
|
+
body: TripCreate,
|
|
61
|
+
uid: Annotated[str, Depends(user_id)],
|
|
62
|
+
db: Annotated[AsyncSession, Depends(get_db)],
|
|
63
|
+
):
|
|
64
|
+
await quota.check_trips(db, uid)
|
|
65
|
+
result = await db.execute(
|
|
66
|
+
text("""
|
|
67
|
+
INSERT INTO trips (user_id, name, description, start_date, end_date)
|
|
68
|
+
VALUES (:uid, :name, :desc, :start_date, :end_date)
|
|
69
|
+
RETURNING *
|
|
70
|
+
"""),
|
|
71
|
+
{"uid": uid, "name": body.name, "desc": body.description,
|
|
72
|
+
"start_date": body.start_date, "end_date": body.end_date},
|
|
73
|
+
)
|
|
74
|
+
await db.commit()
|
|
75
|
+
return dict(result.fetchone()._mapping)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@router.get("/{trip_id}", response_model=TripOut)
|
|
79
|
+
async def get_trip(
|
|
80
|
+
trip_id: UUID,
|
|
81
|
+
uid: Annotated[str, Depends(user_id)],
|
|
82
|
+
db: Annotated[AsyncSession, Depends(get_db)],
|
|
83
|
+
):
|
|
84
|
+
result = await db.execute(
|
|
85
|
+
text("SELECT * FROM trips WHERE id = :id AND user_id = :uid"),
|
|
86
|
+
{"id": str(trip_id), "uid": uid},
|
|
87
|
+
)
|
|
88
|
+
row = result.fetchone()
|
|
89
|
+
if not row:
|
|
90
|
+
raise HTTPException(status_code=404, detail="Trip not found")
|
|
91
|
+
return dict(row._mapping)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@limiter.limit("30/minute")
|
|
95
|
+
@router.patch("/{trip_id}", response_model=TripOut)
|
|
96
|
+
async def update_trip(
|
|
97
|
+
request: Request,
|
|
98
|
+
trip_id: UUID,
|
|
99
|
+
body: TripUpdate,
|
|
100
|
+
uid: Annotated[str, Depends(user_id)],
|
|
101
|
+
db: Annotated[AsyncSession, Depends(get_db)],
|
|
102
|
+
):
|
|
103
|
+
updates = body.model_dump(exclude_none=True)
|
|
104
|
+
if not updates:
|
|
105
|
+
raise HTTPException(status_code=400, detail="Nothing to update")
|
|
106
|
+
|
|
107
|
+
set_clauses = ", ".join(f"{k} = :{k}" for k in updates)
|
|
108
|
+
updates.update({"id": str(trip_id), "uid": uid})
|
|
109
|
+
result = await db.execute(
|
|
110
|
+
text(f"UPDATE trips SET {set_clauses}, updated_at = CURRENT_TIMESTAMP WHERE id = :id AND user_id = :uid RETURNING *"),
|
|
111
|
+
updates,
|
|
112
|
+
)
|
|
113
|
+
row = result.fetchone()
|
|
114
|
+
if not row:
|
|
115
|
+
raise HTTPException(status_code=404, detail="Trip not found")
|
|
116
|
+
await db.commit()
|
|
117
|
+
return dict(row._mapping)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@limiter.limit("30/minute")
|
|
121
|
+
@router.delete("/{trip_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
122
|
+
async def delete_trip(
|
|
123
|
+
request: Request,
|
|
124
|
+
trip_id: UUID,
|
|
125
|
+
uid: Annotated[str, Depends(user_id)],
|
|
126
|
+
db: Annotated[AsyncSession, Depends(get_db)],
|
|
127
|
+
):
|
|
128
|
+
result = await db.execute(
|
|
129
|
+
text("DELETE FROM trips WHERE id = :id AND user_id = :uid"),
|
|
130
|
+
{"id": str(trip_id), "uid": uid},
|
|
131
|
+
)
|
|
132
|
+
await db.commit()
|
|
133
|
+
if result.rowcount == 0:
|
|
134
|
+
raise HTTPException(status_code=404, detail="Trip not found")
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""
|
|
2
|
+
User profile endpoint — returns current user's plan info and upserts user row.
|
|
3
|
+
"""
|
|
4
|
+
import secrets
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
from datetime import datetime, timedelta, timezone
|
|
7
|
+
from fastapi import APIRouter, Depends, HTTPException, Request
|
|
8
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
9
|
+
from sqlalchemy import text
|
|
10
|
+
from app.auth.keycloak import user_id
|
|
11
|
+
from app.db.database import get_db
|
|
12
|
+
|
|
13
|
+
# In-process PIN store (sufficient for single-process; use Redis for multi-replica)
|
|
14
|
+
_pending_links: dict[str, tuple[str, datetime]] = {} # pin -> (uid, expires_at)
|
|
15
|
+
|
|
16
|
+
router = APIRouter(prefix="/users", tags=["users"])
|
|
17
|
+
|
|
18
|
+
@router.get("/me")
|
|
19
|
+
async def me(
|
|
20
|
+
uid: Annotated[str, Depends(user_id)],
|
|
21
|
+
db: Annotated[AsyncSession, Depends(get_db)],
|
|
22
|
+
):
|
|
23
|
+
# Upsert user row (first login creates it)
|
|
24
|
+
await db.execute(
|
|
25
|
+
text("""
|
|
26
|
+
INSERT INTO users (id) VALUES (:uid)
|
|
27
|
+
ON CONFLICT (id) DO NOTHING
|
|
28
|
+
"""),
|
|
29
|
+
{"uid": uid},
|
|
30
|
+
)
|
|
31
|
+
await db.commit()
|
|
32
|
+
|
|
33
|
+
row_check = await db.execute(text("SELECT created_at, updated_at FROM users WHERE id = :uid"), {"uid": uid})
|
|
34
|
+
user_row = row_check.fetchone()
|
|
35
|
+
# Track first login (created_at == updated_at means fresh insert)
|
|
36
|
+
if user_row and str(user_row[0])[:19] == str(user_row[1])[:19]:
|
|
37
|
+
from app.services import telemetry, notifier
|
|
38
|
+
await telemetry.track(db, uid, "user_first_login", {"uid": uid})
|
|
39
|
+
await notifier.notify("tco", "new_user", {"uid": uid[:8] + "..."})
|
|
40
|
+
|
|
41
|
+
row = await db.execute(
|
|
42
|
+
text("""
|
|
43
|
+
SELECT id, email, plan, plan_expires_at, created_at, telegram_chat_id
|
|
44
|
+
FROM users WHERE id = :uid
|
|
45
|
+
"""),
|
|
46
|
+
{"uid": uid},
|
|
47
|
+
)
|
|
48
|
+
r = row.fetchone()
|
|
49
|
+
now = datetime.now(timezone.utc)
|
|
50
|
+
plan = r[2] if r else "free"
|
|
51
|
+
plan_expires_at = r[3] if r else None
|
|
52
|
+
telegram_linked = bool(r[5]) if r else False
|
|
53
|
+
# plan is only active if not expired
|
|
54
|
+
if plan != "free" and plan_expires_at is not None:
|
|
55
|
+
if isinstance(plan_expires_at, str):
|
|
56
|
+
from datetime import datetime as _dt
|
|
57
|
+
try:
|
|
58
|
+
plan_expires_at_dt = _dt.fromisoformat(plan_expires_at.replace("Z", "+00:00"))
|
|
59
|
+
except ValueError:
|
|
60
|
+
plan_expires_at_dt = None
|
|
61
|
+
else:
|
|
62
|
+
plan_expires_at_dt = plan_expires_at
|
|
63
|
+
if plan_expires_at_dt and plan_expires_at_dt < now:
|
|
64
|
+
plan = "free"
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
"id": uid,
|
|
68
|
+
"plan": plan,
|
|
69
|
+
"plan_expires_at": plan_expires_at,
|
|
70
|
+
"free_daily_parses": 50,
|
|
71
|
+
"free_max_trips": 3,
|
|
72
|
+
"is_pro": plan != "free",
|
|
73
|
+
"telegram_linked": telegram_linked,
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@router.post("/telegram-pin")
|
|
78
|
+
async def generate_telegram_pin(
|
|
79
|
+
uid: Annotated[str, Depends(user_id)],
|
|
80
|
+
):
|
|
81
|
+
"""Generate a 6-digit PIN valid 10 min to link Telegram account."""
|
|
82
|
+
pin = str(secrets.randbelow(900000) + 100000) # 100000-999999
|
|
83
|
+
expires = datetime.now(timezone.utc) + timedelta(minutes=10)
|
|
84
|
+
_pending_links[pin] = (uid, expires)
|
|
85
|
+
return {"pin": pin, "expires_in_seconds": 600}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@router.post("/telegram-link")
|
|
89
|
+
async def confirm_telegram_link(
|
|
90
|
+
pin: str,
|
|
91
|
+
chat_id: str,
|
|
92
|
+
request: Request,
|
|
93
|
+
db: Annotated[AsyncSession, Depends(get_db)],
|
|
94
|
+
):
|
|
95
|
+
"""Called by the bot: validates PIN and stores chat_id. Requires X-Bot-Token header."""
|
|
96
|
+
from app.config import get_settings as _gs
|
|
97
|
+
s = _gs()
|
|
98
|
+
bot_token = request.headers.get("X-Bot-Token", "")
|
|
99
|
+
if not s.telegram_bot_token or bot_token != s.telegram_bot_token:
|
|
100
|
+
raise HTTPException(status_code=401, detail="Unauthorized")
|
|
101
|
+
entry = _pending_links.get(pin)
|
|
102
|
+
if not entry:
|
|
103
|
+
raise HTTPException(status_code=404, detail="PIN not found or expired")
|
|
104
|
+
uid, expires = entry
|
|
105
|
+
if datetime.now(timezone.utc) > expires:
|
|
106
|
+
del _pending_links[pin]
|
|
107
|
+
raise HTTPException(status_code=410, detail="PIN expired")
|
|
108
|
+
del _pending_links[pin]
|
|
109
|
+
await db.execute(
|
|
110
|
+
text("UPDATE users SET telegram_chat_id = :cid WHERE id = :uid"),
|
|
111
|
+
{"cid": chat_id, "uid": uid},
|
|
112
|
+
)
|
|
113
|
+
await db.commit()
|
|
114
|
+
return {"ok": True, "uid": uid}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
from pydantic import BaseModel as _BaseModel
|
|
118
|
+
|
|
119
|
+
class TelegramNotifyBody(_BaseModel):
|
|
120
|
+
message: str
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@router.post("/telegram-notify")
|
|
124
|
+
async def telegram_notify(
|
|
125
|
+
uid: Annotated[str, Depends(user_id)],
|
|
126
|
+
db: Annotated[AsyncSession, Depends(get_db)],
|
|
127
|
+
body: TelegramNotifyBody,
|
|
128
|
+
):
|
|
129
|
+
"""Send a Telegram message to the current user's linked chat.
|
|
130
|
+
Used by MCP tools and external integrations."""
|
|
131
|
+
from app.services import notifier as notifier_svc
|
|
132
|
+
await notifier_svc.notify_user(db, uid, body.message)
|
|
133
|
+
return {"ok": True}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@router.delete("/telegram-unlink")
|
|
137
|
+
async def telegram_unlink(
|
|
138
|
+
uid: Annotated[str, Depends(user_id)],
|
|
139
|
+
db: Annotated[AsyncSession, Depends(get_db)],
|
|
140
|
+
):
|
|
141
|
+
"""Unlink Telegram from the current user account."""
|
|
142
|
+
await db.execute(
|
|
143
|
+
text("UPDATE users SET telegram_chat_id = NULL WHERE id = :uid"),
|
|
144
|
+
{"uid": uid},
|
|
145
|
+
)
|
|
146
|
+
await db.commit()
|
|
147
|
+
return {"ok": True}
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@router.get("/by-telegram/{chat_id}")
|
|
151
|
+
async def get_user_by_telegram(
|
|
152
|
+
chat_id: str,
|
|
153
|
+
request: Request,
|
|
154
|
+
db: Annotated[AsyncSession, Depends(get_db)],
|
|
155
|
+
):
|
|
156
|
+
"""Bot-internal: get user's trips by telegram chat_id. Authenticated by bot token header."""
|
|
157
|
+
from app.config import get_settings
|
|
158
|
+
s = get_settings()
|
|
159
|
+
bot_token = request.headers.get("X-Bot-Token", "")
|
|
160
|
+
if not s.telegram_bot_token or bot_token != s.telegram_bot_token:
|
|
161
|
+
raise HTTPException(status_code=401, detail="Unauthorized")
|
|
162
|
+
result = await db.execute(
|
|
163
|
+
text("SELECT id, email, plan FROM users WHERE telegram_chat_id = :cid"),
|
|
164
|
+
{"cid": chat_id},
|
|
165
|
+
)
|
|
166
|
+
row = result.fetchone()
|
|
167
|
+
if not row:
|
|
168
|
+
raise HTTPException(status_code=404, detail="Not linked")
|
|
169
|
+
return {"id": row[0], "email": row[1], "plan": row[2]}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from typing import Annotated
|
|
2
|
+
from fastapi import APIRouter, Depends, Request
|
|
3
|
+
from pydantic import BaseModel, EmailStr
|
|
4
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
5
|
+
from sqlalchemy import text
|
|
6
|
+
from app.db.database import get_db
|
|
7
|
+
from app.services import notifier, email as email_svc
|
|
8
|
+
from app.limiter import limiter
|
|
9
|
+
|
|
10
|
+
router = APIRouter(prefix="/waitlist", tags=["waitlist"])
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class WaitlistBody(BaseModel):
|
|
14
|
+
email: EmailStr
|
|
15
|
+
source: str = "landing"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@limiter.limit("5/minute")
|
|
19
|
+
@router.post("")
|
|
20
|
+
async def join_waitlist(
|
|
21
|
+
request: Request,
|
|
22
|
+
body: WaitlistBody,
|
|
23
|
+
db: Annotated[AsyncSession, Depends(get_db)],
|
|
24
|
+
):
|
|
25
|
+
try:
|
|
26
|
+
await db.execute(
|
|
27
|
+
text("INSERT INTO waitlist (email, source) VALUES (:email, :source)"),
|
|
28
|
+
{"email": body.email.lower(), "source": body.source},
|
|
29
|
+
)
|
|
30
|
+
await db.commit()
|
|
31
|
+
# Fire-and-forget: notification + welcome email
|
|
32
|
+
await notifier.notify("tco", "waitlist_signup", {"email": body.email.lower(), "source": body.source})
|
|
33
|
+
await email_svc.send_waitlist_welcome(body.email.lower())
|
|
34
|
+
return {"joined": True}
|
|
35
|
+
except Exception:
|
|
36
|
+
await db.rollback()
|
|
37
|
+
return {"already_joined": True}
|
|
File without changes
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Cachly Redis cache for Ollama parse results.
|
|
3
|
+
|
|
4
|
+
When CACHLY_REDIS_URL is configured, parsed documents are stored by
|
|
5
|
+
content hash (SHA-256). Uploading the same PDF or text twice returns
|
|
6
|
+
the cached result instantly — no second AI call.
|
|
7
|
+
|
|
8
|
+
Hit/miss counters are stored in Redis (tco:stats:hits / tco:stats:misses)
|
|
9
|
+
so they survive restarts and are visible in the /api/v1/cache/stats endpoint.
|
|
10
|
+
|
|
11
|
+
Cache key format: tco:parse:{hex16}
|
|
12
|
+
Stats keys: tco:stats:hits, tco:stats:misses
|
|
13
|
+
TTL: 7 days (parse results are deterministic for the same input)
|
|
14
|
+
"""
|
|
15
|
+
import hashlib
|
|
16
|
+
import json
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
_redis_client: Any = None
|
|
20
|
+
_redis_available = False
|
|
21
|
+
|
|
22
|
+
CACHE_TTL = 7 * 24 * 3600 # 7 days
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _get_client():
|
|
26
|
+
"""Lazy-init Redis client. Returns None if Cachly is not configured."""
|
|
27
|
+
global _redis_client, _redis_available
|
|
28
|
+
if _redis_client is not None:
|
|
29
|
+
return _redis_client if _redis_available else None
|
|
30
|
+
|
|
31
|
+
import os
|
|
32
|
+
redis_url = os.getenv("CACHLY_REDIS_URL", "")
|
|
33
|
+
if not redis_url:
|
|
34
|
+
_redis_available = False
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
try:
|
|
38
|
+
import redis.asyncio as aioredis
|
|
39
|
+
_redis_client = aioredis.from_url(redis_url, decode_responses=True, socket_connect_timeout=3)
|
|
40
|
+
_redis_available = True
|
|
41
|
+
return _redis_client
|
|
42
|
+
except Exception:
|
|
43
|
+
_redis_available = False
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _cache_key(content: str | bytes) -> str:
|
|
48
|
+
if isinstance(content, str):
|
|
49
|
+
content = content.encode()
|
|
50
|
+
digest = hashlib.sha256(content).hexdigest()[:16]
|
|
51
|
+
return f"tco:parse:{digest}"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
async def cache_get(content: str | bytes) -> dict | None:
|
|
55
|
+
"""Return cached parse result for this content, or None on miss/error.
|
|
56
|
+
Increments tco:stats:hits on hit, tco:stats:misses on miss."""
|
|
57
|
+
client = _get_client()
|
|
58
|
+
if client is None:
|
|
59
|
+
return None
|
|
60
|
+
try:
|
|
61
|
+
key = _cache_key(content)
|
|
62
|
+
raw = await client.get(key)
|
|
63
|
+
if raw is None:
|
|
64
|
+
await client.incr("tco:stats:misses")
|
|
65
|
+
return None
|
|
66
|
+
await client.incr("tco:stats:hits")
|
|
67
|
+
return json.loads(raw)
|
|
68
|
+
except Exception:
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
async def cache_set(content: str | bytes, parsed: dict) -> None:
|
|
73
|
+
"""Store parse result. Fire-and-forget — never blocks or raises."""
|
|
74
|
+
client = _get_client()
|
|
75
|
+
if client is None:
|
|
76
|
+
return
|
|
77
|
+
try:
|
|
78
|
+
key = _cache_key(content)
|
|
79
|
+
await client.set(key, json.dumps(parsed), ex=CACHE_TTL)
|
|
80
|
+
except Exception:
|
|
81
|
+
pass
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
async def cache_stats() -> dict:
|
|
85
|
+
"""Return cache statistics. Returns zeros if Redis is not configured."""
|
|
86
|
+
client = _get_client()
|
|
87
|
+
configured = cachly_configured()
|
|
88
|
+
if client is None:
|
|
89
|
+
return {
|
|
90
|
+
"configured": configured,
|
|
91
|
+
"hits": 0,
|
|
92
|
+
"misses": 0,
|
|
93
|
+
"key_count": 0,
|
|
94
|
+
"hit_rate": 0.0,
|
|
95
|
+
"ttl_days": CACHE_TTL // 86400,
|
|
96
|
+
}
|
|
97
|
+
try:
|
|
98
|
+
hits_raw, misses_raw = await client.mget("tco:stats:hits", "tco:stats:misses")
|
|
99
|
+
hits = int(hits_raw or 0)
|
|
100
|
+
misses = int(misses_raw or 0)
|
|
101
|
+
total = hits + misses
|
|
102
|
+
hit_rate = round(hits / total, 4) if total > 0 else 0.0
|
|
103
|
+
|
|
104
|
+
# count tco:parse:* keys — SCAN is O(1) per call, safe on large keyspaces
|
|
105
|
+
key_count = 0
|
|
106
|
+
async for _ in client.scan_iter("tco:parse:*", count=100):
|
|
107
|
+
key_count += 1
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
"configured": True,
|
|
111
|
+
"hits": hits,
|
|
112
|
+
"misses": misses,
|
|
113
|
+
"key_count": key_count,
|
|
114
|
+
"hit_rate": hit_rate,
|
|
115
|
+
"ttl_days": CACHE_TTL // 86400,
|
|
116
|
+
}
|
|
117
|
+
except Exception:
|
|
118
|
+
return {
|
|
119
|
+
"configured": configured,
|
|
120
|
+
"hits": 0,
|
|
121
|
+
"misses": 0,
|
|
122
|
+
"key_count": 0,
|
|
123
|
+
"hit_rate": 0.0,
|
|
124
|
+
"ttl_days": CACHE_TTL // 86400,
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def cachly_configured() -> bool:
|
|
129
|
+
"""True if CACHLY_REDIS_URL is set."""
|
|
130
|
+
import os
|
|
131
|
+
return bool(os.getenv("CACHLY_REDIS_URL", ""))
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Email drip sequence for waitlist signups.
|
|
3
|
+
Triggered manually via admin endpoint or a cron job.
|
|
4
|
+
|
|
5
|
+
Sequence:
|
|
6
|
+
Day 0 — welcome email (sent at signup, already handled in waitlist.py)
|
|
7
|
+
Day 3 — "Import your first document" nudge
|
|
8
|
+
Day 7 — "Upgrade to Pro" offer
|
|
9
|
+
Day 14 — "What are you organising?" feedback request
|
|
10
|
+
"""
|
|
11
|
+
from datetime import datetime, timedelta, timezone
|
|
12
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
13
|
+
from sqlalchemy import text
|
|
14
|
+
from app.services import email as email_svc
|
|
15
|
+
from app.config import get_settings as _get_settings
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
SEQUENCE = [
|
|
19
|
+
(3, "day3_nudge"),
|
|
20
|
+
(7, "day7_upgrade"),
|
|
21
|
+
(14, "day14_feedback"),
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
SUBJECTS = {
|
|
25
|
+
"day3_nudge": "Hast du schon dein erstes Dokument importiert? ✈️",
|
|
26
|
+
"day7_upgrade": "Unbegrenzte Trips mit TCO Pro 🚀",
|
|
27
|
+
"day14_feedback": "Was organisierst du mit TCO? Wir wollen's wissen 💬",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
BODIES = {
|
|
31
|
+
"day3_nudge": """
|
|
32
|
+
<p>Hey,</p>
|
|
33
|
+
<p>du hast dich vor 3 Tagen für Travel Chaos Organizer angemeldet – super!</p>
|
|
34
|
+
<p>Hast du schon versucht, ein Buchungsbestätigungs-PDF oder Screenshot zu importieren?
|
|
35
|
+
Einfach in der App auf <strong>„Dokument importieren"</strong> tippen –
|
|
36
|
+
unsere KI erkennt Flüge, Hotels und Mietwagen automatisch.</p>
|
|
37
|
+
<p>Viel Spaß beim Aufräumen des Reisechaos 🗂️</p>
|
|
38
|
+
""",
|
|
39
|
+
"day7_upgrade": """
|
|
40
|
+
<p>Hey,</p>
|
|
41
|
+
<p>nach einer Woche mit TCO: wie läuft's?</p>
|
|
42
|
+
<p>Mit dem <strong>kostenlosen Plan</strong> hast du 3 Trips und 50 KI-Parses pro Tag.
|
|
43
|
+
Für Vielreisende gibt's <strong>TCO Pro</strong>: unbegrenzte Trips, unbegrenzte Parses.</p>
|
|
44
|
+
<p><a href="{upgrade_url}" style="background:#4f46e5;color:#fff;padding:10px 20px;border-radius:8px;text-decoration:none;display:inline-block;">Jetzt upgraden →</a></p>
|
|
45
|
+
""",
|
|
46
|
+
"day14_feedback": """
|
|
47
|
+
<p>Hey,</p>
|
|
48
|
+
<p>du nutzt TCO jetzt seit 2 Wochen – wir würden gerne wissen, was du organisierst
|
|
49
|
+
und was wir verbessern können.</p>
|
|
50
|
+
<p>Einfach auf diese Mail antworten – jede Rückmeldung hilft uns sehr!</p>
|
|
51
|
+
""",
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
async def run_drip(db: AsyncSession, dry_run: bool = False) -> dict:
|
|
56
|
+
"""
|
|
57
|
+
Check all waitlist signups and send due emails.
|
|
58
|
+
Tracks sent emails in the events table to avoid duplicates.
|
|
59
|
+
Returns a dict with counts.
|
|
60
|
+
"""
|
|
61
|
+
sent = 0
|
|
62
|
+
skipped = 0
|
|
63
|
+
now = datetime.now(timezone.utc)
|
|
64
|
+
|
|
65
|
+
rows = await db.execute(text("SELECT id, email, created_at FROM waitlist ORDER BY created_at ASC"))
|
|
66
|
+
signups = rows.fetchall()
|
|
67
|
+
|
|
68
|
+
_s = _get_settings()
|
|
69
|
+
_app_url = getattr(_s, 'app_url', 'https://tco.app')
|
|
70
|
+
_upgrade_url = getattr(_s, 'stripe_success_url', 'https://tco.app/upgrade').replace('/upgrade/success', '/upgrade')
|
|
71
|
+
|
|
72
|
+
for signup in signups:
|
|
73
|
+
signup_id, email, created_at = signup
|
|
74
|
+
if isinstance(created_at, str):
|
|
75
|
+
from datetime import datetime as _dt
|
|
76
|
+
try:
|
|
77
|
+
created_at = _dt.fromisoformat(created_at.replace("Z", "+00:00"))
|
|
78
|
+
except ValueError:
|
|
79
|
+
continue
|
|
80
|
+
if created_at.tzinfo is None:
|
|
81
|
+
created_at = created_at.replace(tzinfo=timezone.utc)
|
|
82
|
+
|
|
83
|
+
days_since = (now - created_at).days
|
|
84
|
+
|
|
85
|
+
for day, event_name in SEQUENCE:
|
|
86
|
+
if days_since < day:
|
|
87
|
+
continue
|
|
88
|
+
|
|
89
|
+
# Check if already sent
|
|
90
|
+
already = await db.execute(
|
|
91
|
+
text("""
|
|
92
|
+
SELECT 1 FROM events
|
|
93
|
+
WHERE user_id IS NULL
|
|
94
|
+
AND event_name = :name
|
|
95
|
+
AND properties::text LIKE :email_pat
|
|
96
|
+
LIMIT 1
|
|
97
|
+
"""),
|
|
98
|
+
{"name": f"drip_{event_name}", "email_pat": f'%{email}%'},
|
|
99
|
+
)
|
|
100
|
+
if already.fetchone():
|
|
101
|
+
skipped += 1
|
|
102
|
+
continue
|
|
103
|
+
|
|
104
|
+
if not dry_run:
|
|
105
|
+
subject = SUBJECTS[event_name]
|
|
106
|
+
body = BODIES[event_name].format(upgrade_url=_upgrade_url)
|
|
107
|
+
html = f"""
|
|
108
|
+
<div style="font-family:system-ui,sans-serif;max-width:560px;margin:0 auto;padding:24px">
|
|
109
|
+
<img src="{_app_url}/logo.png" height="32" style="margin-bottom:24px">
|
|
110
|
+
{body}
|
|
111
|
+
<hr style="border:none;border-top:1px solid #eee;margin:24px 0">
|
|
112
|
+
<p style="color:#999;font-size:12px">Travel Chaos Organizer · <a href="{_app_url}">tco.app</a></p>
|
|
113
|
+
</div>"""
|
|
114
|
+
await email_svc._send(email, subject, html)
|
|
115
|
+
|
|
116
|
+
# Track as sent
|
|
117
|
+
import json
|
|
118
|
+
await db.execute(
|
|
119
|
+
text("""
|
|
120
|
+
INSERT INTO events (user_id, event_name, properties)
|
|
121
|
+
VALUES (NULL, :name, :props)
|
|
122
|
+
"""),
|
|
123
|
+
{"name": f"drip_{event_name}", "props": json.dumps({"email": email, "signup_id": str(signup_id)})},
|
|
124
|
+
)
|
|
125
|
+
await db.commit()
|
|
126
|
+
sent += 1
|
|
127
|
+
|
|
128
|
+
return {"sent": sent, "skipped": skipped, "dry_run": dry_run}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Transactional email via Resend API.
|
|
3
|
+
No-op when RESEND_API_KEY is not set.
|
|
4
|
+
"""
|
|
5
|
+
import os
|
|
6
|
+
from typing import Any
|
|
7
|
+
from app.config import get_settings
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _configured() -> bool:
|
|
11
|
+
return bool(os.getenv("RESEND_API_KEY", ""))
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
async def send_waitlist_welcome(to_email: str) -> None:
|
|
15
|
+
"""Send welcome email after waitlist signup."""
|
|
16
|
+
if not _configured():
|
|
17
|
+
return
|
|
18
|
+
try:
|
|
19
|
+
import httpx
|
|
20
|
+
api_key = os.getenv("RESEND_API_KEY", "")
|
|
21
|
+
from_addr = os.getenv("RESEND_FROM", "noreply@tco.app")
|
|
22
|
+
async with httpx.AsyncClient(timeout=10) as client:
|
|
23
|
+
await client.post(
|
|
24
|
+
"https://api.resend.com/emails",
|
|
25
|
+
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
|
|
26
|
+
json={
|
|
27
|
+
"from": from_addr,
|
|
28
|
+
"to": [to_email],
|
|
29
|
+
"subject": "Du bist auf der Liste 🗺️",
|
|
30
|
+
"html": _welcome_html(to_email),
|
|
31
|
+
},
|
|
32
|
+
)
|
|
33
|
+
except Exception:
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
async def _send(to: str, subject: str, html: str) -> None:
|
|
38
|
+
"""Generic send — used by drip and other services."""
|
|
39
|
+
s = get_settings()
|
|
40
|
+
if not s.resend_api_key:
|
|
41
|
+
return
|
|
42
|
+
import httpx
|
|
43
|
+
async with httpx.AsyncClient() as client:
|
|
44
|
+
await client.post(
|
|
45
|
+
"https://api.resend.com/emails",
|
|
46
|
+
headers={"Authorization": f"Bearer {s.resend_api_key}", "Content-Type": "application/json"},
|
|
47
|
+
json={"from": s.resend_from, "to": [to], "subject": subject, "html": html},
|
|
48
|
+
timeout=10,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _welcome_html(email: str) -> str:
|
|
53
|
+
return f"""
|
|
54
|
+
<!DOCTYPE html>
|
|
55
|
+
<html>
|
|
56
|
+
<body style="font-family:sans-serif;background:#0f0f1a;color:#e2e8f0;padding:40px;max-width:560px;margin:0 auto">
|
|
57
|
+
<h1 style="color:#a5b4fc;font-size:24px;margin-bottom:8px">🗺️ Du bist auf der Liste.</h1>
|
|
58
|
+
<p style="color:#6666aa;margin-bottom:24px">Wir melden uns, sobald Travel Chaos Organizer bereit ist.</p>
|
|
59
|
+
<p style="font-size:14px;color:#3a3a5e">Registriert mit: {email}</p>
|
|
60
|
+
</body>
|
|
61
|
+
</html>"""
|