@cachly-dev/mcp-server 0.10.56 → 0.10.57

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