From fc7d8b5d1225e7887b38b53d14bb36ff3ebd5d7c Mon Sep 17 00:00:00 2001 From: Maurice Date: Sat, 30 May 2026 02:39:26 +0200 Subject: [PATCH] Migrate TREK 3 to NestJS + React 19 with a shared Zod contract layer Brownfield strangler migration of the backend onto NestJS modules (auth, trips, days, places, assignments, packing, todo, budget, reservations, collab, files, photos, journey, share, settings, backup, oidc, oauth, admin, atlas, vacay, weather, airports, maps, categories, tags, notifications, system-notices) served through a per-prefix dispatcher, keeping the existing SQLite/better-sqlite3 DB and JWT httpOnly cookie auth, with behavioural parity for every route. Client: React 19 upgrade, "page = wiring container + data hook" pattern across all pages, per-domain Zustand stores bound to @trek/shared contracts, and decomposition of the large components (DayPlanSidebar, PackingListPanel, CollabNotes, FileManager, MemoriesPanel, PlacesSidebar, CollabChat, SystemNoticeModal, BudgetPanel, PlaceFormModal, ...) into focused render units backed by in-file hooks. Apply the shared global request pipeline (helmet/CSP, CORS, HSTS, forced HTTPS, the global MFA policy and request logging) to the NestJS instance as well, so a migrated route is protected identically to the legacy fallback rather than bypassing it. --- client/.gitignore | 5 + client/e2e/auth.setup.ts | 42 + client/e2e/create-trip.spec.ts | 25 + client/e2e/dashboard.spec.ts | 10 + client/e2e/login.public.spec.ts | 8 + client/e2e/server-launch.mjs | 43 + client/e2e/trip-planner.spec.ts | 23 + client/package.json | 36 +- client/playwright.config.ts | 57 + client/scripts/check-page-pattern.mjs | 44 + client/src/api/client.ts | 205 +- client/src/components/Budget/BudgetPanel.tsx | 48 +- client/src/components/Collab/CollabChat.tsx | 186 +- client/src/components/Collab/CollabNotes.tsx | 545 +- client/src/components/Files/FileManager.tsx | 1149 ++-- client/src/components/Layout/PageShell.tsx | 42 + .../src/components/Memories/MemoriesPanel.tsx | 1024 ++-- .../components/Packing/PackingListPanel.tsx | 943 +-- .../src/components/Planner/DayDetailPanel.tsx | 280 +- .../src/components/Planner/DayPlanSidebar.tsx | 294 +- .../src/components/Planner/PlaceFormModal.tsx | 125 +- .../src/components/Planner/PlaceInspector.tsx | 702 +-- .../src/components/Planner/PlacesSidebar.tsx | 1090 ++-- client/src/components/Planner/useDayDetail.ts | 102 + .../components/Settings/IntegrationsTab.tsx | 44 +- .../SystemNotices/SystemNoticeModal.tsx | 413 +- client/src/components/Todo/TodoListPanel.tsx | 233 +- client/src/components/Todo/TodoRow.tsx | 118 + client/src/components/Todo/todoListModel.ts | 24 + client/src/components/Todo/useTodoList.ts | 111 + client/src/components/shared/Spinner.tsx | 33 + client/src/i18n/TransHtml.tsx | 58 + client/src/i18n/TranslationContext.tsx | 42 +- client/src/i18n/index.ts | 1 + client/src/pages/AdminPage.tsx | 381 +- client/src/pages/AtlasPage.tsx | 737 +-- client/src/pages/DashboardPage.tsx | 243 +- client/src/pages/FilesPage.tsx | 70 +- client/src/pages/ForgotPasswordPage.tsx | 37 +- client/src/pages/InAppNotificationsPage.tsx | 61 +- client/src/pages/JourneyDetailPage.tsx | 271 +- client/src/pages/JourneyPage.test.tsx | 34 + client/src/pages/JourneyPage.tsx | 109 +- client/src/pages/JourneyPublicPage.tsx | 142 +- client/src/pages/LoginPage.tsx | 268 +- client/src/pages/OAuthAuthorizePage.tsx | 146 +- client/src/pages/PATTERN.md | 66 + client/src/pages/PhotosPage.tsx | 79 +- client/src/pages/RegisterPage.tsx | 47 +- client/src/pages/ResetPasswordPage.tsx | 57 +- client/src/pages/SettingsPage.tsx | 41 +- client/src/pages/SharedTripPage.tsx | 18 +- client/src/pages/TripPlannerPage.tsx | 613 +- client/src/pages/VacayPage.tsx | 69 +- client/src/pages/admin/adminModel.ts | 38 + client/src/pages/admin/useAdmin.ts | 357 ++ client/src/pages/atlas/atlasModel.ts | 66 + client/src/pages/atlas/useAtlas.ts | 691 +++ client/src/pages/dashboard/dashboardModel.ts | 72 + client/src/pages/dashboard/useDashboard.ts | 186 + client/src/pages/files/useFiles.ts | 57 + .../pages/forgotPassword/useForgotPassword.ts | 43 + .../useInAppNotifications.ts | 47 + client/src/pages/journey/useJourney.ts | 108 + .../pages/journeyDetail/useJourneyDetail.ts | 290 + .../pages/journeyPublic/journeyPublicModel.ts | 52 + .../pages/journeyPublic/useJourneyPublic.ts | 108 + client/src/pages/login/useLogin.ts | 275 + .../pages/oauthAuthorize/useOAuthAuthorize.ts | 156 + client/src/pages/photos/usePhotos.ts | 66 + client/src/pages/register/useRegister.ts | 54 + .../pages/resetPassword/useResetPassword.ts | 66 + client/src/pages/settings/useSettings.ts | 37 + client/src/pages/sharedTrip/useSharedTrip.ts | 28 + .../src/pages/tripPlanner/useTripPlanner.ts | 641 ++ client/src/pages/vacay/useVacay.ts | 58 + client/src/store/inAppNotificationStore.ts | 4 + client/src/store/systemNoticeStore.ts | 35 +- client/src/store/vacayStore.ts | 21 +- client/src/utils/reorder.ts | 12 - package-lock.json | 5132 +++++------------ package.json | 5 + server/src/app.ts | 144 +- server/src/db/database.ts | 7 + server/src/index.ts | 12 +- server/src/mcp/tools/prompts.ts | 18 +- server/src/middleware/globalMiddleware.ts | 163 + server/src/nest/README.md | 28 +- server/src/nest/admin/admin.controller.ts | 339 ++ server/src/nest/admin/admin.module.ts | 9 + server/src/nest/admin/admin.service.ts | 79 + .../src/nest/airports/airports.controller.ts | 38 + server/src/nest/airports/airports.module.ts | 10 + server/src/nest/airports/airports.service.ts | 19 + server/src/nest/app.module.ts | 36 +- .../assignments/assignments.controller.ts | 189 + .../nest/assignments/assignments.module.ts | 14 + .../nest/assignments/assignments.service.ts | 83 + server/src/nest/atlas/atlas.controller.ts | 142 + server/src/nest/atlas/atlas.module.ts | 10 + server/src/nest/atlas/atlas.service.ts | 75 + server/src/nest/auth/admin.guard.ts | 3 +- .../src/nest/auth/auth-public.controller.ts | 156 + server/src/nest/auth/auth.controller.ts | 267 + server/src/nest/auth/auth.module.ts | 17 + server/src/nest/auth/auth.service.ts | 61 + server/src/nest/auth/cookie-auth.guard.ts | 26 + server/src/nest/auth/jwt-auth.guard.ts | 2 +- server/src/nest/auth/optional-jwt.guard.ts | 19 + server/src/nest/auth/rate-limit.service.ts | 45 + server/src/nest/backup/backup.controller.ts | 167 + server/src/nest/backup/backup.module.ts | 9 + server/src/nest/backup/backup.service.ts | 24 + server/src/nest/budget/budget.controller.ts | 183 + server/src/nest/budget/budget.module.ts | 10 + server/src/nest/budget/budget.service.ts | 89 + .../nest/categories/categories.controller.ts | 65 + .../src/nest/categories/categories.module.ts | 10 + .../src/nest/categories/categories.service.ts | 37 + server/src/nest/collab/collab.controller.ts | 299 + server/src/nest/collab/collab.module.ts | 9 + server/src/nest/collab/collab.service.ts | 63 + .../nest/common/idempotency.interceptor.ts | 90 + server/src/nest/config/config.controller.ts | 18 + server/src/nest/config/config.module.ts | 8 + server/src/nest/days/day-notes.controller.ts | 127 + server/src/nest/days/day-notes.service.ts | 50 + server/src/nest/days/days.controller.ts | 100 + server/src/nest/days/days.module.ts | 16 + server/src/nest/days/days.service.ts | 49 + .../nest/files/files-download.controller.ts | 53 + server/src/nest/files/files.controller.ts | 224 + server/src/nest/files/files.module.ts | 10 + server/src/nest/files/files.service.ts | 49 + .../src/nest/journey/journey-addon.guard.ts | 20 + .../nest/journey/journey-public.controller.ts | 76 + server/src/nest/journey/journey.controller.ts | 419 ++ server/src/nest/journey/journey.module.ts | 11 + server/src/nest/journey/journey.service.ts | 85 + server/src/nest/maps/maps.controller.ts | 198 + server/src/nest/maps/maps.module.ts | 10 + server/src/nest/maps/maps.service.ts | 89 + .../notifications/notifications.controller.ts | 188 + .../notifications/notifications.module.ts | 10 + .../notifications/notifications.service.ts | 107 + server/src/nest/oauth/oauth-api.controller.ts | 178 + .../src/nest/oauth/oauth-public.controller.ts | 165 + server/src/nest/oauth/oauth.module.ts | 17 + server/src/nest/oauth/oauth.service.ts | 36 + server/src/nest/oidc/oidc.controller.ts | 142 + server/src/nest/oidc/oidc.module.ts | 9 + server/src/nest/oidc/oidc.service.ts | 31 + server/src/nest/packing/packing.controller.ts | 273 + server/src/nest/packing/packing.module.ts | 10 + server/src/nest/packing/packing.service.ts | 104 + server/src/nest/photos/photos.controller.ts | 55 + server/src/nest/photos/photos.module.ts | 9 + server/src/nest/photos/photos.service.ts | 24 + server/src/nest/places/places.controller.ts | 282 + server/src/nest/places/places.module.ts | 10 + server/src/nest/places/places.service.ts | 79 + .../reservations/accommodations.controller.ts | 133 + .../reservations/accommodations.service.ts | 54 + .../reservations/reservations.controller.ts | 145 + .../nest/reservations/reservations.module.ts | 15 + .../nest/reservations/reservations.service.ts | 114 + .../src/nest/settings/settings.controller.ts | 53 + server/src/nest/settings/settings.module.ts | 9 + server/src/nest/settings/settings.service.ts | 13 + server/src/nest/share/share.controller.ts | 83 + server/src/nest/share/share.module.ts | 9 + server/src/nest/share/share.service.ts | 29 + server/src/nest/strangler.ts | 106 +- .../system-notices.controller.ts | 34 + .../system-notices/system-notices.module.ts | 10 + .../system-notices/system-notices.service.ts | 19 + server/src/nest/tags/tags.controller.ts | 60 + server/src/nest/tags/tags.module.ts | 10 + server/src/nest/tags/tags.service.ts | 36 + server/src/nest/todo/todo.controller.ts | 138 + server/src/nest/todo/todo.module.ts | 10 + server/src/nest/todo/todo.service.ts | 55 + server/src/nest/trips/trips.controller.ts | 273 + server/src/nest/trips/trips.module.ts | 11 + server/src/nest/trips/trips.service.ts | 127 + server/src/nest/vacay/vacay.controller.ts | 255 + server/src/nest/vacay/vacay.module.ts | 10 + server/src/nest/vacay/vacay.service.ts | 112 + server/src/services/atlasService.ts | 2 +- server/src/services/authService.ts | 6 +- server/src/services/backupService.ts | 16 +- server/src/services/fileService.ts | 2 +- server/src/services/journeyService.ts | 1 + .../notificationPreferencesService.ts | 5 +- server/src/services/placeService.ts | 2 +- server/src/services/tripService.ts | 2 +- server/src/types.ts | 6 +- server/src/typings/express.d.ts | 21 + server/tests/e2e/admin.e2e.test.ts | 99 + server/tests/e2e/airports.e2e.test.ts | 95 + server/tests/e2e/assignments.e2e.test.ts | 109 + server/tests/e2e/atlas.e2e.test.ts | 124 + server/tests/e2e/auth.e2e.test.ts | 108 + server/tests/e2e/backup.e2e.test.ts | 108 + server/tests/e2e/budget.e2e.test.ts | 107 + server/tests/e2e/categories.e2e.test.ts | 119 + server/tests/e2e/collab.e2e.test.ts | 137 + server/tests/e2e/config.e2e.test.ts | 39 + server/tests/e2e/days.e2e.test.ts | 113 + server/tests/e2e/files.e2e.test.ts | 135 + server/tests/e2e/journey.e2e.test.ts | 119 + server/tests/e2e/maps.e2e.test.ts | 101 + server/tests/e2e/notifications.e2e.test.ts | 116 + server/tests/e2e/oauth.e2e.test.ts | 113 + server/tests/e2e/oidc.e2e.test.ts | 86 + server/tests/e2e/packing.e2e.test.ts | 106 + server/tests/e2e/places.e2e.test.ts | 114 + server/tests/e2e/reservations.e2e.test.ts | 134 + server/tests/e2e/settings.e2e.test.ts | 90 + server/tests/e2e/share.e2e.test.ts | 109 + server/tests/e2e/system-notices.e2e.test.ts | 92 + server/tests/e2e/tags.e2e.test.ts | 111 + server/tests/e2e/todo.e2e.test.ts | 100 + server/tests/e2e/trips.e2e.test.ts | 119 + server/tests/e2e/vacay.e2e.test.ts | 100 + server/tests/integration/auth.test.ts | 25 + server/tests/parity/l10-todo.parity.test.ts | 117 + server/tests/parity/l11-budget.parity.test.ts | 124 + .../parity/l12-reservations.parity.test.ts | 154 + server/tests/parity/l13-days.parity.test.ts | 135 + .../parity/l14-assignments.parity.test.ts | 131 + server/tests/parity/l15-places.parity.test.ts | 138 + server/tests/parity/l16-trips.parity.test.ts | 139 + server/tests/parity/l17-collab.parity.test.ts | 166 + server/tests/parity/l18-files.parity.test.ts | 172 + .../tests/parity/l19-journey.parity.test.ts | 197 + server/tests/parity/l2.parity.test.ts | 130 + server/tests/parity/l20-share.parity.test.ts | 118 + .../tests/parity/l21-settings.parity.test.ts | 77 + server/tests/parity/l22-backup.parity.test.ts | 121 + server/tests/parity/l23-auth.parity.test.ts | 158 + server/tests/parity/l24-oidc.parity.test.ts | 105 + server/tests/parity/l25-oauth.parity.test.ts | 138 + server/tests/parity/l26-admin.parity.test.ts | 182 + server/tests/parity/l3.parity.test.ts | 145 + server/tests/parity/l4.parity.test.ts | 107 + server/tests/parity/l5.parity.test.ts | 104 + server/tests/parity/l6.parity.test.ts | 148 + server/tests/parity/l7.parity.test.ts | 124 + server/tests/parity/l8-vacay.parity.test.ts | 125 + server/tests/parity/l9-packing.parity.test.ts | 136 + server/tests/parity/parity.ts | 3 + .../nest/accommodations.controller.test.ts | 101 + .../tests/unit/nest/admin.controller.test.ts | 135 + .../unit/nest/airports.controller.test.ts | 72 + .../unit/nest/assignments.controller.test.ts | 95 + .../tests/unit/nest/atlas.controller.test.ts | 131 + .../tests/unit/nest/auth.controller.test.ts | 169 + .../tests/unit/nest/backup.controller.test.ts | 140 + .../tests/unit/nest/budget.controller.test.ts | 152 + .../unit/nest/categories.controller.test.ts | 84 + .../tests/unit/nest/collab.controller.test.ts | 174 + .../tests/unit/nest/config.controller.test.ts | 9 + .../tests/unit/nest/days.controller.test.ts | 106 + .../tests/unit/nest/files.controller.test.ts | 182 + .../unit/nest/idempotency.interceptor.test.ts | 147 + .../unit/nest/journey.controller.test.ts | 167 + .../tests/unit/nest/maps.controller.test.ts | 230 + .../nest/notifications.controller.test.ts | 183 + .../tests/unit/nest/oauth.controller.test.ts | 218 + .../tests/unit/nest/oidc.controller.test.ts | 141 + .../unit/nest/packing.controller.test.ts | 149 + .../tests/unit/nest/packing.service.test.ts | 75 + .../tests/unit/nest/places.controller.test.ts | 146 + .../unit/nest/reservations.controller.test.ts | 114 + .../unit/nest/reservations.service.test.ts | 106 + .../unit/nest/settings.controller.test.ts | 54 + .../tests/unit/nest/share.controller.test.ts | 72 + server/tests/unit/nest/strangler.test.ts | 116 +- .../nest/system-notices.controller.test.ts | 55 + .../tests/unit/nest/tags.controller.test.ts | 86 + .../tests/unit/nest/todo.controller.test.ts | 123 + .../tests/unit/nest/trips.controller.test.ts | 173 + server/tests/unit/nest/trips.service.test.ts | 76 + .../tests/unit/nest/vacay.controller.test.ts | 175 + shared/package.json | 13 +- shared/scripts/i18n-parity.mjs | 145 + shared/src/admin/admin.schema.spec.ts | 33 + shared/src/admin/admin.schema.ts | 34 + shared/src/airport/airport.schema.spec.ts | 26 + shared/src/airport/airport.schema.ts | 37 + .../src/assignment/assignment.schema.spec.ts | 29 + shared/src/assignment/assignment.schema.ts | 40 + shared/src/atlas/atlas.schema.spec.ts | 29 + shared/src/atlas/atlas.schema.ts | 57 + shared/src/auth/auth.schema.spec.ts | 47 + shared/src/auth/auth.schema.ts | 57 + shared/src/backup/backup.schema.spec.ts | 14 + shared/src/backup/backup.schema.ts | 19 + shared/src/budget/budget.schema.spec.ts | 36 + shared/src/budget/budget.schema.ts | 56 + shared/src/category/category.schema.spec.ts | 27 + shared/src/category/category.schema.ts | 43 + shared/src/collab/collab.schema.spec.ts | 47 + shared/src/collab/collab.schema.ts | 56 + shared/src/config/config.schema.ts | 14 + shared/src/day/day.schema.spec.ts | 30 + shared/src/day/day.schema.ts | 40 + shared/src/file/file.schema.spec.ts | 26 + shared/src/file/file.schema.ts | 33 + .../src/i18n/externalNotifications/index.ts | 2 + shared/src/i18n/gr/externalNotifications.ts | 64 + shared/src/i18n/gr/settings.ts | 1 - shared/src/i18n/i18n-parity.spec.ts | 32 + shared/src/index.ts | 31 + shared/src/journey/journey.schema.spec.ts | 55 + shared/src/journey/journey.schema.ts | 53 + shared/src/maps/maps.schema.spec.ts | 39 + shared/src/maps/maps.schema.ts | 88 + .../notification/notification.schema.spec.ts | 36 + .../src/notification/notification.schema.ts | 55 + shared/src/oauth/oauth.schema.spec.ts | 25 + shared/src/oauth/oauth.schema.ts | 44 + shared/src/oidc/oidc.schema.spec.ts | 17 + shared/src/oidc/oidc.schema.ts | 21 + shared/src/packing/packing.schema.spec.ts | 35 + shared/src/packing/packing.schema.ts | 70 + shared/src/place/place.schema.spec.ts | 27 + shared/src/place/place.schema.ts | 39 + .../reservation/reservation.schema.spec.ts | 27 + shared/src/reservation/reservation.schema.ts | 44 + shared/src/sanitize/sanitize.spec.ts | 118 + shared/src/sanitize/sanitize.ts | 82 + shared/src/settings/settings.schema.spec.ts | 24 + shared/src/settings/settings.schema.ts | 22 + shared/src/share/share.schema.spec.ts | 13 + shared/src/share/share.schema.ts | 19 + .../system-notice.schema.spec.ts | 42 + .../src/system-notice/system-notice.schema.ts | 58 + shared/src/tag/tag.schema.spec.ts | 21 + shared/src/tag/tag.schema.ts | 41 + shared/src/todo/todo.schema.spec.ts | 30 + shared/src/todo/todo.schema.ts | 42 + shared/src/trip/trip.schema.spec.ts | 28 + shared/src/trip/trip.schema.ts | 49 + shared/src/vacay/vacay.schema.spec.ts | 39 + shared/src/vacay/vacay.schema.ts | 68 + 347 files changed, 31278 insertions(+), 10381 deletions(-) create mode 100644 client/.gitignore create mode 100644 client/e2e/auth.setup.ts create mode 100644 client/e2e/create-trip.spec.ts create mode 100644 client/e2e/dashboard.spec.ts create mode 100644 client/e2e/login.public.spec.ts create mode 100644 client/e2e/server-launch.mjs create mode 100644 client/e2e/trip-planner.spec.ts create mode 100644 client/playwright.config.ts create mode 100644 client/scripts/check-page-pattern.mjs create mode 100644 client/src/components/Layout/PageShell.tsx create mode 100644 client/src/components/Planner/useDayDetail.ts create mode 100644 client/src/components/Todo/TodoRow.tsx create mode 100644 client/src/components/Todo/todoListModel.ts create mode 100644 client/src/components/Todo/useTodoList.ts create mode 100644 client/src/components/shared/Spinner.tsx create mode 100644 client/src/i18n/TransHtml.tsx create mode 100644 client/src/pages/PATTERN.md create mode 100644 client/src/pages/admin/adminModel.ts create mode 100644 client/src/pages/admin/useAdmin.ts create mode 100644 client/src/pages/atlas/atlasModel.ts create mode 100644 client/src/pages/atlas/useAtlas.ts create mode 100644 client/src/pages/dashboard/dashboardModel.ts create mode 100644 client/src/pages/dashboard/useDashboard.ts create mode 100644 client/src/pages/files/useFiles.ts create mode 100644 client/src/pages/forgotPassword/useForgotPassword.ts create mode 100644 client/src/pages/inAppNotifications/useInAppNotifications.ts create mode 100644 client/src/pages/journey/useJourney.ts create mode 100644 client/src/pages/journeyDetail/useJourneyDetail.ts create mode 100644 client/src/pages/journeyPublic/journeyPublicModel.ts create mode 100644 client/src/pages/journeyPublic/useJourneyPublic.ts create mode 100644 client/src/pages/login/useLogin.ts create mode 100644 client/src/pages/oauthAuthorize/useOAuthAuthorize.ts create mode 100644 client/src/pages/photos/usePhotos.ts create mode 100644 client/src/pages/register/useRegister.ts create mode 100644 client/src/pages/resetPassword/useResetPassword.ts create mode 100644 client/src/pages/settings/useSettings.ts create mode 100644 client/src/pages/sharedTrip/useSharedTrip.ts create mode 100644 client/src/pages/tripPlanner/useTripPlanner.ts create mode 100644 client/src/pages/vacay/useVacay.ts delete mode 100644 client/src/utils/reorder.ts create mode 100644 server/src/middleware/globalMiddleware.ts create mode 100644 server/src/nest/admin/admin.controller.ts create mode 100644 server/src/nest/admin/admin.module.ts create mode 100644 server/src/nest/admin/admin.service.ts create mode 100644 server/src/nest/airports/airports.controller.ts create mode 100644 server/src/nest/airports/airports.module.ts create mode 100644 server/src/nest/airports/airports.service.ts create mode 100644 server/src/nest/assignments/assignments.controller.ts create mode 100644 server/src/nest/assignments/assignments.module.ts create mode 100644 server/src/nest/assignments/assignments.service.ts create mode 100644 server/src/nest/atlas/atlas.controller.ts create mode 100644 server/src/nest/atlas/atlas.module.ts create mode 100644 server/src/nest/atlas/atlas.service.ts create mode 100644 server/src/nest/auth/auth-public.controller.ts create mode 100644 server/src/nest/auth/auth.controller.ts create mode 100644 server/src/nest/auth/auth.module.ts create mode 100644 server/src/nest/auth/auth.service.ts create mode 100644 server/src/nest/auth/cookie-auth.guard.ts create mode 100644 server/src/nest/auth/optional-jwt.guard.ts create mode 100644 server/src/nest/auth/rate-limit.service.ts create mode 100644 server/src/nest/backup/backup.controller.ts create mode 100644 server/src/nest/backup/backup.module.ts create mode 100644 server/src/nest/backup/backup.service.ts create mode 100644 server/src/nest/budget/budget.controller.ts create mode 100644 server/src/nest/budget/budget.module.ts create mode 100644 server/src/nest/budget/budget.service.ts create mode 100644 server/src/nest/categories/categories.controller.ts create mode 100644 server/src/nest/categories/categories.module.ts create mode 100644 server/src/nest/categories/categories.service.ts create mode 100644 server/src/nest/collab/collab.controller.ts create mode 100644 server/src/nest/collab/collab.module.ts create mode 100644 server/src/nest/collab/collab.service.ts create mode 100644 server/src/nest/common/idempotency.interceptor.ts create mode 100644 server/src/nest/config/config.controller.ts create mode 100644 server/src/nest/config/config.module.ts create mode 100644 server/src/nest/days/day-notes.controller.ts create mode 100644 server/src/nest/days/day-notes.service.ts create mode 100644 server/src/nest/days/days.controller.ts create mode 100644 server/src/nest/days/days.module.ts create mode 100644 server/src/nest/days/days.service.ts create mode 100644 server/src/nest/files/files-download.controller.ts create mode 100644 server/src/nest/files/files.controller.ts create mode 100644 server/src/nest/files/files.module.ts create mode 100644 server/src/nest/files/files.service.ts create mode 100644 server/src/nest/journey/journey-addon.guard.ts create mode 100644 server/src/nest/journey/journey-public.controller.ts create mode 100644 server/src/nest/journey/journey.controller.ts create mode 100644 server/src/nest/journey/journey.module.ts create mode 100644 server/src/nest/journey/journey.service.ts create mode 100644 server/src/nest/maps/maps.controller.ts create mode 100644 server/src/nest/maps/maps.module.ts create mode 100644 server/src/nest/maps/maps.service.ts create mode 100644 server/src/nest/notifications/notifications.controller.ts create mode 100644 server/src/nest/notifications/notifications.module.ts create mode 100644 server/src/nest/notifications/notifications.service.ts create mode 100644 server/src/nest/oauth/oauth-api.controller.ts create mode 100644 server/src/nest/oauth/oauth-public.controller.ts create mode 100644 server/src/nest/oauth/oauth.module.ts create mode 100644 server/src/nest/oauth/oauth.service.ts create mode 100644 server/src/nest/oidc/oidc.controller.ts create mode 100644 server/src/nest/oidc/oidc.module.ts create mode 100644 server/src/nest/oidc/oidc.service.ts create mode 100644 server/src/nest/packing/packing.controller.ts create mode 100644 server/src/nest/packing/packing.module.ts create mode 100644 server/src/nest/packing/packing.service.ts create mode 100644 server/src/nest/photos/photos.controller.ts create mode 100644 server/src/nest/photos/photos.module.ts create mode 100644 server/src/nest/photos/photos.service.ts create mode 100644 server/src/nest/places/places.controller.ts create mode 100644 server/src/nest/places/places.module.ts create mode 100644 server/src/nest/places/places.service.ts create mode 100644 server/src/nest/reservations/accommodations.controller.ts create mode 100644 server/src/nest/reservations/accommodations.service.ts create mode 100644 server/src/nest/reservations/reservations.controller.ts create mode 100644 server/src/nest/reservations/reservations.module.ts create mode 100644 server/src/nest/reservations/reservations.service.ts create mode 100644 server/src/nest/settings/settings.controller.ts create mode 100644 server/src/nest/settings/settings.module.ts create mode 100644 server/src/nest/settings/settings.service.ts create mode 100644 server/src/nest/share/share.controller.ts create mode 100644 server/src/nest/share/share.module.ts create mode 100644 server/src/nest/share/share.service.ts create mode 100644 server/src/nest/system-notices/system-notices.controller.ts create mode 100644 server/src/nest/system-notices/system-notices.module.ts create mode 100644 server/src/nest/system-notices/system-notices.service.ts create mode 100644 server/src/nest/tags/tags.controller.ts create mode 100644 server/src/nest/tags/tags.module.ts create mode 100644 server/src/nest/tags/tags.service.ts create mode 100644 server/src/nest/todo/todo.controller.ts create mode 100644 server/src/nest/todo/todo.module.ts create mode 100644 server/src/nest/todo/todo.service.ts create mode 100644 server/src/nest/trips/trips.controller.ts create mode 100644 server/src/nest/trips/trips.module.ts create mode 100644 server/src/nest/trips/trips.service.ts create mode 100644 server/src/nest/vacay/vacay.controller.ts create mode 100644 server/src/nest/vacay/vacay.module.ts create mode 100644 server/src/nest/vacay/vacay.service.ts create mode 100644 server/src/typings/express.d.ts create mode 100644 server/tests/e2e/admin.e2e.test.ts create mode 100644 server/tests/e2e/airports.e2e.test.ts create mode 100644 server/tests/e2e/assignments.e2e.test.ts create mode 100644 server/tests/e2e/atlas.e2e.test.ts create mode 100644 server/tests/e2e/auth.e2e.test.ts create mode 100644 server/tests/e2e/backup.e2e.test.ts create mode 100644 server/tests/e2e/budget.e2e.test.ts create mode 100644 server/tests/e2e/categories.e2e.test.ts create mode 100644 server/tests/e2e/collab.e2e.test.ts create mode 100644 server/tests/e2e/config.e2e.test.ts create mode 100644 server/tests/e2e/days.e2e.test.ts create mode 100644 server/tests/e2e/files.e2e.test.ts create mode 100644 server/tests/e2e/journey.e2e.test.ts create mode 100644 server/tests/e2e/maps.e2e.test.ts create mode 100644 server/tests/e2e/notifications.e2e.test.ts create mode 100644 server/tests/e2e/oauth.e2e.test.ts create mode 100644 server/tests/e2e/oidc.e2e.test.ts create mode 100644 server/tests/e2e/packing.e2e.test.ts create mode 100644 server/tests/e2e/places.e2e.test.ts create mode 100644 server/tests/e2e/reservations.e2e.test.ts create mode 100644 server/tests/e2e/settings.e2e.test.ts create mode 100644 server/tests/e2e/share.e2e.test.ts create mode 100644 server/tests/e2e/system-notices.e2e.test.ts create mode 100644 server/tests/e2e/tags.e2e.test.ts create mode 100644 server/tests/e2e/todo.e2e.test.ts create mode 100644 server/tests/e2e/trips.e2e.test.ts create mode 100644 server/tests/e2e/vacay.e2e.test.ts create mode 100644 server/tests/parity/l10-todo.parity.test.ts create mode 100644 server/tests/parity/l11-budget.parity.test.ts create mode 100644 server/tests/parity/l12-reservations.parity.test.ts create mode 100644 server/tests/parity/l13-days.parity.test.ts create mode 100644 server/tests/parity/l14-assignments.parity.test.ts create mode 100644 server/tests/parity/l15-places.parity.test.ts create mode 100644 server/tests/parity/l16-trips.parity.test.ts create mode 100644 server/tests/parity/l17-collab.parity.test.ts create mode 100644 server/tests/parity/l18-files.parity.test.ts create mode 100644 server/tests/parity/l19-journey.parity.test.ts create mode 100644 server/tests/parity/l2.parity.test.ts create mode 100644 server/tests/parity/l20-share.parity.test.ts create mode 100644 server/tests/parity/l21-settings.parity.test.ts create mode 100644 server/tests/parity/l22-backup.parity.test.ts create mode 100644 server/tests/parity/l23-auth.parity.test.ts create mode 100644 server/tests/parity/l24-oidc.parity.test.ts create mode 100644 server/tests/parity/l25-oauth.parity.test.ts create mode 100644 server/tests/parity/l26-admin.parity.test.ts create mode 100644 server/tests/parity/l3.parity.test.ts create mode 100644 server/tests/parity/l4.parity.test.ts create mode 100644 server/tests/parity/l5.parity.test.ts create mode 100644 server/tests/parity/l6.parity.test.ts create mode 100644 server/tests/parity/l7.parity.test.ts create mode 100644 server/tests/parity/l8-vacay.parity.test.ts create mode 100644 server/tests/parity/l9-packing.parity.test.ts create mode 100644 server/tests/unit/nest/accommodations.controller.test.ts create mode 100644 server/tests/unit/nest/admin.controller.test.ts create mode 100644 server/tests/unit/nest/airports.controller.test.ts create mode 100644 server/tests/unit/nest/assignments.controller.test.ts create mode 100644 server/tests/unit/nest/atlas.controller.test.ts create mode 100644 server/tests/unit/nest/auth.controller.test.ts create mode 100644 server/tests/unit/nest/backup.controller.test.ts create mode 100644 server/tests/unit/nest/budget.controller.test.ts create mode 100644 server/tests/unit/nest/categories.controller.test.ts create mode 100644 server/tests/unit/nest/collab.controller.test.ts create mode 100644 server/tests/unit/nest/config.controller.test.ts create mode 100644 server/tests/unit/nest/days.controller.test.ts create mode 100644 server/tests/unit/nest/files.controller.test.ts create mode 100644 server/tests/unit/nest/idempotency.interceptor.test.ts create mode 100644 server/tests/unit/nest/journey.controller.test.ts create mode 100644 server/tests/unit/nest/maps.controller.test.ts create mode 100644 server/tests/unit/nest/notifications.controller.test.ts create mode 100644 server/tests/unit/nest/oauth.controller.test.ts create mode 100644 server/tests/unit/nest/oidc.controller.test.ts create mode 100644 server/tests/unit/nest/packing.controller.test.ts create mode 100644 server/tests/unit/nest/packing.service.test.ts create mode 100644 server/tests/unit/nest/places.controller.test.ts create mode 100644 server/tests/unit/nest/reservations.controller.test.ts create mode 100644 server/tests/unit/nest/reservations.service.test.ts create mode 100644 server/tests/unit/nest/settings.controller.test.ts create mode 100644 server/tests/unit/nest/share.controller.test.ts create mode 100644 server/tests/unit/nest/system-notices.controller.test.ts create mode 100644 server/tests/unit/nest/tags.controller.test.ts create mode 100644 server/tests/unit/nest/todo.controller.test.ts create mode 100644 server/tests/unit/nest/trips.controller.test.ts create mode 100644 server/tests/unit/nest/trips.service.test.ts create mode 100644 server/tests/unit/nest/vacay.controller.test.ts create mode 100644 shared/scripts/i18n-parity.mjs create mode 100644 shared/src/admin/admin.schema.spec.ts create mode 100644 shared/src/admin/admin.schema.ts create mode 100644 shared/src/airport/airport.schema.spec.ts create mode 100644 shared/src/airport/airport.schema.ts create mode 100644 shared/src/assignment/assignment.schema.spec.ts create mode 100644 shared/src/assignment/assignment.schema.ts create mode 100644 shared/src/atlas/atlas.schema.spec.ts create mode 100644 shared/src/atlas/atlas.schema.ts create mode 100644 shared/src/auth/auth.schema.spec.ts create mode 100644 shared/src/auth/auth.schema.ts create mode 100644 shared/src/backup/backup.schema.spec.ts create mode 100644 shared/src/backup/backup.schema.ts create mode 100644 shared/src/budget/budget.schema.spec.ts create mode 100644 shared/src/budget/budget.schema.ts create mode 100644 shared/src/category/category.schema.spec.ts create mode 100644 shared/src/category/category.schema.ts create mode 100644 shared/src/collab/collab.schema.spec.ts create mode 100644 shared/src/collab/collab.schema.ts create mode 100644 shared/src/config/config.schema.ts create mode 100644 shared/src/day/day.schema.spec.ts create mode 100644 shared/src/day/day.schema.ts create mode 100644 shared/src/file/file.schema.spec.ts create mode 100644 shared/src/file/file.schema.ts create mode 100644 shared/src/i18n/gr/externalNotifications.ts create mode 100644 shared/src/i18n/i18n-parity.spec.ts create mode 100644 shared/src/journey/journey.schema.spec.ts create mode 100644 shared/src/journey/journey.schema.ts create mode 100644 shared/src/maps/maps.schema.spec.ts create mode 100644 shared/src/maps/maps.schema.ts create mode 100644 shared/src/notification/notification.schema.spec.ts create mode 100644 shared/src/notification/notification.schema.ts create mode 100644 shared/src/oauth/oauth.schema.spec.ts create mode 100644 shared/src/oauth/oauth.schema.ts create mode 100644 shared/src/oidc/oidc.schema.spec.ts create mode 100644 shared/src/oidc/oidc.schema.ts create mode 100644 shared/src/packing/packing.schema.spec.ts create mode 100644 shared/src/packing/packing.schema.ts create mode 100644 shared/src/place/place.schema.spec.ts create mode 100644 shared/src/place/place.schema.ts create mode 100644 shared/src/reservation/reservation.schema.spec.ts create mode 100644 shared/src/reservation/reservation.schema.ts create mode 100644 shared/src/sanitize/sanitize.spec.ts create mode 100644 shared/src/sanitize/sanitize.ts create mode 100644 shared/src/settings/settings.schema.spec.ts create mode 100644 shared/src/settings/settings.schema.ts create mode 100644 shared/src/share/share.schema.spec.ts create mode 100644 shared/src/share/share.schema.ts create mode 100644 shared/src/system-notice/system-notice.schema.spec.ts create mode 100644 shared/src/system-notice/system-notice.schema.ts create mode 100644 shared/src/tag/tag.schema.spec.ts create mode 100644 shared/src/tag/tag.schema.ts create mode 100644 shared/src/todo/todo.schema.spec.ts create mode 100644 shared/src/todo/todo.schema.ts create mode 100644 shared/src/trip/trip.schema.spec.ts create mode 100644 shared/src/trip/trip.schema.ts create mode 100644 shared/src/vacay/vacay.schema.spec.ts create mode 100644 shared/src/vacay/vacay.schema.ts diff --git a/client/.gitignore b/client/.gitignore new file mode 100644 index 00000000..11215320 --- /dev/null +++ b/client/.gitignore @@ -0,0 +1,5 @@ +# Playwright E2E (FE7) +e2e/.tmp/ +test-results/ +playwright-report/ +playwright/.cache/ diff --git a/client/e2e/auth.setup.ts b/client/e2e/auth.setup.ts new file mode 100644 index 00000000..46a34229 --- /dev/null +++ b/client/e2e/auth.setup.ts @@ -0,0 +1,42 @@ +import { test as setup, expect } from '@playwright/test' + +// Relative to the config dir (client/), matching `storageState` in +// playwright.config.ts. Playwright runs from the client workspace root. +const stateFile = 'e2e/.tmp/state.json' + +// Credentials match e2e/server-launch.mjs (ADMIN_EMAIL/ADMIN_PASSWORD). The +// seeded admin is created with must_change_password=1, so the first login goes +// through the forced change-password step before reaching the dashboard. +const EMAIL = 'e2e@trek.local' +const SEED_PW = 'E2eTest12345!' +const NEW_PW = 'E2eChanged12345!' + +setup('authenticate the seeded admin (incl. forced password change)', async ({ page }) => { + await page.goto('/login') + await page.locator('input[type="email"]').fill(EMAIL) + await page.locator('input[type="password"]').fill(SEED_PW) + await page.locator('button[type="submit"]').click() + + // must_change_password=1 → the change-password step renders two password + // fields (new + confirm). Selector-agnostic of the UI language. + const pw = page.locator('input[type="password"]') + await expect(pw).toHaveCount(2) + await pw.nth(0).fill(NEW_PW) + await pw.nth(1).fill(NEW_PW) + await page.locator('button[type="submit"]').click() + + await page.waitForURL('**/dashboard', { timeout: 30_000 }) + + // Dismiss the first-run "Welcome to TREK" system-notice modal(s). It renders + // asynchronously (after the notices fetch), so wait for it before clicking. + // Dismissal is recorded server-side against this user, so clearing it here + // keeps it cleared for every authenticated flow in the run (shared test DB). + const ok = page.getByRole('button', { name: 'OK', exact: true }) + await ok.waitFor({ state: 'visible', timeout: 10_000 }).catch(() => {}) + for (let i = 0; i < 8 && (await ok.isVisible().catch(() => false)); i++) { + await ok.click() + await page.waitForTimeout(400) + } + + await page.context().storageState({ path: stateFile }) +}) diff --git a/client/e2e/create-trip.spec.ts b/client/e2e/create-trip.spec.ts new file mode 100644 index 00000000..6c264c92 --- /dev/null +++ b/client/e2e/create-trip.spec.ts @@ -0,0 +1,25 @@ +import { test, expect } from '@playwright/test' + +// Trip lifecycle (core): from the dashboard, open the new-trip modal, name the +// trip, submit, and confirm it shows up on the dashboard. Exercises the whole +// authenticated stack — dashboard → TripFormModal → POST /api/trips → store → +// re-render — against the real backend + isolated test DB. +test('create a trip and see it on the dashboard', async ({ page }) => { + await page.goto('/dashboard') + + // The "+ New Trip" card is always rendered in the default (planned) filter. + await page.locator('.add-trip-card').click() + + // Scope to the shared Modal (.modal-backdrop). Its form has no in-form submit + // button (the primary action lives in the footer), so click it explicitly + // rather than pressing Enter. The Create button is the slate primary button; + // Cancel is the bordered one. + const modal = page.locator('.modal-backdrop') + await expect(modal).toBeVisible() + + const title = `E2E Trip ${Date.now()}` + await modal.locator('input[type="text"]').first().fill(title) + await modal.getByRole('button', { name: 'Create New Trip' }).click() + + await expect(page.getByText(title).first()).toBeVisible({ timeout: 15_000 }) +}) diff --git a/client/e2e/dashboard.spec.ts b/client/e2e/dashboard.spec.ts new file mode 100644 index 00000000..d906e34a --- /dev/null +++ b/client/e2e/dashboard.spec.ts @@ -0,0 +1,10 @@ +import { test, expect } from '@playwright/test' + +// Authenticated smoke: the stored session lands on the dashboard and the +// app chrome (navbar) renders instead of bouncing back to /login. +test('authenticated session reaches the dashboard', async ({ page }) => { + await page.goto('/dashboard') + await expect(page).toHaveURL(/\/dashboard/) + // The shared Navbar shows the TREK brand once authenticated. + await expect(page.getByRole('img', { name: 'TREK' }).first()).toBeVisible() +}) diff --git a/client/e2e/login.public.spec.ts b/client/e2e/login.public.spec.ts new file mode 100644 index 00000000..36fc67d3 --- /dev/null +++ b/client/e2e/login.public.spec.ts @@ -0,0 +1,8 @@ +import { test, expect } from '@playwright/test' + +// Infra smoke + first unauthenticated flow: the app boots, the backend is +// reachable through the Vite proxy, and the login screen renders its form. +test('login screen renders with a password field', async ({ page }) => { + await page.goto('/login') + await expect(page.locator('input[type="password"]')).toBeVisible() +}) diff --git a/client/e2e/server-launch.mjs b/client/e2e/server-launch.mjs new file mode 100644 index 00000000..c9cd1067 --- /dev/null +++ b/client/e2e/server-launch.mjs @@ -0,0 +1,43 @@ +// Boots the TREK backend for the Playwright E2E run against a fresh, isolated +// SQLite database. The DB file is deleted first so every run starts clean, then +// the server's own startup seeds a known admin from ADMIN_EMAIL/ADMIN_PASSWORD. +// +// The server is built once and launched as a SINGLE node process (not the +// watch-mode `npm run dev`, which spawns tsc -w + node --watch grandchildren +// that survive Playwright's teardown and then linger on :3001 with stale DB +// state). A single child is killed cleanly when Playwright tears the run down. +import { rmSync } from 'node:fs' +import { spawn, execSync } from 'node:child_process' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const here = path.dirname(fileURLToPath(import.meta.url)) +const dbFile = path.join(here, '.tmp', 'e2e.db') +const serverDir = path.join(here, '..', '..', 'server') + +for (const f of [dbFile, `${dbFile}-wal`, `${dbFile}-shm`]) { + try { rmSync(f, { force: true }) } catch {} +} + +// Build once (no watcher) — the resulting process is a single killable node. +execSync('node scripts/build.mjs', { cwd: serverDir, stdio: 'inherit' }) + +const env = { + ...process.env, + TREK_DB_FILE: dbFile, + ADMIN_EMAIL: 'e2e@trek.local', + ADMIN_PASSWORD: 'E2eTest12345!', + PORT: '3001', + NODE_ENV: 'development', +} + +const child = spawn(process.execPath, ['--require', 'tsconfig-paths/register', 'dist/index.js'], { + cwd: serverDir, + env, + stdio: 'inherit', +}) +const stop = () => { try { child.kill() } catch {} } +process.on('SIGINT', stop) +process.on('SIGTERM', stop) +process.on('exit', stop) +child.on('exit', code => process.exit(code ?? 0)) diff --git a/client/e2e/trip-planner.spec.ts b/client/e2e/trip-planner.spec.ts new file mode 100644 index 00000000..9a4b8b2c --- /dev/null +++ b/client/e2e/trip-planner.spec.ts @@ -0,0 +1,23 @@ +import { test, expect } from '@playwright/test' + +// Open a trip into the planner: create a trip, open it from the dashboard, and +// confirm the trip planner (TripPlannerPage — the app's largest page) actually +// mounts, proving the day-plan/map shell renders rather than crashing on load. +test('open a trip and land in the planner with a map', async ({ page }) => { + await page.goto('/dashboard') + + // Create a trip to open. + await page.locator('.add-trip-card').click() + const modal = page.locator('.modal-backdrop') + await expect(modal).toBeVisible() + const title = `E2E Planner ${Date.now()}` + await modal.locator('input[type="text"]').first().fill(title) + await modal.getByRole('button', { name: 'Create New Trip' }).click() + + // Open it from the dashboard. + await page.getByText(title).first().click() + + await expect(page).toHaveURL(/\/trips\/\d+/) + // The planner shows a Leaflet map once mounted (past the splash screen). + await expect(page.locator('.leaflet-container')).toBeVisible({ timeout: 20_000 }) +}) diff --git a/client/package.json b/client/package.json index 3a99ea90..2736a134 100644 --- a/client/package.json +++ b/client/package.json @@ -14,12 +14,15 @@ "test:watch": "vitest", "test:coverage": "vitest run --coverage", "lint": "eslint .", + "lint:pages": "node scripts/check-page-pattern.mjs", + "e2e": "playwright test", + "e2e:report": "playwright show-report", "format": "prettier --write \"src/**/*.tsx\" \"src/**/*.css\"", "format:check": "prettier --check \"src/**/*.tsx\" \"src/**/*.css\"" }, "dependencies": { + "@react-pdf/renderer": "^4.5.1", "@trek/shared": "*", - "@react-pdf/renderer": "^4.3.2", "axios": "^1.6.7", "dexie": "^4.4.2", "heic-to": "^1.4.2", @@ -27,11 +30,11 @@ "lucide-react": "^0.344.0", "mapbox-gl": "^3.22.0", "marked": "^18.0.0", - "react": "^18.2.0", - "react-dom": "^18.2.0", + "react": "^19.2.6", + "react-dom": "^19.2.6", "react-dropzone": "^14.4.1", - "react-leaflet": "^4.2.1", - "react-leaflet-cluster": "^2.1.0", + "react-leaflet": "^5.0.0", + "react-leaflet-cluster": "^4.1.3", "react-markdown": "^10.1.0", "react-router-dom": "^6.22.2", "react-window": "^2.2.7", @@ -43,33 +46,34 @@ "zustand": "^4.5.2" }, "devDependencies": { + "@playwright/test": "^1.60.0", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", + "@trivago/prettier-plugin-sort-imports": "^6.0.2", "@types/leaflet": "^1.9.8", - "@types/react": "^18.2.61", - "@types/react-dom": "^18.2.19", + "@types/react": "^19.2.15", + "@types/react-dom": "^19.2.3", "@types/react-window": "^1.8.8", "@vitejs/plugin-react": "^4.2.1", "@vitest/coverage-v8": "^3.2.4", "autoprefixer": "^10.4.18", + "eslint": "^10.2.1", + "eslint-config-flat-gitignore": "^2.3.0", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.2", "fake-indexeddb": "^6.2.5", "jsdom": "^29.0.1", "msw": "^2.13.0", "postcss": "^8.4.35", + "prettier": "^3.8.3", + "prettier-plugin-organize-imports": "^4.3.0", + "prettier-plugin-tailwindcss": "^0.8.0", "sharp": "^0.33.0", "tailwindcss": "^3.4.1", "typescript": "^6.0.2", "vite": "^5.1.4", "vite-plugin-pwa": "^0.21.0", - "vitest": "^3.2.4", - "@trivago/prettier-plugin-sort-imports": "^6.0.2", - "prettier": "^3.8.3", - "prettier-plugin-organize-imports": "^4.3.0", - "prettier-plugin-tailwindcss": "^0.8.0", - "eslint": "^10.2.1", - "eslint-config-flat-gitignore": "^2.3.0", - "eslint-plugin-react-hooks": "^7.1.1", - "eslint-plugin-react-refresh": "^0.5.2" + "vitest": "^3.2.4" } } diff --git a/client/playwright.config.ts b/client/playwright.config.ts new file mode 100644 index 00000000..e6868a43 --- /dev/null +++ b/client/playwright.config.ts @@ -0,0 +1,57 @@ +import { defineConfig, devices } from '@playwright/test' + +/** + * E2E harness for TREK's critical user flows (FE7). + * + * Two web servers are orchestrated: the Express/Nest backend on :3001 against an + * isolated throwaway SQLite DB (e2e/server-launch.mjs sets TREK_DB_FILE + seeds a + * known admin), and the Vite dev server on :5173 which proxies /api, /uploads, + * /ws to the backend. Tests run serially against one worker so they share the + * single seeded database deterministically. + */ +export default defineConfig({ + testDir: './e2e', + fullyParallel: false, + workers: 1, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 0, + timeout: 45_000, + expect: { timeout: 15_000 }, + reporter: [['list']], + use: { + baseURL: 'http://localhost:5173', + trace: 'on-first-retry', + screenshot: 'only-on-failure', + }, + projects: [ + // Unauthenticated flows (login, register, public share) — no stored session. + { name: 'public', testMatch: /\.public\.spec\.ts/, use: { ...devices['Desktop Chrome'] } }, + // One-time login that persists a session for the authenticated flows. + { name: 'setup', testMatch: /auth\.setup\.ts/ }, + { + name: 'app', + testMatch: /\.spec\.ts/, + testIgnore: /(\.public\.spec\.ts|auth\.setup\.ts)/, + use: { ...devices['Desktop Chrome'], storageState: 'e2e/.tmp/state.json' }, + dependencies: ['setup'], + }, + ], + webServer: [ + { + // Always start our own backend (never reuse) so the isolated test DB is + // reset + reseeded on every run, regardless of any stray dev server. + command: 'node e2e/server-launch.mjs', + port: 3001, + reuseExistingServer: false, + timeout: 180_000, + stdout: 'pipe', + stderr: 'pipe', + }, + { + command: 'npm run dev', + port: 5173, + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, + ], +}) diff --git a/client/scripts/check-page-pattern.mjs b/client/scripts/check-page-pattern.mjs new file mode 100644 index 00000000..5d7d789c --- /dev/null +++ b/client/scripts/check-page-pattern.mjs @@ -0,0 +1,44 @@ +// Guards the "Page = wiring container + data hook" convention (see +// src/pages/PATTERN.md). A *Page.tsx default-export component should wire a +// co-located use() hook into JSX — it must not own state/effects itself. +// +// We scan only the default-export component body (from `export default function` +// up to the next top-level `function` declaration or EOF), so presentational +// sub-components and helper hooks living in the same file are not flagged. +// Context hooks like useTranslation/useParams are fine; the smell is stateful +// logic — useState/useReducer/useEffect/useLayoutEffect/useMemo/useCallback/useRef. +import { readdirSync, readFileSync } from 'node:fs' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' + +const pagesDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'src', 'pages') +const BANNED = ['useState', 'useReducer', 'useEffect', 'useLayoutEffect', 'useMemo', 'useCallback', 'useRef'] +const bannedRe = new RegExp(`\\b(${BANNED.join('|')})\\s*\\(`) + +const violations = [] +for (const file of readdirSync(pagesDir)) { + if (!file.endsWith('Page.tsx') || file.endsWith('.test.tsx')) continue + const src = readFileSync(join(pagesDir, file), 'utf8') + const lines = src.split('\n') + const start = lines.findIndex(l => /export default function/.test(l)) + if (start === -1) continue + // The page body ends at the next top-level declaration (a `function` at + // column 0) — everything after that is a sub-component or helper. + let end = lines.length + for (let i = start + 1; i < lines.length; i++) { + if (/^(function |const [A-Z]\w* = )/.test(lines[i])) { end = i; break } + } + for (let i = start; i < end; i++) { + if (bannedRe.test(lines[i])) { + violations.push(`${file}:${i + 1} ${lines[i].trim()}`) + } + } +} + +if (violations.length > 0) { + console.error('Page-pattern violations — move this state/effect logic into the page\'s use() hook:\n') + for (const v of violations) console.error(' ' + v) + console.error(`\n${violations.length} violation(s). See src/pages/PATTERN.md.`) + process.exit(1) +} +console.log('Page pattern OK — no state/effect logic in page containers.') diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 81d40904..2d8b6ae3 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -1,7 +1,62 @@ import axios, { AxiosInstance } from 'axios' -import type { WeatherResult } from '@trek/shared' +import type { z } from 'zod' +import { + weatherResultSchema, type WeatherResult, + inAppListResultSchema, type InAppListResult, + unreadCountResultSchema, type UnreadCountResult, + type NotificationRespondRequest, + type SettingUpsertRequest, type SettingsBulkRequest, + type JourneyCreateRequest, type JourneyAddTripRequest, + type JourneyReorderEntriesRequest, type JourneyProviderPhotosRequest, + type JourneyShareLinkRequest, + type RegisterRequest, type LoginRequest, type ForgotPasswordRequest, + type ResetPasswordRequest, type ChangePasswordRequest, + type MfaVerifyLoginRequest, type MfaEnableRequest, type McpTokenCreateRequest, + type TripAddMemberRequest, type AssignmentReorderRequest, + type PackingReorderRequest, type PackingCreateBagRequest, type TodoReorderRequest, + type TripCreateRequest, type TripUpdateRequest, type TripCopyRequest, + type DayCreateRequest, type DayUpdateRequest, + type PlaceCreateRequest, type PlaceUpdateRequest, + type ReservationCreateRequest, type ReservationUpdateRequest, + type AccommodationCreateRequest, type AccommodationUpdateRequest, + type BudgetCreateItemRequest, type BudgetUpdateItemRequest, + type PackingCreateItemRequest, type PackingUpdateItemRequest, + type TodoCreateItemRequest, type TodoUpdateItemRequest, + type AssignmentCreateRequest, type AssignmentParticipantsRequest, type AssignmentTimeRequest, + type PlaceBulkDeleteRequest, + type DayNoteCreateRequest, type DayNoteUpdateRequest, + type PackingImportRequest, type PackingBagMembersRequest, type PackingUpdateBagRequest, + type PackingCategoryAssigneesRequest, + type BudgetUpdateMembersRequest, type BudgetToggleMemberPaidRequest, type BudgetReorderCategoriesRequest, + type TodoCategoryAssigneesRequest, + type CollabNoteCreateRequest, type CollabNoteUpdateRequest, type CollabPollCreateRequest, + type CollabPollVoteRequest, type CollabMessageCreateRequest, type CollabReactionRequest, + type FileUpdateRequest, type FileLinkRequest, + type CreateTagRequest, type UpdateTagRequest, + type CreateCategoryRequest, type UpdateCategoryRequest, + type PlaceImportListRequest, +} from '@trek/shared' import { getSocketId } from './websocket' import { isReachable, probeNow } from '../sync/connectivity' + +/** + * Validate a response payload against its @trek/shared Zod schema — but only in + * dev, and never throwing. A drift between the server contract and the client's + * expected shape is surfaced as a console warning during development; in + * production (and on any mismatch) the data passes through untouched, so adding + * validation can never break a working call. This is the typed-request helper + * the FE adopts per domain as each backend module lands on @trek/shared. + */ +const API_DEV = Boolean((import.meta as { env?: { DEV?: boolean } }).env?.DEV) +export function parseInDev(schema: S, data: unknown, label: string): z.infer { + if (API_DEV) { + const result = schema.safeParse(data) + if (!result.success) { + console.warn(`[api] ${label}: response did not match the @trek/shared schema`, result.error.issues) + } + } + return data as z.infer +} const RATE_LIMIT_MESSAGES: Record = { en: 'Too many attempts. Please try again later.', de: 'Zu viele Versuche. Bitte versuchen Sie es später erneut.', @@ -154,12 +209,12 @@ apiClient.interceptors.response.use( ) export const authApi = { - register: (data: { username: string; email: string; password: string; invite_token?: string }) => apiClient.post('/auth/register', data).then(r => r.data), + register: (data: RegisterRequest) => apiClient.post('/auth/register', data).then(r => r.data), validateInvite: (token: string) => apiClient.get(`/auth/invite/${token}`).then(r => r.data), - login: (data: { email: string; password: string }) => apiClient.post('/auth/login', data).then(r => r.data), - verifyMfaLogin: (data: { mfa_token: string; code: string }) => apiClient.post('/auth/mfa/verify-login', data).then(r => r.data), + login: (data: LoginRequest) => apiClient.post('/auth/login', data).then(r => r.data), + verifyMfaLogin: (data: MfaVerifyLoginRequest) => apiClient.post('/auth/mfa/verify-login', data).then(r => r.data), mfaSetup: () => apiClient.post('/auth/mfa/setup', {}).then(r => r.data), - mfaEnable: (data: { code: string }) => apiClient.post('/auth/mfa/enable', data).then(r => r.data as { success: boolean; mfa_enabled: boolean; backup_codes?: string[] }), + mfaEnable: (data: MfaEnableRequest) => apiClient.post('/auth/mfa/enable', data).then(r => r.data as { success: boolean; mfa_enabled: boolean; backup_codes?: string[] }), mfaDisable: (data: { password: string; code: string }) => apiClient.post('/auth/mfa/disable', data).then(r => r.data), me: () => apiClient.get('/auth/me').then(r => r.data), updateMapsKey: (key: string | null) => apiClient.put('/auth/me/maps-key', { maps_api_key: key }).then(r => r.data), @@ -173,14 +228,14 @@ export const authApi = { updateAppSettings: (data: Record) => apiClient.put('/auth/app-settings', data).then(r => r.data), validateKeys: () => apiClient.get('/auth/validate-keys').then(r => r.data), travelStats: () => apiClient.get('/auth/travel-stats').then(r => r.data), - changePassword: (data: { current_password: string; new_password: string }) => apiClient.put('/auth/me/password', data).then(r => r.data), - forgotPassword: (data: { email: string }) => apiClient.post('/auth/forgot-password', data).then(r => r.data as { ok: true }), - resetPassword: (data: { token: string; new_password: string; mfa_code?: string }) => apiClient.post('/auth/reset-password', data).then(r => r.data as { success?: true; mfa_required?: true }), + changePassword: (data: ChangePasswordRequest) => apiClient.put('/auth/me/password', data).then(r => r.data), + forgotPassword: (data: ForgotPasswordRequest) => apiClient.post('/auth/forgot-password', data).then(r => r.data as { ok: true }), + resetPassword: (data: ResetPasswordRequest) => apiClient.post('/auth/reset-password', data).then(r => r.data as { success?: true; mfa_required?: true }), deleteOwnAccount: () => apiClient.delete('/auth/me').then(r => r.data), demoLogin: () => apiClient.post('/auth/demo-login').then(r => r.data), mcpTokens: { list: () => apiClient.get('/auth/mcp-tokens').then(r => r.data), - create: (name: string) => apiClient.post('/auth/mcp-tokens', { name }).then(r => r.data), + create: (name: string) => apiClient.post('/auth/mcp-tokens', { name } satisfies McpTokenCreateRequest).then(r => r.data), delete: (id: number) => apiClient.delete(`/auth/mcp-tokens/${id}`).then(r => r.data), }, } @@ -226,32 +281,32 @@ export const oauthApi = { export const tripsApi = { list: (params?: Record) => apiClient.get('/trips', { params }).then(r => r.data), - create: (data: Record) => apiClient.post('/trips', data).then(r => r.data), + create: (data: TripCreateRequest) => apiClient.post('/trips', data).then(r => r.data), get: (id: number | string) => apiClient.get(`/trips/${id}`).then(r => r.data), - update: (id: number | string, data: Record) => apiClient.put(`/trips/${id}`, data).then(r => r.data), + update: (id: number | string, data: TripUpdateRequest) => apiClient.put(`/trips/${id}`, data).then(r => r.data), delete: (id: number | string) => apiClient.delete(`/trips/${id}`).then(r => r.data), uploadCover: (id: number | string, formData: FormData) => apiClient.post(`/trips/${id}/cover`, formData, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data), archive: (id: number | string) => apiClient.put(`/trips/${id}`, { is_archived: true }).then(r => r.data), unarchive: (id: number | string) => apiClient.put(`/trips/${id}`, { is_archived: false }).then(r => r.data), getMembers: (id: number | string) => apiClient.get(`/trips/${id}/members`).then(r => r.data), - addMember: (id: number | string, identifier: string) => apiClient.post(`/trips/${id}/members`, { identifier }).then(r => r.data), + addMember: (id: number | string, identifier: string) => apiClient.post(`/trips/${id}/members`, { identifier } satisfies TripAddMemberRequest).then(r => r.data), removeMember: (id: number | string, userId: number) => apiClient.delete(`/trips/${id}/members/${userId}`).then(r => r.data), - copy: (id: number | string, data?: { title?: string }) => apiClient.post(`/trips/${id}/copy`, data || {}).then(r => r.data), + copy: (id: number | string, data?: TripCopyRequest) => apiClient.post(`/trips/${id}/copy`, data || {}).then(r => r.data), bundle: (id: number | string) => apiClient.get(`/trips/${id}/bundle`).then(r => r.data), } export const daysApi = { list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/days`).then(r => r.data), - create: (tripId: number | string, data: Record) => apiClient.post(`/trips/${tripId}/days`, data).then(r => r.data), - update: (tripId: number | string, dayId: number | string, data: Record) => apiClient.put(`/trips/${tripId}/days/${dayId}`, data).then(r => r.data), + create: (tripId: number | string, data: DayCreateRequest) => apiClient.post(`/trips/${tripId}/days`, data).then(r => r.data), + update: (tripId: number | string, dayId: number | string, data: DayUpdateRequest) => apiClient.put(`/trips/${tripId}/days/${dayId}`, data).then(r => r.data), delete: (tripId: number | string, dayId: number | string) => apiClient.delete(`/trips/${tripId}/days/${dayId}`).then(r => r.data), } export const placesApi = { list: (tripId: number | string, params?: Record) => apiClient.get(`/trips/${tripId}/places`, { params }).then(r => r.data), - create: (tripId: number | string, data: Record) => apiClient.post(`/trips/${tripId}/places`, data).then(r => r.data), + create: (tripId: number | string, data: PlaceCreateRequest) => apiClient.post(`/trips/${tripId}/places`, data).then(r => r.data), get: (tripId: number | string, id: number | string) => apiClient.get(`/trips/${tripId}/places/${id}`).then(r => r.data), - update: (tripId: number | string, id: number | string, data: Record) => apiClient.put(`/trips/${tripId}/places/${id}`, data).then(r => r.data), + update: (tripId: number | string, id: number | string, data: PlaceUpdateRequest) => apiClient.put(`/trips/${tripId}/places/${id}`, data).then(r => r.data), delete: (tripId: number | string, id: number | string) => apiClient.delete(`/trips/${tripId}/places/${id}`).then(r => r.data), searchImage: (tripId: number | string, id: number | string) => apiClient.get(`/trips/${tripId}/places/${id}/image`).then(r => r.data), importGpx: (tripId: number | string, file: File, opts?: { waypoints?: boolean; routes?: boolean; tracks?: boolean }) => { @@ -270,64 +325,64 @@ export const placesApi = { return apiClient.post(`/trips/${tripId}/places/import/map`, fd, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data) }, importGoogleList: (tripId: number | string, url: string) => - apiClient.post(`/trips/${tripId}/places/import/google-list`, { url }).then(r => r.data), + apiClient.post(`/trips/${tripId}/places/import/google-list`, { url } satisfies PlaceImportListRequest).then(r => r.data), importNaverList: (tripId: number | string, url: string) => apiClient.post(`/trips/${tripId}/places/import/naver-list`, { url }).then(r => r.data), bulkDelete: (tripId: number | string, ids: number[]) => - apiClient.post(`/trips/${tripId}/places/bulk-delete`, { ids }).then(r => r.data), + apiClient.post(`/trips/${tripId}/places/bulk-delete`, { ids } satisfies PlaceBulkDeleteRequest).then(r => r.data), } export const assignmentsApi = { list: (tripId: number | string, dayId: number | string) => apiClient.get(`/trips/${tripId}/days/${dayId}/assignments`).then(r => r.data), - create: (tripId: number | string, dayId: number | string, data: { place_id: number | string }) => apiClient.post(`/trips/${tripId}/days/${dayId}/assignments`, data).then(r => r.data), + create: (tripId: number | string, dayId: number | string, data: AssignmentCreateRequest) => apiClient.post(`/trips/${tripId}/days/${dayId}/assignments`, data).then(r => r.data), delete: (tripId: number | string, dayId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/days/${dayId}/assignments/${id}`).then(r => r.data), - reorder: (tripId: number | string, dayId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/days/${dayId}/assignments/reorder`, { orderedIds }).then(r => r.data), + reorder: (tripId: number | string, dayId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/days/${dayId}/assignments/reorder`, { orderedIds } satisfies AssignmentReorderRequest).then(r => r.data), move: (tripId: number | string, assignmentId: number, newDayId: number | string, orderIndex: number | null) => apiClient.put(`/trips/${tripId}/assignments/${assignmentId}/move`, { new_day_id: newDayId, order_index: orderIndex }).then(r => r.data), update: (tripId: number | string, dayId: number | string, id: number, data: Record) => apiClient.put(`/trips/${tripId}/days/${dayId}/assignments/${id}`, data).then(r => r.data), getParticipants: (tripId: number | string, id: number) => apiClient.get(`/trips/${tripId}/assignments/${id}/participants`).then(r => r.data), - setParticipants: (tripId: number | string, id: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/assignments/${id}/participants`, { user_ids: userIds }).then(r => r.data), - updateTime: (tripId: number | string, id: number, times: Record) => apiClient.put(`/trips/${tripId}/assignments/${id}/time`, times).then(r => r.data), + setParticipants: (tripId: number | string, id: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/assignments/${id}/participants`, { user_ids: userIds } satisfies AssignmentParticipantsRequest).then(r => r.data), + updateTime: (tripId: number | string, id: number, times: AssignmentTimeRequest) => apiClient.put(`/trips/${tripId}/assignments/${id}/time`, times).then(r => r.data), } export const packingApi = { list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing`).then(r => r.data), - create: (tripId: number | string, data: Record) => apiClient.post(`/trips/${tripId}/packing`, data).then(r => r.data), - bulkImport: (tripId: number | string, items: { name: string; category?: string; quantity?: number }[]) => apiClient.post(`/trips/${tripId}/packing/import`, { items }).then(r => r.data), - update: (tripId: number | string, id: number, data: Record) => apiClient.put(`/trips/${tripId}/packing/${id}`, data).then(r => r.data), + create: (tripId: number | string, data: PackingCreateItemRequest) => apiClient.post(`/trips/${tripId}/packing`, data).then(r => r.data), + bulkImport: (tripId: number | string, items: { name: string; category?: string; quantity?: number }[]) => apiClient.post(`/trips/${tripId}/packing/import`, { items } satisfies PackingImportRequest).then(r => r.data), + update: (tripId: number | string, id: number, data: PackingUpdateItemRequest) => apiClient.put(`/trips/${tripId}/packing/${id}`, data).then(r => r.data), delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/packing/${id}`).then(r => r.data), - reorder: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/packing/reorder`, { orderedIds }).then(r => r.data), + reorder: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/packing/reorder`, { orderedIds } satisfies PackingReorderRequest).then(r => r.data), getCategoryAssignees: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing/category-assignees`).then(r => r.data), - setCategoryAssignees: (tripId: number | string, categoryName: string, userIds: number[]) => apiClient.put(`/trips/${tripId}/packing/category-assignees/${encodeURIComponent(categoryName)}`, { user_ids: userIds }).then(r => r.data), + setCategoryAssignees: (tripId: number | string, categoryName: string, userIds: number[]) => apiClient.put(`/trips/${tripId}/packing/category-assignees/${encodeURIComponent(categoryName)}`, { user_ids: userIds } satisfies PackingCategoryAssigneesRequest).then(r => r.data), applyTemplate: (tripId: number | string, templateId: number) => apiClient.post(`/trips/${tripId}/packing/apply-template/${templateId}`).then(r => r.data), saveAsTemplate: (tripId: number | string, name: string) => apiClient.post(`/trips/${tripId}/packing/save-as-template`, { name }).then(r => r.data), - setBagMembers: (tripId: number | string, bagId: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/packing/bags/${bagId}/members`, { user_ids: userIds }).then(r => r.data), + setBagMembers: (tripId: number | string, bagId: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/packing/bags/${bagId}/members`, { user_ids: userIds } satisfies PackingBagMembersRequest).then(r => r.data), listBags: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing/bags`).then(r => r.data), - createBag: (tripId: number | string, data: { name: string; color?: string }) => apiClient.post(`/trips/${tripId}/packing/bags`, data).then(r => r.data), - updateBag: (tripId: number | string, bagId: number, data: Record) => apiClient.put(`/trips/${tripId}/packing/bags/${bagId}`, data).then(r => r.data), + createBag: (tripId: number | string, data: PackingCreateBagRequest) => apiClient.post(`/trips/${tripId}/packing/bags`, data).then(r => r.data), + updateBag: (tripId: number | string, bagId: number, data: PackingUpdateBagRequest) => apiClient.put(`/trips/${tripId}/packing/bags/${bagId}`, data).then(r => r.data), deleteBag: (tripId: number | string, bagId: number) => apiClient.delete(`/trips/${tripId}/packing/bags/${bagId}`).then(r => r.data), } export const todoApi = { list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/todo`).then(r => r.data), - create: (tripId: number | string, data: Record) => apiClient.post(`/trips/${tripId}/todo`, data).then(r => r.data), - update: (tripId: number | string, id: number, data: Record) => apiClient.put(`/trips/${tripId}/todo/${id}`, data).then(r => r.data), + create: (tripId: number | string, data: TodoCreateItemRequest) => apiClient.post(`/trips/${tripId}/todo`, data).then(r => r.data), + update: (tripId: number | string, id: number, data: TodoUpdateItemRequest) => apiClient.put(`/trips/${tripId}/todo/${id}`, data).then(r => r.data), delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/todo/${id}`).then(r => r.data), - reorder: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/todo/reorder`, { orderedIds }).then(r => r.data), + reorder: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/todo/reorder`, { orderedIds } satisfies TodoReorderRequest).then(r => r.data), getCategoryAssignees: (tripId: number | string) => apiClient.get(`/trips/${tripId}/todo/category-assignees`).then(r => r.data), - setCategoryAssignees: (tripId: number | string, categoryName: string, userIds: number[]) => apiClient.put(`/trips/${tripId}/todo/category-assignees/${encodeURIComponent(categoryName)}`, { user_ids: userIds }).then(r => r.data), + setCategoryAssignees: (tripId: number | string, categoryName: string, userIds: number[]) => apiClient.put(`/trips/${tripId}/todo/category-assignees/${encodeURIComponent(categoryName)}`, { user_ids: userIds } satisfies TodoCategoryAssigneesRequest).then(r => r.data), } export const tagsApi = { list: () => apiClient.get('/tags').then(r => r.data), - create: (data: Record) => apiClient.post('/tags', data).then(r => r.data), - update: (id: number, data: Record) => apiClient.put(`/tags/${id}`, data).then(r => r.data), + create: (data: CreateTagRequest) => apiClient.post('/tags', data).then(r => r.data), + update: (id: number, data: UpdateTagRequest) => apiClient.put(`/tags/${id}`, data).then(r => r.data), delete: (id: number) => apiClient.delete(`/tags/${id}`).then(r => r.data), } export const categoriesApi = { list: () => apiClient.get('/categories').then(r => r.data), - create: (data: Record) => apiClient.post('/categories', data).then(r => r.data), - update: (id: number, data: Record) => apiClient.put(`/categories/${id}`, data).then(r => r.data), + create: (data: CreateCategoryRequest) => apiClient.post('/categories', data).then(r => r.data), + update: (id: number, data: UpdateCategoryRequest) => apiClient.put(`/categories/${id}`, data).then(r => r.data), delete: (id: number) => apiClient.delete(`/categories/${id}`).then(r => r.data), } @@ -390,7 +445,7 @@ export const addonsApi = { export const journeyApi = { list: () => apiClient.get('/journeys').then(r => r.data), - create: (data: { title: string; subtitle?: string; trip_ids?: number[] }) => apiClient.post('/journeys', data).then(r => r.data), + create: (data: JourneyCreateRequest) => apiClient.post('/journeys', data).then(r => r.data), get: (id: number) => apiClient.get(`/journeys/${id}`).then(r => r.data), update: (id: number, data: Record) => apiClient.patch(`/journeys/${id}`, data).then(r => r.data), delete: (id: number) => apiClient.delete(`/journeys/${id}`).then(r => r.data), @@ -399,7 +454,7 @@ export const journeyApi = { availableTrips: () => apiClient.get('/journeys/available-trips').then(r => r.data), // Trips (sync sources) - addTrip: (id: number, tripId: number) => apiClient.post(`/journeys/${id}/trips`, { trip_id: tripId }).then(r => r.data), + addTrip: (id: number, tripId: number) => apiClient.post(`/journeys/${id}/trips`, { trip_id: tripId } satisfies JourneyAddTripRequest).then(r => r.data), removeTrip: (id: number, tripId: number) => apiClient.delete(`/journeys/${id}/trips/${tripId}`).then(r => r.data), // Entries @@ -407,7 +462,7 @@ export const journeyApi = { createEntry: (id: number, data: Record) => apiClient.post(`/journeys/${id}/entries`, data).then(r => r.data), updateEntry: (entryId: number, data: Record) => apiClient.patch(`/journeys/entries/${entryId}`, data).then(r => r.data), deleteEntry: (entryId: number) => apiClient.delete(`/journeys/entries/${entryId}`).then(r => r.data), - reorderEntries: (journeyId: number, orderedIds: number[]) => apiClient.put(`/journeys/${journeyId}/entries/reorder`, { orderedIds }).then(r => r.data), + reorderEntries: (journeyId: number, orderedIds: number[]) => apiClient.put(`/journeys/${journeyId}/entries/reorder`, { orderedIds } satisfies JourneyReorderEntriesRequest).then(r => r.data), // Photos uploadPhotos: (entryId: number, formData: FormData, opts?: { onUploadProgress?: (e: import('axios').AxiosProgressEvent) => void; idempotencyKey?: string; signal?: AbortSignal }) => @@ -424,7 +479,7 @@ export const journeyApi = { onUploadProgress: opts?.onUploadProgress, signal: opts?.signal, }).then(r => r.data), - addProviderPhotosToGallery: (journeyId: number, provider: string, assetIds: string[], passphrase?: string) => apiClient.post(`/journeys/${journeyId}/gallery/provider-photos`, { provider, asset_ids: assetIds, ...(passphrase ? { passphrase } : {}) }).then(r => r.data), + addProviderPhotosToGallery: (journeyId: number, provider: string, assetIds: string[], passphrase?: string) => apiClient.post(`/journeys/${journeyId}/gallery/provider-photos`, { provider, asset_ids: assetIds, ...(passphrase ? { passphrase } : {}) } satisfies JourneyProviderPhotosRequest).then(r => r.data), addProviderPhoto: (entryId: number, provider: string, assetId: string, caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_id: assetId, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data), addProviderPhotos: (entryId: number, provider: string, assetIds: string[], caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_ids: assetIds, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data), linkPhoto: (entryId: number, journeyPhotoId: number) => apiClient.post(`/journeys/entries/${entryId}/link-photo`, { journey_photo_id: journeyPhotoId }).then(r => r.data), @@ -446,7 +501,7 @@ export const journeyApi = { // Share getShareLink: (id: number) => apiClient.get(`/journeys/${id}/share-link`).then(r => r.data), - createShareLink: (id: number, perms: { share_timeline?: boolean; share_gallery?: boolean; share_map?: boolean }) => apiClient.post(`/journeys/${id}/share-link`, perms).then(r => r.data), + createShareLink: (id: number, perms: JourneyShareLinkRequest) => apiClient.post(`/journeys/${id}/share-link`, perms).then(r => r.data), deleteShareLink: (id: number) => apiClient.delete(`/journeys/${id}/share-link`).then(r => r.data), getPublicJourney: (token: string) => apiClient.get(`/public/journey/${token}`).then(r => r.data), } @@ -468,15 +523,15 @@ export const airportsApi = { export const budgetApi = { list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget`).then(r => r.data), - create: (tripId: number | string, data: Record) => apiClient.post(`/trips/${tripId}/budget`, data).then(r => r.data), - update: (tripId: number | string, id: number, data: Record) => apiClient.put(`/trips/${tripId}/budget/${id}`, data).then(r => r.data), + create: (tripId: number | string, data: BudgetCreateItemRequest) => apiClient.post(`/trips/${tripId}/budget`, data).then(r => r.data), + update: (tripId: number | string, id: number, data: BudgetUpdateItemRequest) => apiClient.put(`/trips/${tripId}/budget/${id}`, data).then(r => r.data), delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/budget/${id}`).then(r => r.data), - setMembers: (tripId: number | string, id: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/budget/${id}/members`, { user_ids: userIds }).then(r => r.data), - togglePaid: (tripId: number | string, id: number, userId: number, paid: boolean) => apiClient.put(`/trips/${tripId}/budget/${id}/members/${userId}/paid`, { paid }).then(r => r.data), + setMembers: (tripId: number | string, id: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/budget/${id}/members`, { user_ids: userIds } satisfies BudgetUpdateMembersRequest).then(r => r.data), + togglePaid: (tripId: number | string, id: number, userId: number, paid: boolean) => apiClient.put(`/trips/${tripId}/budget/${id}/members/${userId}/paid`, { paid } satisfies BudgetToggleMemberPaidRequest).then(r => r.data), perPersonSummary: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget/summary/per-person`).then(r => r.data), settlement: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget/settlement`).then(r => r.data), reorderItems: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/budget/reorder/items`, { orderedIds }).then(r => r.data), - reorderCategories: (tripId: number | string, orderedCategories: string[]) => apiClient.put(`/trips/${tripId}/budget/reorder/categories`, { orderedCategories }).then(r => r.data), + reorderCategories: (tripId: number | string, orderedCategories: string[]) => apiClient.put(`/trips/${tripId}/budget/reorder/categories`, { orderedCategories } satisfies BudgetReorderCategoriesRequest).then(r => r.data), } export const filesApi = { @@ -484,13 +539,13 @@ export const filesApi = { upload: (tripId: number | string, formData: FormData) => apiClient.post(`/trips/${tripId}/files`, formData, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data), - update: (tripId: number | string, id: number, data: Record) => apiClient.put(`/trips/${tripId}/files/${id}`, data).then(r => r.data), + update: (tripId: number | string, id: number, data: FileUpdateRequest) => apiClient.put(`/trips/${tripId}/files/${id}`, data).then(r => r.data), delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/files/${id}`).then(r => r.data), toggleStar: (tripId: number | string, id: number) => apiClient.patch(`/trips/${tripId}/files/${id}/star`).then(r => r.data), restore: (tripId: number | string, id: number) => apiClient.post(`/trips/${tripId}/files/${id}/restore`).then(r => r.data), permanentDelete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/files/${id}/permanent`).then(r => r.data), emptyTrash: (tripId: number | string) => apiClient.delete(`/trips/${tripId}/files/trash/empty`).then(r => r.data), - addLink: (tripId: number | string, fileId: number, data: { reservation_id?: number; assignment_id?: number }) => apiClient.post(`/trips/${tripId}/files/${fileId}/link`, data).then(r => r.data), + addLink: (tripId: number | string, fileId: number, data: FileLinkRequest) => apiClient.post(`/trips/${tripId}/files/${fileId}/link`, data).then(r => r.data), removeLink: (tripId: number | string, fileId: number, linkId: number) => apiClient.delete(`/trips/${tripId}/files/${fileId}/link/${linkId}`).then(r => r.data), getLinks: (tripId: number | string, fileId: number) => apiClient.get(`/trips/${tripId}/files/${fileId}/links`).then(r => r.data), } @@ -498,15 +553,15 @@ export const filesApi = { export const reservationsApi = { list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/reservations`).then(r => r.data), upcoming: () => apiClient.get('/reservations/upcoming').then(r => r.data), - create: (tripId: number | string, data: Record) => apiClient.post(`/trips/${tripId}/reservations`, data).then(r => r.data), - update: (tripId: number | string, id: number, data: Record) => apiClient.put(`/trips/${tripId}/reservations/${id}`, data).then(r => r.data), + create: (tripId: number | string, data: ReservationCreateRequest) => apiClient.post(`/trips/${tripId}/reservations`, data).then(r => r.data), + update: (tripId: number | string, id: number, data: ReservationUpdateRequest) => apiClient.put(`/trips/${tripId}/reservations/${id}`, data).then(r => r.data), delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/reservations/${id}`).then(r => r.data), updatePositions: (tripId: number | string, positions: { id: number; day_plan_position: number }[], dayId?: number) => apiClient.put(`/trips/${tripId}/reservations/positions`, { positions, day_id: dayId }).then(r => r.data), } export const weatherApi = { - get: (lat: number, lng: number, date: string): Promise => apiClient.get('/weather', { params: { lat, lng, date } }).then(r => r.data), - getDetailed: (lat: number, lng: number, date: string, lang?: string): Promise => apiClient.get('/weather/detailed', { params: { lat, lng, date, lang } }).then(r => r.data), + get: (lat: number, lng: number, date: string): Promise => apiClient.get('/weather', { params: { lat, lng, date } }).then(r => parseInDev(weatherResultSchema, r.data, 'weather.get')), + getDetailed: (lat: number, lng: number, date: string, lang?: string): Promise => apiClient.get('/weather/detailed', { params: { lat, lng, date, lang } }).then(r => parseInDev(weatherResultSchema, r.data, 'weather.getDetailed')), } export const configApi = { @@ -516,40 +571,46 @@ export const configApi = { export const settingsApi = { get: () => apiClient.get('/settings').then(r => r.data), - set: (key: string, value: unknown) => apiClient.put('/settings', { key, value }).then(r => r.data), - setBulk: (settings: Record) => apiClient.post('/settings/bulk', { settings }).then(r => r.data), + set: (key: string, value: unknown) => { + const body: SettingUpsertRequest = { key, value } + return apiClient.put('/settings', body).then(r => r.data) + }, + setBulk: (settings: Record) => { + const body: SettingsBulkRequest = { settings } + return apiClient.post('/settings/bulk', body).then(r => r.data) + }, } export const accommodationsApi = { list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/accommodations`).then(r => r.data), - create: (tripId: number | string, data: Record) => apiClient.post(`/trips/${tripId}/accommodations`, data).then(r => r.data), - update: (tripId: number | string, id: number, data: Record) => apiClient.put(`/trips/${tripId}/accommodations/${id}`, data).then(r => r.data), + create: (tripId: number | string, data: AccommodationCreateRequest) => apiClient.post(`/trips/${tripId}/accommodations`, data).then(r => r.data), + update: (tripId: number | string, id: number, data: AccommodationUpdateRequest) => apiClient.put(`/trips/${tripId}/accommodations/${id}`, data).then(r => r.data), delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/accommodations/${id}`).then(r => r.data), } export const dayNotesApi = { list: (tripId: number | string, dayId: number | string) => apiClient.get(`/trips/${tripId}/days/${dayId}/notes`).then(r => r.data), - create: (tripId: number | string, dayId: number | string, data: Record) => apiClient.post(`/trips/${tripId}/days/${dayId}/notes`, data).then(r => r.data), - update: (tripId: number | string, dayId: number | string, id: number, data: Record) => apiClient.put(`/trips/${tripId}/days/${dayId}/notes/${id}`, data).then(r => r.data), + create: (tripId: number | string, dayId: number | string, data: DayNoteCreateRequest) => apiClient.post(`/trips/${tripId}/days/${dayId}/notes`, data).then(r => r.data), + update: (tripId: number | string, dayId: number | string, id: number, data: DayNoteUpdateRequest) => apiClient.put(`/trips/${tripId}/days/${dayId}/notes/${id}`, data).then(r => r.data), delete: (tripId: number | string, dayId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/days/${dayId}/notes/${id}`).then(r => r.data), } export const collabApi = { getNotes: (tripId: number | string) => apiClient.get(`/trips/${tripId}/collab/notes`).then(r => r.data), - createNote: (tripId: number | string, data: Record) => apiClient.post(`/trips/${tripId}/collab/notes`, data).then(r => r.data), - updateNote: (tripId: number | string, id: number, data: Record) => apiClient.put(`/trips/${tripId}/collab/notes/${id}`, data).then(r => r.data), + createNote: (tripId: number | string, data: CollabNoteCreateRequest) => apiClient.post(`/trips/${tripId}/collab/notes`, data).then(r => r.data), + updateNote: (tripId: number | string, id: number, data: CollabNoteUpdateRequest) => apiClient.put(`/trips/${tripId}/collab/notes/${id}`, data).then(r => r.data), deleteNote: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/collab/notes/${id}`).then(r => r.data), uploadNoteFile: (tripId: number | string, noteId: number, formData: FormData) => apiClient.post(`/trips/${tripId}/collab/notes/${noteId}/files`, formData, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data), deleteNoteFile: (tripId: number | string, noteId: number, fileId: number) => apiClient.delete(`/trips/${tripId}/collab/notes/${noteId}/files/${fileId}`).then(r => r.data), getPolls: (tripId: number | string) => apiClient.get(`/trips/${tripId}/collab/polls`).then(r => r.data), - createPoll: (tripId: number | string, data: Record) => apiClient.post(`/trips/${tripId}/collab/polls`, data).then(r => r.data), - votePoll: (tripId: number | string, id: number, optionIndex: number) => apiClient.post(`/trips/${tripId}/collab/polls/${id}/vote`, { option_index: optionIndex }).then(r => r.data), + createPoll: (tripId: number | string, data: CollabPollCreateRequest) => apiClient.post(`/trips/${tripId}/collab/polls`, data).then(r => r.data), + votePoll: (tripId: number | string, id: number, optionIndex: number) => apiClient.post(`/trips/${tripId}/collab/polls/${id}/vote`, { option_index: optionIndex } satisfies CollabPollVoteRequest).then(r => r.data), closePoll: (tripId: number | string, id: number) => apiClient.put(`/trips/${tripId}/collab/polls/${id}/close`).then(r => r.data), deletePoll: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/collab/polls/${id}`).then(r => r.data), getMessages: (tripId: number | string, before?: string) => apiClient.get(`/trips/${tripId}/collab/messages${before ? `?before=${before}` : ''}`).then(r => r.data), - sendMessage: (tripId: number | string, data: Record) => apiClient.post(`/trips/${tripId}/collab/messages`, data).then(r => r.data), + sendMessage: (tripId: number | string, data: CollabMessageCreateRequest) => apiClient.post(`/trips/${tripId}/collab/messages`, data).then(r => r.data), deleteMessage: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/collab/messages/${id}`).then(r => r.data), - reactMessage: (tripId: number | string, id: number, emoji: string) => apiClient.post(`/trips/${tripId}/collab/messages/${id}/react`, { emoji }).then(r => r.data), + reactMessage: (tripId: number | string, id: number, emoji: string) => apiClient.post(`/trips/${tripId}/collab/messages/${id}/react`, { emoji } satisfies CollabReactionRequest).then(r => r.data), linkPreview: (tripId: number | string, url: string) => apiClient.get(`/trips/${tripId}/collab/link-preview?url=${encodeURIComponent(url)}`).then(r => r.data), } @@ -596,10 +657,10 @@ export const notificationsApi = { } export const inAppNotificationsApi = { - list: (params?: { limit?: number; offset?: number; unread_only?: boolean }) => - apiClient.get('/notifications/in-app', { params }).then(r => r.data), - unreadCount: () => - apiClient.get('/notifications/in-app/unread-count').then(r => r.data), + list: (params?: { limit?: number; offset?: number; unread_only?: boolean }): Promise => + apiClient.get('/notifications/in-app', { params }).then(r => parseInDev(inAppListResultSchema, r.data, 'notifications.list')), + unreadCount: (): Promise => + apiClient.get('/notifications/in-app/unread-count').then(r => parseInDev(unreadCountResultSchema, r.data, 'notifications.unreadCount')), markRead: (id: number) => apiClient.put(`/notifications/in-app/${id}/read`).then(r => r.data), markUnread: (id: number) => @@ -610,7 +671,7 @@ export const inAppNotificationsApi = { apiClient.delete(`/notifications/in-app/${id}`).then(r => r.data), deleteAll: () => apiClient.delete('/notifications/in-app/all').then(r => r.data), - respond: (id: number, response: 'positive' | 'negative') => + respond: (id: number, response: NotificationRespondRequest['response']) => apiClient.post(`/notifications/in-app/${id}/respond`, { response }).then(r => r.data), } diff --git a/client/src/components/Budget/BudgetPanel.tsx b/client/src/components/Budget/BudgetPanel.tsx index 1b2cab37..6fe80bdf 100644 --- a/client/src/components/Budget/BudgetPanel.tsx +++ b/client/src/components/Budget/BudgetPanel.tsx @@ -771,12 +771,40 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
- {categoryNames.map((cat, ci) => { - const items = grouped.get(cat) || [] - const subtotal = items.reduce((s, x) => s + (x.total_price || 0), 0) - const color = categoryColor(cat) + {categoryNames.map(cat => ( + + ))} +
- return ( + +
+ + ) +} + +function BudgetCategoryTable({ cat, grouped, categoryColor, canEdit, editingCat, setEditingCat, + dragCat, setDragCat, dragOverCat, setDragOverCat, dragItem, setDragItem, dragOverItem, setDragOverItem, + dragItemCat, setDragItemCat, categoryNames, reorderBudgetCategories, reorderBudgetItems, + handleRenameCategory, handleDeleteCategory, handleDeleteItem, handleUpdateField, handleAddItem, + tripId, currency, locale, t, fmt, hasMultipleMembers, tripMembers, setBudgetItemMembers, toggleBudgetMemberPaid, th, td }: any) { + const items = grouped.get(cat) || [] + const subtotal = items.reduce((s, x) => s + (x.total_price || 0), 0) + const color = categoryColor(cat) + return (
- ) - })} - + ) +} +function BudgetSummary({ theme, currency, locale, grandTotal, hasMultipleMembers, budgetItems, + settlement, settlementOpen, setSettlementOpen, pieSegments, isDark, tripId, t, fmt }: any) { + return (
-
-
) } diff --git a/client/src/components/Collab/CollabChat.tsx b/client/src/components/Collab/CollabChat.tsx index 2735029b..2eb6395e 100644 --- a/client/src/components/Collab/CollabChat.tsx +++ b/client/src/components/Collab/CollabChat.tsx @@ -353,6 +353,99 @@ interface CollabChatProps { } export default function CollabChat({ tripId, currentUser }: CollabChatProps) { + const S = useCollabChat(tripId, currentUser) + const { t, is12h, can, trip, canEdit, messages, setMessages, loading, setLoading, hasMore, setHasMore, loadingMore, setLoadingMore, text, setText, replyTo, setReplyTo, hoveredId, setHoveredId, sending, setSending, showEmoji, setShowEmoji, reactMenu, setReactMenu, deletingIds, setDeletingIds, deleteTimersRef, containerRef, messagesRef, scrollRef, textareaRef, emojiBtnRef, isAtBottom, scrollToBottom, checkAtBottom, handleLoadMore, handleTextChange, handleSend, handleKeyDown, handleDelete, handleReact, handleEmojiSelect, isOwn, isEmojiOnly } = S + if (loading) { + return ( +
+
+ +
+ ) + } + return ( +
+ + {/* Composer */} +
+ {/* Reply preview */} + {replyTo && ( +
+ + + {replyTo.username}: {(replyTo.text || '').slice(0, 60)} + + +
+ )} + +
+ {/* Emoji button */} + {canEdit && ( + + )} + +