Compare commits

..

44 Commits

Author SHA1 Message Date
github-actions[bot] 4ae4e0c676 chore: bump version to 3.0.13 [skip ci] 2026-04-30 23:43:49 +00:00
Julien G. 51ab30f436 Bug fixes - April 30th 2026 (#936)
* fix: hotel day-range clamping in ReservationModal + stale assignment_id on accommodation clear (issues #929, #934)

* ReservationModal hotel start/end pickers now use findIndex-based
  positional clamping instead of raw ID arithmetic, matching the fix
  applied to DayDetailPanel in 8e05ba7. Prevents inverted
  start_day_id/end_day_id on trips with non-monotonic day IDs.

* Clearing accommodation_id on a hotel reservation now forces
  assignment_id to null in the save payload, removing the stale
  day-assignment link that had no UI path to clear.

* Migration: swaps inverted start_day_id/end_day_id pairs in
  day_accommodations where start.day_number > end.day_number,
  recovering existing corrupt rows from the pre-fix picker bug.

* Tests FE-PLANNER-RESMODAL-050/051/052 cover both fixes.

* fix: preserve line breaks and wrap long URLs in notes fields (#930)

Add remark-breaks to all reservation/place notes markdown renderers so
single newlines render as <br>, and add wordBreak/overflowWrap styles
so long unbroken URLs (e.g. booking.com tracking links) wrap correctly.

* fix: delete linked budget item when accommodation or reservation is deleted (#933)

Deleting an accommodation or reservation now removes any budget item
linked via reservation_id, preventing orphan entries in the Budget page.
Also fixes a pre-existing payload-shape bug where budget:deleted was
broadcast with {id} instead of {itemId}, breaking live updates for
collaborators when a reservation price was cleared.

Tests added: ACCOM-006, RESV-009b, BUDGET-004b.

* fix: restore scroll position in mobile Plan and Places sidebars on reopen (issue #932)

Both DayPlanSidebar and PlacesSidebar have their own internal scroll
containers (overflowY: auto). Scroll events don't bubble, so previous
attempts that tracked scrollTop on the outer portal div never fired.

Each sidebar now accepts initialScrollTop and onScrollTopChange props.
The internal scroll container saves its scrollTop via onScrollTopChange
on every scroll event, and restores it via useLayoutEffect on mount
(before the browser paints, so no visible flash).

TripPlannerPage holds the saved values in refs (mobilePlanScrollTopRef,
mobilePlacesScrollTopRef) and passes them through on each portal mount.

* fix(map): prevent auto zoom-out when opening/closing place inspector (issue #921)

Both Leaflet and Mapbox GL renderers now gate fitBounds strictly on fitKey
increments from the parent. Selecting or dismissing a place inspector changes
paddingOpts (via hasInspector) but no longer triggers a re-fit that zoomed
the map out to the full trip extent when no day was selected.

Also removes the zoom-12 visibility gate on Leaflet route info pills so they
render at all zoom levels when a route is active.

* fix: translate mobile bottom-nav tab labels (issue #931)

Replaced hardcoded English labels in BottomNav with t() lookups using the same translation keys as the desktop navbar (nav.myTrips, admin.addons.catalog.*.name).
2026-05-01 01:43:19 +02:00
github-actions[bot] 8b53948231 chore: bump version to 3.0.12 [skip ci] 2026-04-28 22:17:13 +00:00
Julien G. 78d6f2ba77 Bug fixes - April 28th 2026 (#915)
* fix: replace raw day-ID range checks with position-based helper (issue #889 follow-up)

Commit 8e05ba7 fixed the accommodation date-range pickers, but the
post-save state filters in DayDetailPanel and several other consumers
still compared `day.id >= start_day_id && day.id <= end_day_id`. With
non-monotonic ID layouts (day_number 1-9 → IDs 17-25, day_number 10-16
→ IDs 1-7) this made the just-saved accommodation immediately invisible
— matching the regression reported in the last comment of #889.

Introduces `isDayInAccommodationRange` in `client/src/utils/dayOrder.ts`
which compares positional order (`day_number` with `indexOf` fallback)
rather than raw IDs. Falls back to the old numeric comparison when
endpoint days are absent from the loaded array (sparse test data or
partial loads) so existing tests are unaffected.

Fixed call sites:
- DayDetailPanel.tsx (initial load, post-create, post-delete, post-edit-save)
- DayPlanSidebar.tsx (daily badge renderer)
- SharedTripPage.tsx (public share view)
- TripPDF.tsx (PDF export filter + sort)

Also declares `day_number?: number` on the client `Day` type (already
returned by the server but previously untyped).

Adds regression tests FE-PLANNER-DAYDETAIL-060/061/062 covering the
edit-save, create-save, and initial-load paths with the reporter's exact
non-monotonic ID layout.

* fix: non-transport reservations no longer appear as transports in day planner (issue #914)

getTransportForDay now uses TRANSPORT_TYPES allowlist instead of only excluding hotels,
and the click handler dispatches to onEditReservation for non-transport types instead of
always opening TransportModal, preventing silent type coercion to 'flight'.

* feat: add file attachment support to TransportModal (issue #918)

Transports (flight/train/car/cruise) now support file attachments identical to the reservation modal — upload on create/edit, link existing files, and unlink. The Files tab and Assign File modal now differentiate between bookings and transports with separate sections and type-specific icons. Translations added for all 15 locales.
2026-04-29 00:16:56 +02:00
jubnl bb89d70a94 docs: document required permissions for Immich and Synology photo providers
Co-authored-by: Ben Haas <ben@benhaas.io>
2026-04-28 05:32:39 +02:00
jubnl ad9f3887d8 docs: add wiki guide for adding places to day itinerary with GIFs
Co-authored-by: Tranko <tranko@gmail.com>
2026-04-28 05:32:35 +02:00
github-actions[bot] 7f1fb508db chore: bump version to 3.0.11 [skip ci] 2026-04-28 03:17:32 +00:00
Julien G. 1f5deeba6c Bug fixes - April 27th 2026 (#907)
* fix: clean up dangling FK references before deleting a user

Resolves FOREIGN KEY constraint failed (500) on DELETE /api/admin/users/:id
and DELETE /api/auth/me when the target user had rows in trip_members.invited_by,
share_tokens.created_by, budget_items.paid_by_user_id, journeys.user_id,
journey_entries.author_id, journey_contributors.user_id, or
journey_share_tokens.created_by — none of which had ON DELETE clauses.

Introduces deleteUserCompletely() in userCleanupService.ts which wraps all
cleanup and the final DELETE FROM users in a single transaction. Both
adminService.deleteUser and authService.deleteAccount now call it instead of
the bare DELETE. Tests ADMIN-005b and AUTH-040 cover all reference types
including notification sender/recipient and notice dismissals.

* test: extend FK deletion tests to cover journeys, files, and photos

ADMIN-005b and AUTH-040 now also seed and assert:
- owned journey with entries (cascade-deleted via journeys.user_id cleanup)
- trip_files.uploaded_by (SET NULL — file survives, attribution cleared)
- trek_photos.owner_id (SET NULL — photo record survives, owner cleared)
- trip_photos.user_id (CASCADE — photo association removed)

* test: extend user deletion tests to cover all FK relationships

ADMIN-005b and AUTH-040 now seed and assert every user FK relationship:

CASCADE (row deleted): trips, trip_members, tags, mcp_tokens, oauth_tokens,
oauth_consents, vacay_plans, vacay_plan_members, bucket_list,
visited_countries, visited_regions, packing_templates, invite_tokens,
collab_notes, settings, password_reset_tokens, notification_channel_preferences

SET NULL (row survives, column nulled): categories, todo_items.assigned_user_id,
packing_bags, audit_log

Caught and fixed: notification_preferences was dropped in migration 72;
correct table is notification_channel_preferences.

* fix: preserve URL hash and OIDC redirect target through login flow

- Include location.hash in redirect param at all three producer sites
  (ProtectedRoute, axios 401 interceptor, OAuthAuthorizePage) so
  hash fragments survive the login bounce
- Stash redirectTarget in sessionStorage before any OIDC provider
  redirect and restore it after the code exchange, since the IdP
  strips the original ?redirect= param during the roundtrip
- Clear sessionStorage on OIDC error to avoid stale state
- Add tests covering sessionStorage stash on mount, navigate to saved
  redirect after OIDC exchange, fallback to /dashboard, and cleanup
  on error

* fix: use day position instead of ID for accommodation date range clamping

Math.min/Math.max over raw day IDs breaks the start/end picker when a
trip's day IDs are non-monotonic relative to day_number (normal after
repeated generateDays extend/shrink cycles). Replaced with findIndex
lookups so clamping is always based on positional order.

Closes #889

* fix: normalize env var comparisons to be case-insensitive

All NODE_ENV, DEMO_MODE, OIDC_ONLY, FORCE_HTTPS, COOKIE_SECURE, and
ALLOW_INTERNAL_NETWORK checks now use .toLowerCase() so values like
'Production' or 'True' behave identically to their lowercase forms.
Also adds APP_VERSION to the startup banner.

* fix: delete surplus days when shortening a trip

When shrinking a trip's date range, surplus days are now deleted along
with their assignments, notes, and accommodations (cascade). Places
remain in the trip pool; reservations keep their day reference nulled
by the existing ON DELETE SET NULL constraint (issue #909).

Updates TRIP-SVC-011 to reflect the new behaviour; adds TRIP-SVC-016
as a regression test for the empty-day case.

* fix: auto-backup retention deletes itself and manual backups on Docker

Two bugs in cleanupOldBackups:
1. Filter was .endsWith('.zip') — swept manual backup-*.zip files too.
   Now restricted to auto-backup-* prefix.
2. Age was derived from stat.birthtimeMs, which is 0 on overlayfs
   (Docker default), making every backup appear epoch-old and get
   deleted immediately. Age is now parsed from the filename timestamp
   and falls back to mtimeMs (reliable on overlayfs).

Also converts inline require('./services/auditLog') calls to a static
import throughout scheduler.ts, and adds 8 unit tests covering the
fixed retention logic including the overlayfs regression case.

* test: update TRIP-024 to match delete behavior on trip shrink

* feat: add bypass-branch-check label to skip branch enforcement
2026-04-28 05:17:20 +02:00
jubnl ca832e8d88 chore: prevent new build on workflow change 2026-04-27 00:31:22 +02:00
jubnl 12fc7f7b68 docs: fix Proxmox update section to run inside LXC and add command 2026-04-27 00:28:48 +02:00
github-actions[bot] 2770a189df chore: bump version to 3.0.10 [skip ci] 2026-04-26 22:22:31 +00:00
jubnl 2b162a8cc7 chore: reset to 3.0.9 2026-04-27 00:22:09 +02:00
github-actions[bot] 009d89fecf chore: bump version to 3.0.10 [skip ci] 2026-04-26 22:15:15 +00:00
jubnl 5c3b89578d docs: add Proxmox VE LXC install guide and update CI ignore paths
- Add wiki/Install-Proxmox.md with full install/update/log instructions
- Add Proxmox VE section to wiki/Updating.md
- Add Install: Proxmox VE (LXC) to wiki/_Sidebar.md
- Add "Proxmox Community Script" option to bug report install dropdown
- Exclude GitHub meta files from triggering Docker CI workflow
2026-04-27 00:14:50 +02:00
github-actions[bot] 303e7de433 chore: bump version to 3.0.9 [skip ci] 2026-04-26 19:59:33 +00:00
Maurice 08eb7f3733 Merge pull request #892 from mauriceboe/fixes-26-04-2026
fixes-26-04-2026
2026-04-26 21:59:21 +02:00
jubnl 90d86eda61 chore: Add Trademark policy 2026-04-26 15:36:34 +02:00
jubnl 0eca6d54a1 chore: Add Trademark policy 2026-04-26 15:27:33 +02:00
Julien G. bc1fb71391 Fix exit code 132 on old CPUs by replacing sharp with jimp (issue #888) (#895)
sharp's prebuilt Linux x64 binary requires SSE4.2 (x86-64-v2), causing a
SIGILL crash on older hardware (e.g. AMD A6-3420M). Replace with jimp, a
pure-JS image library with no native binaries. Also skip thumbnail generation
entirely when the Journey addon is disabled (the default), preventing the
issue for most installs regardless of the image library used.
2026-04-26 13:26:09 +02:00
Maurice cb425fb397 Fix 500 on reservation edit after DB reinit (issue #883)
saveEndpoints was bound at module load via db.transaction(...). When the
demo-mode hourly reset (or a self-hoster's backup restore) closes the DB
connection and reinitialises it, the bound transaction still references
the now-closed connection — every subsequent reservation save with an
endpoints field throws "The database connection is not open", which the
client surfaces as "Internal server error".

Bind the transaction lazily on each call so it always runs against the
current connection.
2026-04-26 12:14:17 +02:00
Maurice 35ed712d46 Fix demo banner overlapping bottom tab bar on mobile
The demo welcome modal extended below the mobile bottom tab bar,
hiding the dismiss button so visitors couldn't close it.

- Use dvh so mobile URL bar is accounted for correctly
- Reserve ~80px of bottom padding for the tab bar
- Make the footer sticky so the dismiss button stays visible
  while scrolling through the modal content
- Bump z-index to ensure the overlay sits above the tab bar
2026-04-26 12:02:25 +02:00
jubnl 4923973380 docs(wiki): add MCP OAuth troubleshooting entry for missing APP_URL 2026-04-23 20:02:32 +02:00
github-actions[bot] 8342cf3010 chore: bump version to 3.0.8 [skip ci] 2026-04-23 17:49:49 +00:00
Julien G. 2a37eeccb3 fix: hot fixes 23-04-2026 (#856)
* fix(packing): resolve avatar URL path in bag and category assignees (#854)

packingService was returning raw avatar filenames from the DB instead of
the full /uploads/avatars/<filename> path, causing broken profile images
for users with uploaded avatars.

* fix(budget): use Map.get() to fix category rename no-op (#855)

* fix(security): relax Referrer-Policy and document HSTS_INCLUDE_SUBDOMAINS (#862) (#863)

- Change Helmet default from no-referrer to strict-origin-when-cross-origin
  so browsers send the origin on cross-origin requests, allowing Google Maps
  API key restrictions by HTTP referrer to work correctly
- Document HSTS_INCLUDE_SUBDOMAINS in all deployment artifacts:
  .env.example, docker-compose.yml, README.md, unraid-template.xml,
  charts/values.yaml, charts/configmap.yaml, wiki/Environment-Variables.md

* fix(planner): prefetch budget items on trip page mount (#864)

Loads budgetItems alongside reservations when TripPlannerPage mounts so
the Budget category dropdown in ReservationModal and TransportModal shows
pre-existing categories on first open, regardless of whether the Budget
tab has been visited.

Closes #861

* fix(reservations): prevent Invalid Date when end time is set without end date (#866)

When reservation_end_time held a bare time string ("HH:MM"), fmtDate()
produced Invalid Date on the reservation card.

- Modal: when end date is blank but end time is filled, construct a
  same-day ISO datetime using the start date (prevents time-only strings
  from ever being persisted)
- Panel: derive endDatePart via regex so date-only end values ("YYYY-MM-DD")
  still show the multi-day range, while bare time strings are skipped and
  handled correctly by the existing time column logic

Closes #860

* fix(planner): format reservation end time instead of rendering raw ISO string (#867)

Closes #859

* fix(planner): wire Route toggle into mobile day sidebar (#850) (#868)

The per-booking Route icon was missing on mobile because the mobile
DayPlanSidebar invocation in TripPlannerPage didn't pass
visibleConnectionIds or onToggleConnection. Mobile PWA users couldn't
activate reservation map overlays without forcing desktop mode.

Also corrects the Map-Features wiki: fixes the setting name
("Booking route labels" not "Show connection labels"), documents the
route_calculation requirement for travel-time pills, and explains that
overlays are off by default and must be toggled per reservation.
2026-04-23 19:49:36 +02:00
github-actions[bot] ae0e59d9f1 chore: bump version to 3.0.7 [skip ci] 2026-04-23 09:07:40 +00:00
Maurice 50bb7573fd [Snyk] Security upgrade uuid from 9.0.1 to 14.0.0 (#849)
* fix: server/package.json & server/package-lock.json to reduce vulnerabilities

The following vulnerabilities are fixed with an upgrade:
- https://snyk.io/vuln/SNYK-JS-UUID-16133035

* fix: bump fast-xml-parser version

---------

Co-authored-by: snyk-bot <snyk-bot@snyk.io>
Co-authored-by: jubnl <jgunther021@gmail.com>
2026-04-23 11:07:25 +02:00
github-actions[bot] b852317c84 chore: bump version to 3.0.6 [skip ci] 2026-04-23 08:53:44 +00:00
Julien G. 4436b6f673 fix(journey,pdf): journey reorder sort_order + PDF multi-day transport (#848)
* fix(journey): make sort_order authoritative for within-day entry ordering

Reorder buttons appeared broken because the server ORDER BY put entry_time
before sort_order, so entries synced from trip places with differing times
would always sort by time regardless of sort_order writes. The client store
mirrored the same comparator, making even the optimistic update invisible.

- Change ORDER BY to (entry_date, sort_order, id) in getJourneyFull and listEntries
- Fix syncTripPlaces and onPlaceCreated to assign MAX+1 sort_order per day instead of day_number/0
- Update client store comparator to match
- Add DB migration to backfill sort_order using old effective key (entry_time, id) so existing journeys retain their visual order
- Add tests: JOURNEY-SVC-089–093, FE-STORE-JOURNEY-018–019

Closes #846

* fix(pdf): include multi-day transport return/arrival in PDF itinerary (#847)

Reservations were matched to days by pickup date only, so the end-day
card (e.g. car Return, flight Arrival) was silently dropped from the PDF.
Add span-aware helpers mirroring DayPlanSidebar logic: match by day_id/end_day_id
span, show reservation_end_time on end days, prefix title with phase label
(Return/Arrival/etc.), and use per-day position for sort order.

* test(pdf): add missing day_id to transport reservation fixture
2026-04-23 10:53:32 +02:00
github-actions[bot] 311647fd46 chore: bump version to 3.0.5 [skip ci] 2026-04-23 08:07:13 +00:00
Xre0uS 28dbd86d03 fix(files): open attachments only in new tab (#840)
window.open with noreferrer returns null, which triggered the popup-blocked download fallback in addition to the new-tab open. Use a target=_blank anchor click instead.
2026-04-23 10:06:56 +02:00
github-actions[bot] 842d9760df chore: bump version to 3.0.4 [skip ci] 2026-04-23 07:13:48 +00:00
Julien G. 58218ff5f6 fix(oidc,ui): restore Authentik login and fix mobile delete dialog (#845)
OIDC: when OIDC_DISCOVERY_URL is explicitly set, trust the discovery
doc's issuer for id_token comparison instead of rejecting a path
mismatch as an error. Authentik (and similar realm-path providers)
return a canonical issuer like /application/o/<slug>/ that differs
from the operator's base OIDC_ISSUER. Strict equality blocked login
in 3.x despite working in v2. Default discovery (no custom URL) keeps
the strict check. Adds OIDC-SVC-037/038/039.

UI: ConfirmDialog and CopyTripDialog lacked the --bottom-nav-h
paddingBottom offset that other overlays already use. On mobile portrait
the action buttons were hidden behind the sticky bottom nav bar.

Closes #843
Closes #844
2026-04-23 09:13:35 +02:00
github-actions[bot] 83be5fc92a chore: bump version to 3.0.3 [skip ci] 2026-04-22 20:16:47 +00:00
Julien G. 7798d2a3fd fix(oidc): normalize id_token iss claim before issuer comparison (#837)
jwt.verify does an exact string match on the issuer. Providers like
Authentik include a trailing slash in the id_token iss claim while the
configured issuer is already normalized (no trailing slash), causing
every login attempt to fail with jwt issuer invalid.

Move the issuer check out of jwt.verify options and apply the same
trailing-slash normalization used in the discovery doc validation.
Also adds OIDC-SVC-033–036 unit tests covering exact match, trailing
slash, wrong issuer, and wrong audience cases.

Closes #834
2026-04-22 22:16:33 +02:00
github-actions[bot] ec1ed60117 chore: bump version to 3.0.2 [skip ci] 2026-04-22 19:25:28 +00:00
Julien G. ed4c21eade Merge pull request #835 from mauriceboe/fix/oidc-issuer-trailing-slash
fix(oidc): normalize discovery doc issuer before trailing slash comparison
2026-04-22 21:25:15 +02:00
jubnl 9093948ff6 test(systemNotices): exclude v3 upgrade notices from login_count-only tests
Tests that expect an empty notice list were using first_seen_version='0.0.0'
(DB default), which matches the existingUserBeforeVersion('3.0.0') condition
now that the app is at 3.0.1. Set first_seen_version='3.0.0' so only the
firstLogin condition controls visibility in these tests.
2026-04-22 21:19:04 +02:00
jubnl 2cea4d73aa fix(oidc): normalize discovery doc issuer before comparison
Trailing slash in doc.issuer (e.g. Authentik) caused a mismatch against
the already-normalized configured issuer, breaking OIDC login entirely.

Closes #834
2026-04-22 21:14:29 +02:00
github-actions[bot] a2a6f52e6e chore: bump version to 3.0.1 [skip ci] 2026-04-22 17:58:18 +00:00
Maurice 0978b40b6d Merge pull request #832 from mauriceboe/fix/reservations-day-id-mismatch
fix(reservations): restore correct day assignment for non-transport bookings
2026-04-22 19:58:03 +02:00
Maurice 6155b6dc86 fix(reservations): restore correct day assignment for non-transport bookings
v3.0.0 switched the planner from rendering reservations by
reservation_time to rendering them by day_id (commit 3f61e1c), but
migration 110 only backfilled day_id for transport types. Tours,
restaurants, events and 'other' bookings kept whatever day_id was
stored in the DB — often the trip's first day, from older code paths
that defaulted it there — so after the upgrade those rows all show
up on day 1 regardless of their actual reservation_time.

- Migration 122: for every non-hotel reservation, null out any
  day_id / end_day_id that does not match the reservation's time,
  then backfill it from reservation_time / reservation_end_time.
  Idempotent; leaves already-correct rows alone.
- reservationService.createReservation / updateReservation now
  derive day_id / end_day_id from reservation_time /
  reservation_end_time when the client didn't send one explicitly,
  so the mismatch cannot reappear on new or edited bookings.
  Hotels are skipped because they store their date range on the
  linked day_accommodation.
2026-04-22 19:47:22 +02:00
jubnl 314486325e fix: resolve dead wiki links across install and config pages 2026-04-22 19:21:53 +02:00
github-actions[bot] 523bca3a20 chore: bump version to 3.0.0 [skip ci] 2026-04-22 16:59:12 +00:00
Maurice d5be528d4b Merge pull request #758 from mauriceboe/dev
V3.0.0
2026-04-22 18:58:23 +02:00
327 changed files with 2435 additions and 190588 deletions
+1
View File
@@ -62,6 +62,7 @@ body:
- Docker (standalone)
- Kubernetes / Helm
- Unraid template
- Proxmox Community Script
- Sources
- Other
validations:
@@ -26,6 +26,9 @@ jobs:
const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
for (const pull of pulls) {
const hasBypass = pull.labels.some(l => l.name === 'bypass-branch-check');
if (hasBypass) continue;
const hasLabel = pull.labels.some(l => l.name === 'wrong-base-branch');
if (!hasLabel) continue;
+4 -1
View File
@@ -7,7 +7,10 @@ on:
- 'docs/**'
- '**/*.md'
- 'wiki/**'
- '.github/workflows/wiki.yml'
- '.github/workflows/**'
- '.github/ISSUE_TEMPLATE/**'
- '.github/FUNDING.yml'
- '.github/PULL_REQUEST_TEMPLATE.md'
workflow_dispatch:
inputs:
bump:
@@ -21,6 +21,12 @@ jobs:
const labels = context.payload.pull_request.labels.map(l => l.name);
const prNumber = context.payload.pull_request.number;
// bypass-branch-check label skips all enforcement
if (labels.includes('bypass-branch-check')) {
console.log('bypass-branch-check label present, skipping enforcement.');
return;
}
// If the base was fixed, remove the label and let it through
if (base !== 'main') {
if (labels.includes('wrong-base-branch')) {
+2 -2
View File
@@ -1,5 +1,5 @@
apiVersion: v2
name: trek
version: 3.0.9
version: 3.0.13
description: Minimal Helm chart for TREK app
appVersion: "3.0.9"
appVersion: "3.0.13"
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "trek-client",
"version": "3.0.9",
"version": "3.0.13",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "trek-client",
"version": "3.0.9",
"version": "3.0.13",
"dependencies": {
"@react-pdf/renderer": "^4.3.2",
"axios": "^1.6.7",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "trek-client",
"version": "3.0.9",
"version": "3.0.13",
"private": true,
"type": "module",
"scripts": {
+1 -1
View File
@@ -58,7 +58,7 @@ function ProtectedRoute({ children, adminRequired = false, addonId }: ProtectedR
}
if (!isAuthenticated) {
const redirectParam = encodeURIComponent(location.pathname + location.search)
const redirectParam = encodeURIComponent(location.pathname + location.search + location.hash)
return <Navigate to={`/login?redirect=${redirectParam}`} replace />
}
+1 -1
View File
@@ -75,7 +75,7 @@ apiClient.interceptors.response.use(
if (error.response?.status === 401 && (error.response?.data as { code?: string } | undefined)?.code === 'AUTH_REQUIRED') {
const { pathname } = window.location
if (!isAuthPublicPath(pathname)) {
const currentPath = pathname + window.location.search
const currentPath = pathname + window.location.search + window.location.hash
window.location.href = '/login?redirect=' + encodeURIComponent(currentPath)
}
}
+73 -46
View File
@@ -1,7 +1,7 @@
import ReactDOM from 'react-dom'
import { useState, useCallback, useRef, useEffect } from 'react'
import { useDropzone } from 'react-dropzone'
import { Upload, Trash2, ExternalLink, Download, X, FileText, FileImage, File, MapPin, Ticket, StickyNote, Star, RotateCcw, Pencil, Check, ChevronLeft, ChevronRight } from 'lucide-react'
import { Upload, Trash2, ExternalLink, Download, X, FileText, FileImage, File, MapPin, Ticket, StickyNote, Star, RotateCcw, Pencil, Check, ChevronLeft, ChevronRight, Plane, Train, Car, Ship } from 'lucide-react'
import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
import { filesApi } from '../../api/client'
@@ -236,6 +236,15 @@ function AvatarChip({ name, avatarUrl, size = 20 }: { name: string; avatarUrl?:
)
}
const TRANSPORT_TYPES = new Set(['flight', 'train', 'car', 'cruise'])
function transportIcon(type: string) {
if (type === 'train') return Train
if (type === 'car') return Car
if (type === 'cruise') return Ship
return Plane
}
interface FileManagerProps {
files?: TripFile[]
onUpload: (fd: FormData) => Promise<any>
@@ -490,7 +499,9 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
<SourceBadge key={p.id} icon={MapPin} label={`${t('files.sourcePlan')} · ${p.name}`} />
))}
{linkedReservations.map(r => (
<SourceBadge key={r.id} icon={Ticket} label={`${t('files.sourceBooking')} · ${r.title || t('files.sourceBooking')}`} />
TRANSPORT_TYPES.has(r.type)
? <SourceBadge key={r.id} icon={transportIcon(r.type)} label={`${t('files.sourceTransport')} · ${r.title || t('files.sourceTransport')}`} />
: <SourceBadge key={r.id} icon={Ticket} label={`${t('files.sourceBooking')} · ${r.title || t('files.sourceBooking')}`} />
))}
{file.note_id && (
<SourceBadge icon={StickyNote} label={t('files.sourceCollab') || 'Collab Notes'} />
@@ -673,52 +684,68 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
</div>
)
const bookingReservations = reservations.filter(r => !TRANSPORT_TYPES.has(r.type))
const transportReservations = reservations.filter(r => TRANSPORT_TYPES.has(r.type))
const reservationBtn = (r: Reservation) => {
const isLinked = file.reservation_id === r.id || (file.linked_reservation_ids || []).includes(r.id)
const Icon = TRANSPORT_TYPES.has(r.type) ? transportIcon(r.type) : Ticket
return (
<button key={r.id} onClick={async () => {
if (isLinked) {
if (file.reservation_id === r.id) {
await handleAssign(file.id, { reservation_id: null })
} else {
try {
const linksRes = await filesApi.getLinks(tripId, file.id)
const link = (linksRes.links || []).find((l: any) => l.reservation_id === r.id)
if (link) await filesApi.removeLink(tripId, file.id, link.id)
refreshFiles()
} catch {}
}
} else {
if (!file.reservation_id) {
await handleAssign(file.id, { reservation_id: r.id })
} else {
try {
await filesApi.addLink(tripId, file.id, { reservation_id: r.id })
refreshFiles()
} catch {}
}
}
}} style={{
width: '100%', textAlign: 'left', padding: '6px 10px 6px 20px', background: isLinked ? 'var(--bg-hover)' : 'none',
border: 'none', cursor: 'pointer', fontSize: 13, color: 'var(--text-primary)',
borderRadius: 8, fontFamily: 'inherit', fontWeight: isLinked ? 600 : 400,
display: 'flex', alignItems: 'center', gap: 6,
}}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = isLinked ? 'var(--bg-hover)' : 'transparent'}>
<Icon size={12} style={{ flexShrink: 0, color: 'var(--text-muted)' }} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.title || r.name}</span>
{isLinked && <Check size={14} style={{ marginLeft: 'auto', flexShrink: 0, color: 'var(--accent)' }} />}
</button>
)
}
const bookingsSection = reservations.length > 0 && (
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', padding: '8px 10px 4px', textTransform: 'uppercase', letterSpacing: 0.5 }}>
{t('files.assignBooking')}
</div>
{reservations.map(r => {
const isLinked = file.reservation_id === r.id || (file.linked_reservation_ids || []).includes(r.id)
return (
<button key={r.id} onClick={async () => {
if (isLinked) {
// Unlink: if primary reservation_id, clear it; if via file_links, remove link
if (file.reservation_id === r.id) {
await handleAssign(file.id, { reservation_id: null })
} else {
try {
const linksRes = await filesApi.getLinks(tripId, file.id)
const link = (linksRes.links || []).find((l: any) => l.reservation_id === r.id)
if (link) await filesApi.removeLink(tripId, file.id, link.id)
refreshFiles()
} catch {}
}
} else {
// Link: if no primary, set it; otherwise use file_links
if (!file.reservation_id) {
await handleAssign(file.id, { reservation_id: r.id })
} else {
try {
await filesApi.addLink(tripId, file.id, { reservation_id: r.id })
refreshFiles()
} catch {}
}
}
}} style={{
width: '100%', textAlign: 'left', padding: '6px 10px 6px 20px', background: isLinked ? 'var(--bg-hover)' : 'none',
border: 'none', cursor: 'pointer', fontSize: 13, color: 'var(--text-primary)',
borderRadius: 8, fontFamily: 'inherit', fontWeight: isLinked ? 600 : 400,
display: 'flex', alignItems: 'center', gap: 6,
}}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = isLinked ? 'var(--bg-hover)' : 'transparent'}>
<Ticket size={12} style={{ flexShrink: 0, color: 'var(--text-muted)' }} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.title || r.name}</span>
{isLinked && <Check size={14} style={{ marginLeft: 'auto', flexShrink: 0, color: 'var(--accent)' }} />}
</button>
)
})}
{bookingReservations.length > 0 && (
<>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', padding: '8px 10px 4px', textTransform: 'uppercase', letterSpacing: 0.5 }}>
{t('files.assignBooking')}
</div>
{bookingReservations.map(reservationBtn)}
</>
)}
{transportReservations.length > 0 && (
<>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', padding: '8px 10px 4px', textTransform: 'uppercase', letterSpacing: 0.5, marginTop: bookingReservations.length > 0 ? 4 : 0 }}>
{t('files.assignTransport')}
</div>
{transportReservations.map(reservationBtn)}
</>
)}
</div>
)
@@ -19,8 +19,10 @@ vi.mock('react-router-dom', async () => {
import { render, screen, fireEvent } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { useAuthStore } from '../../store/authStore';
import { useSettingsStore } from '../../store/settingsStore';
import { useAddonStore } from '../../store/addonStore';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildUser } from '../../../tests/helpers/factories';
import { buildUser, buildSettings } from '../../../tests/helpers/factories';
import BottomNav from './BottomNav';
const currentUser = buildUser({ id: 1, username: 'testuser', email: 'test@example.com' });
@@ -39,7 +41,7 @@ describe('BottomNav', () => {
it('FE-COMP-BOTTOMNAV-002: shows Trips nav link', () => {
render(<BottomNav />);
expect(screen.getByText('Trips')).toBeInTheDocument();
expect(screen.getByText('My Trips')).toBeInTheDocument();
});
it('FE-COMP-BOTTOMNAV-003: shows Profile button', () => {
@@ -99,4 +101,39 @@ describe('BottomNav', () => {
// Sheet should be closed — username no longer visible (only the nav Profile text remains)
expect(screen.queryByText('testuser')).not.toBeInTheDocument();
});
it('FE-COMP-BOTTOMNAV-010: Trips label translates when language is fr', () => {
seedStore(useSettingsStore, { settings: buildSettings({ language: 'fr' }) });
render(<BottomNav />);
expect(screen.getByText('Mes voyages')).toBeInTheDocument();
});
it('FE-COMP-BOTTOMNAV-011: Profile label translates when language is fr', () => {
seedStore(useSettingsStore, { settings: buildSettings({ language: 'fr' }) });
render(<BottomNav />);
expect(screen.getByText('Profil')).toBeInTheDocument();
});
it('FE-COMP-BOTTOMNAV-012: addon labels translate when language is fr', () => {
seedStore(useSettingsStore, { settings: buildSettings({ language: 'fr' }) });
seedStore(useAddonStore, {
addons: [
{ id: 'vacay', name: 'Vacay', type: 'global', icon: 'calendar', enabled: true },
{ id: 'atlas', name: 'Atlas', type: 'global', icon: 'globe', enabled: true },
{ id: 'journey', name: 'Journey', type: 'global', icon: 'compass', enabled: true },
],
});
render(<BottomNav />);
expect(screen.getByText('Vacances')).toBeInTheDocument();
expect(screen.getByText('Atlas')).toBeInTheDocument();
expect(screen.getByText('Journal de voyage')).toBeInTheDocument();
});
it('FE-COMP-BOTTOMNAV-013: unknown addon id is not rendered', () => {
seedStore(useAddonStore, {
addons: [{ id: 'foo', name: 'Foo Addon', type: 'global', icon: 'star', enabled: true }],
});
render(<BottomNav />);
expect(screen.queryByText('Foo Addon')).not.toBeInTheDocument();
});
});
+11 -13
View File
@@ -7,14 +7,10 @@ import { useTranslation } from '../../i18n'
import { Plane, CalendarDays, Globe, Compass, User, Settings, Shield, LogOut, X } from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
const BASE_ITEMS: { to: string; label: string; icon: LucideIcon; addonId?: string }[] = [
{ to: '/trips', label: 'Trips', icon: Plane },
]
const ADDON_NAV: Record<string, { to: string; label: string; icon: LucideIcon }> = {
vacay: { to: '/vacay', label: 'Vacay', icon: CalendarDays },
atlas: { to: '/atlas', label: 'Atlas', icon: Globe },
journey: { to: '/journey', label: 'Journey', icon: Compass },
const ADDON_NAV: Record<string, { icon: LucideIcon; labelKey: string }> = {
vacay: { icon: CalendarDays, labelKey: 'admin.addons.catalog.vacay.name' },
atlas: { icon: Globe, labelKey: 'admin.addons.catalog.atlas.name' },
journey: { icon: Compass, labelKey: 'admin.addons.catalog.journey.name' },
}
export default function BottomNav() {
@@ -25,11 +21,13 @@ export default function BottomNav() {
const globalAddons = addons.filter(a => a.type === 'global' && a.enabled)
const [showProfile, setShowProfile] = useState(false)
const items = [...BASE_ITEMS]
for (const addon of globalAddons) {
const nav = ADDON_NAV[addon.id]
if (nav) items.push(nav)
}
const items: { to: string; label: string; icon: LucideIcon }[] = [
{ to: '/trips', label: t('nav.myTrips'), icon: Plane },
...globalAddons.flatMap(addon => {
const nav = ADDON_NAV[addon.id]
return nav ? [{ to: `/${addon.id}`, label: t(nav.labelKey), icon: nav.icon }] : []
}),
]
return (
<>
+41 -9
View File
@@ -7,6 +7,16 @@ import { resetAllStores } from '../../../tests/helpers/store'
import { buildPlace } from '../../../tests/helpers/factories'
import * as photoService from '../../services/photoService'
const mapMock = vi.hoisted(() => ({
panTo: vi.fn(),
setView: vi.fn(),
fitBounds: vi.fn(),
getZoom: vi.fn().mockReturnValue(10),
on: vi.fn(),
off: vi.fn(),
panBy: vi.fn(),
}))
vi.mock('react-leaflet', () => ({
MapContainer: ({ children }: any) => <div data-testid="map-container">{children}</div>,
TileLayer: () => <div data-testid="tile-layer" />,
@@ -27,15 +37,7 @@ vi.mock('react-leaflet', () => ({
Polyline: ({ positions }: any) => <div data-testid="polyline" data-points={JSON.stringify(positions)} />,
CircleMarker: () => <div data-testid="circle-marker" />,
Circle: () => <div data-testid="circle" />,
useMap: () => ({
panTo: vi.fn(),
setView: vi.fn(),
fitBounds: vi.fn(),
getZoom: () => 10,
on: vi.fn(),
off: vi.fn(),
panBy: vi.fn(),
}),
useMap: () => mapMock,
useMapEvents: () => ({}),
}))
@@ -79,6 +81,7 @@ function buildMapPlace(overrides: Record<string, any> = {}) {
}
afterEach(() => {
vi.clearAllMocks()
resetAllStores()
})
@@ -216,4 +219,33 @@ describe('MapView', () => {
render(<MapView places={places} selectedPlaceId={5} />)
expect(screen.getByTestId('marker')).toBeTruthy()
})
it('FE-COMP-MAPVIEW-018: changing selectedPlaceId/hasInspector does not refit bounds (issue #921)', () => {
const places = [
buildMapPlace({ id: 1, lat: 48.8584, lng: 2.2945 }),
buildMapPlace({ id: 2, lat: 48.86, lng: 2.337 }),
]
const { rerender } = render(<MapView places={places} fitKey={1} selectedPlaceId={null} hasInspector={false} />)
const initialCount = mapMock.fitBounds.mock.calls.length
// Toggle selectedPlaceId on — mimics opening place inspector (hasInspector flips,
// paddingOpts memo creates new object). fitBounds must NOT fire again.
rerender(<MapView places={places} fitKey={1} selectedPlaceId={1} hasInspector={true} />)
expect(mapMock.fitBounds).toHaveBeenCalledTimes(initialCount)
// Toggle selectedPlaceId off — mimics closing inspector via X button.
rerender(<MapView places={places} fitKey={1} selectedPlaceId={null} hasInspector={false} />)
expect(mapMock.fitBounds).toHaveBeenCalledTimes(initialCount)
})
it('FE-COMP-MAPVIEW-019: bumping fitKey triggers a new fitBounds call', () => {
const places = [
buildMapPlace({ id: 1, lat: 48.8584, lng: 2.2945 }),
]
const { rerender } = render(<MapView places={places} fitKey={1} />)
const afterFirst = mapMock.fitBounds.mock.calls.length
rerender(<MapView places={places} fitKey={2} />)
expect(mapMock.fitBounds.mock.calls.length).toBeGreaterThan(afterFirst)
})
})
+2 -13
View File
@@ -186,7 +186,7 @@ function BoundsController({ places, fitKey, paddingOpts, hasDayDetail }: BoundsC
}
}
} catch {}
}, [fitKey, places, paddingOpts, map, hasDayDetail])
}, [fitKey]) // eslint-disable-line react-hooks/exhaustive-deps
return null
}
@@ -233,18 +233,7 @@ interface RouteLabelProps {
}
function RouteLabel({ midpoint, walkingText, drivingText }: RouteLabelProps) {
const map = useMap()
const [visible, setVisible] = useState(map ? map.getZoom() >= 12 : false)
useEffect(() => {
if (!map) return
const check = () => setVisible(map.getZoom() >= 12)
check()
map.on('zoomend', check)
return () => map.off('zoomend', check)
}, [map])
if (!visible || !midpoint) return null
if (!midpoint) return null
const icon = L.divIcon({
className: 'route-info-pill',
@@ -0,0 +1,164 @@
import React from 'react'
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'
import { render } from '../../../tests/helpers/render'
import { act } from '@testing-library/react'
import { resetAllStores } from '../../../tests/helpers/store'
import { buildPlace } from '../../../tests/helpers/factories'
import { useSettingsStore } from '../../store/settingsStore'
// Stable fake map so fitBounds call counts survive re-renders.
const glMap = vi.hoisted(() => ({
on: vi.fn(),
off: vi.fn(),
once: vi.fn(),
loaded: vi.fn().mockReturnValue(true),
fitBounds: vi.fn(),
flyTo: vi.fn(),
jumpTo: vi.fn(),
getZoom: vi.fn().mockReturnValue(10),
addControl: vi.fn(),
removeControl: vi.fn(),
remove: vi.fn(),
addSource: vi.fn(),
getSource: vi.fn().mockReturnValue(null),
addLayer: vi.fn(),
setLayoutProperty: vi.fn(),
getStyle: vi.fn().mockReturnValue({ layers: [] }),
isStyleLoaded: vi.fn().mockReturnValue(true),
getCanvasContainer: vi.fn(() => document.createElement('div')),
}))
vi.mock('mapbox-gl', () => ({
default: {
accessToken: '',
Map: vi.fn(() => glMap),
Marker: vi.fn(() => ({
setLngLat: vi.fn().mockReturnThis(),
addTo: vi.fn().mockReturnThis(),
remove: vi.fn(),
getElement: vi.fn(() => document.createElement('div')),
})),
LngLatBounds: vi.fn(() => ({ extend: vi.fn().mockReturnThis() })),
NavigationControl: vi.fn(),
},
}))
vi.mock('mapbox-gl/dist/mapbox-gl.css', () => ({}))
vi.mock('./mapboxSetup', () => ({
isStandardFamily: vi.fn(() => false),
supportsCustom3d: vi.fn(() => false),
wantsTerrain: vi.fn(() => false),
addCustom3dBuildings: vi.fn(),
addTerrainAndSky: vi.fn(),
}))
vi.mock('./locationMarkerMapbox', () => ({
attachLocationMarker: vi.fn(() => ({ update: vi.fn() })),
}))
vi.mock('./reservationsMapbox', () => ({
ReservationMapboxOverlay: vi.fn().mockImplementation(() => ({ update: vi.fn() })),
}))
vi.mock('../../hooks/useGeolocation', () => ({
useGeolocation: vi.fn(() => ({
position: null,
mode: 'off',
error: null,
cycleMode: vi.fn(),
setMode: vi.fn(),
})),
}))
vi.mock('../../services/photoService', () => ({
getCached: vi.fn(() => null),
isLoading: vi.fn(() => false),
fetchPhoto: vi.fn(),
onThumbReady: vi.fn(() => () => {}),
getAllThumbs: vi.fn(() => ({})),
}))
import { MapViewGL } from './MapViewGL'
function buildMapPlace(overrides: Record<string, any> = {}) {
return {
...buildPlace(),
category_name: null,
category_color: null,
category_icon: null,
...overrides,
} as any
}
beforeEach(() => {
useSettingsStore.setState({
settings: {
...useSettingsStore.getState().settings,
map_provider: 'mapbox-gl',
mapbox_access_token: 'pk.test_token',
mapbox_style: 'mapbox://styles/mapbox/streets-v12',
mapbox_3d_enabled: false,
},
} as any)
})
afterEach(() => {
vi.clearAllMocks()
resetAllStores()
})
describe('MapViewGL', () => {
it('FE-COMP-MAPVIEWGL-001: opening place inspector does not refit bounds (issue #921)', async () => {
const places = [
buildMapPlace({ id: 1, lat: 48.8584, lng: 2.2945 }),
buildMapPlace({ id: 2, lat: 48.86, lng: 2.337 }),
]
const { rerender } = render(
<MapViewGL places={places} fitKey={1} selectedPlaceId={null} hasInspector={false} />,
)
await act(async () => {})
const after_initial = glMap.fitBounds.mock.calls.length
// Selecting a place flips hasInspector → paddingOpts memo changes.
// fitBounds must NOT fire again (this was the bug).
rerender(
<MapViewGL places={places} fitKey={1} selectedPlaceId={1} hasInspector={true} />,
)
await act(async () => {})
expect(glMap.fitBounds).toHaveBeenCalledTimes(after_initial)
})
it('FE-COMP-MAPVIEWGL-002: closing inspector does not refit bounds (issue #921)', async () => {
const places = [
buildMapPlace({ id: 1, lat: 48.8584, lng: 2.2945 }),
]
const { rerender } = render(
<MapViewGL places={places} fitKey={1} selectedPlaceId={1} hasInspector={true} />,
)
await act(async () => {})
const after_initial = glMap.fitBounds.mock.calls.length
// Closing inspector (X button) clears selectedPlaceId → hasInspector=false → new paddingOpts.
rerender(
<MapViewGL places={places} fitKey={1} selectedPlaceId={null} hasInspector={false} />,
)
await act(async () => {})
expect(glMap.fitBounds).toHaveBeenCalledTimes(after_initial)
})
it('FE-COMP-MAPVIEWGL-003: bumping fitKey triggers a new fitBounds call', async () => {
const places = [
buildMapPlace({ id: 1, lat: 48.8584, lng: 2.2945 }),
]
const { rerender } = render(<MapViewGL places={places} fitKey={1} />)
await act(async () => {})
const after_first = glMap.fitBounds.mock.calls.length
rerender(<MapViewGL places={places} fitKey={2} />)
await act(async () => {})
expect(glMap.fitBounds.mock.calls.length).toBeGreaterThan(after_first)
})
})
+4 -7
View File
@@ -507,13 +507,10 @@ export function MapViewGL({
return { top, right: rightWidth + 40, bottom, left: leftWidth + 40 }
}, [leftWidth, rightWidth, hasInspector, hasDayDetail])
// Also fit when the places collection changes so the initial render
// zooms to the trip instead of the default center.
const placeBoundsKey = useMemo(
() => places.filter(p => p.lat && p.lng).map(p => `${p.id}:${p.lat}:${p.lng}`).join('|'),
[places]
)
const prevFitKey = useRef(-1)
useEffect(() => {
if (fitKey === prevFitKey.current) return
prevFitKey.current = fitKey
const map = mapRef.current
if (!map) return
const target = dayPlaces.length > 0 ? dayPlaces : places
@@ -533,7 +530,7 @@ export function MapViewGL({
}
if (map.loaded()) run()
else map.once('load', run)
}, [fitKey, placeBoundsKey, paddingOpts, mapbox3d]) // eslint-disable-line react-hooks/exhaustive-deps
}, [fitKey]) // eslint-disable-line react-hooks/exhaustive-deps
// flyTo selected place
useEffect(() => {
+7 -2
View File
@@ -4,6 +4,7 @@ import { getCategoryIcon } from '../shared/categoryIcons'
import { FileText, Info, Clock, MapPin, Navigation, Train, Plane, Bus, Car, Ship, Coffee, Ticket, Star, Heart, Camera, Flag, Lightbulb, AlertTriangle, ShoppingBag, Bookmark, Hotel, LogIn, LogOut, KeyRound, BedDouble, Utensils, Users, LucideIcon } from 'lucide-react'
import { accommodationsApi, mapsApi } from '../../api/client'
import type { Trip, Day, Place, Category, AssignmentsMap, DayNotesMap } from '../../types'
import { isDayInAccommodationRange, getDayOrder } from '../../utils/dayOrder'
function renderLucideIcon(icon:LucideIcon, props = {}) {
if (!_renderToStaticMarkup) return ''
@@ -285,8 +286,12 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
}).join('')
const accommodationsForDay = (accommodations.accommodations || []).filter(a =>
days.some(d => d.id >= a.start_day_id && d.id <= a.end_day_id && d.id === day?.id)
).sort((a, b) => a.start_day_id - b.start_day_id)
day ? isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days) : false
).sort((a, b) => {
const startA = days.find(d => d.id === a.start_day_id)
const startB = days.find(d => d.id === b.start_day_id)
return (startA ? getDayOrder(startA, days) : 0) - (startB ? getDayOrder(startB, days) : 0)
})
const accommodationDetails = accommodationsForDay.map(item => {
const isCheckIn = day.id === item.start_day_id
@@ -892,6 +892,277 @@ describe('DayDetailPanel', () => {
expect(screen.getByText(/June|15/i)).toBeInTheDocument();
});
// ── Accommodation date-range picker — non-monotonic day IDs (issue #889) ─────
// Builds the reporter's exact ID layout: day_number 1-9 → IDs 17-25, day_number 10-16 → IDs 1-7.
// This happens after repeated trip-length changes via generateDays (no import/migration needed).
function buildNonMonotonicDays() {
return [
buildDay({ id: 17, trip_id: 1, date: '2026-04-30' }),
buildDay({ id: 18, trip_id: 1, date: '2026-05-01' }),
buildDay({ id: 19, trip_id: 1, date: '2026-05-02' }),
buildDay({ id: 20, trip_id: 1, date: '2026-05-03' }),
buildDay({ id: 21, trip_id: 1, date: '2026-05-04' }),
buildDay({ id: 22, trip_id: 1, date: '2026-05-05' }),
buildDay({ id: 23, trip_id: 1, date: '2026-05-06' }),
buildDay({ id: 24, trip_id: 1, date: '2026-05-07' }),
buildDay({ id: 25, trip_id: 1, date: '2026-05-08' }),
buildDay({ id: 1, trip_id: 1, date: '2026-05-09' }),
buildDay({ id: 2, trip_id: 1, date: '2026-05-10' }),
buildDay({ id: 3, trip_id: 1, date: '2026-05-11' }),
buildDay({ id: 4, trip_id: 1, date: '2026-05-12' }),
buildDay({ id: 5, trip_id: 1, date: '2026-05-13' }),
buildDay({ id: 6, trip_id: 1, date: '2026-05-14' }),
buildDay({ id: 7, trip_id: 1, date: '2026-05-15' }),
];
}
// Returns the two CustomSelect trigger buttons for start/end day pickers.
// When no dropdown is open, these are the only globally-visible buttons whose textContent
// matches /Day \d+/ (the main panel title is a div, not a button).
// [0] = start trigger, [1] = end trigger (DOM source order).
function getDayPickerTriggers() {
return screen.getAllByRole('button').filter(b => /Day \d+/.test(b.textContent ?? ''));
}
it('FE-PLANNER-DAYDETAIL-056: non-monotonic IDs — end picker does not clobber start-day', async () => {
const days = buildNonMonotonicDays();
const place = buildPlace({ id: 50, name: 'Range Hotel' });
let capturedBody: any;
server.use(
http.post('/api/trips/1/accommodations', async ({ request }) => {
capturedBody = await request.json();
return HttpResponse.json({
accommodation: {
id: 99, place_id: 50, place_name: 'Range Hotel', place_address: null,
start_day_id: capturedBody.start_day_id, end_day_id: capturedBody.end_day_id,
check_in: null, check_out: null, confirmation: null,
},
});
}),
);
render(<DayDetailPanel {...defaultProps} day={days[0]} days={days} places={[place]} />);
await userEvent.click(await screen.findByText(/Add accommodation/i));
await userEvent.click(await screen.findByRole('button', { name: /Range Hotel/i }));
// Both triggers show "Day 1"; the second one is the end picker.
await userEvent.click(getDayPickerTriggers()[1]);
// Select "Day 16" (id=7) from the open dropdown — textContent starts with "Day 16".
await userEvent.click(screen.getAllByRole('button').find(b => b.textContent?.startsWith('Day 16'))!);
await userEvent.click(screen.getByRole('button', { name: /^Save$/i }));
await waitFor(() => {
// start must remain id 17 (day 1) — old code would clobber it to id 7 via Math.min
expect(capturedBody?.start_day_id).toBe(17);
expect(capturedBody?.end_day_id).toBe(7);
});
});
it('FE-PLANNER-DAYDETAIL-057: non-monotonic IDs — start picker does not collapse end when start has high ID', async () => {
const days = buildNonMonotonicDays();
const place = buildPlace({ id: 51, name: 'Span Hotel' });
let capturedBody: any;
server.use(
http.post('/api/trips/1/accommodations', async ({ request }) => {
capturedBody = await request.json();
return HttpResponse.json({
accommodation: {
id: 100, place_id: 51, place_name: 'Span Hotel', place_address: null,
start_day_id: capturedBody.start_day_id, end_day_id: capturedBody.end_day_id,
check_in: null, check_out: null, confirmation: null,
},
});
}),
);
render(<DayDetailPanel {...defaultProps} day={days[0]} days={days} places={[place]} />);
await userEvent.click(await screen.findByText(/Add accommodation/i));
await userEvent.click(await screen.findByRole('button', { name: /Span Hotel/i }));
// Set end to day 16 (id=7, low ID but last day by position).
await userEvent.click(getDayPickerTriggers()[1]);
await userEvent.click(screen.getAllByRole('button').find(b => b.textContent?.startsWith('Day 16'))!);
// Set start to day 9 (id=25, high ID, but earlier by position than day 16).
// Old code: Math.max(25, 7) = 25 → end collapses to day 9.
// New code: position(id=25)=8 < position(id=7)=15 → end stays at 7 (day 16).
await userEvent.click(getDayPickerTriggers()[0]);
await userEvent.click(screen.getAllByRole('button').find(b => b.textContent?.startsWith('Day 9'))!);
await userEvent.click(screen.getByRole('button', { name: /^Save$/i }));
await waitFor(() => {
expect(capturedBody?.start_day_id).toBe(25); // day 9
expect(capturedBody?.end_day_id).toBe(7); // day 16 — must NOT have collapsed
});
});
it('FE-PLANNER-DAYDETAIL-058: non-monotonic IDs — All days button sets correct first/last IDs', async () => {
const days = buildNonMonotonicDays();
const place = buildPlace({ id: 52, name: 'Full Trip Hotel' });
let capturedBody: any;
server.use(
http.post('/api/trips/1/accommodations', async ({ request }) => {
capturedBody = await request.json();
return HttpResponse.json({
accommodation: {
id: 101, place_id: 52, place_name: 'Full Trip Hotel', place_address: null,
start_day_id: capturedBody.start_day_id, end_day_id: capturedBody.end_day_id,
check_in: null, check_out: null, confirmation: null,
},
});
}),
);
render(<DayDetailPanel {...defaultProps} day={days[0]} days={days} places={[place]} />);
await userEvent.click(await screen.findByText(/Add accommodation/i));
await userEvent.click(await screen.findByRole('button', { name: /Full Trip Hotel/i }));
// "All" is the day.allDays translation (en: "All") — the Apply-to-entire-trip button.
// When categories=[] the category-filter "All" button is not rendered, so this is unique.
await userEvent.click(screen.getByRole('button', { name: /^All$/i }));
await userEvent.click(screen.getByRole('button', { name: /^Save$/i }));
await waitFor(() => {
// days[0].id=17 (first by position), days[15].id=7 (last by position)
expect(capturedBody?.start_day_id).toBe(17);
expect(capturedBody?.end_day_id).toBe(7);
});
});
it('FE-PLANNER-DAYDETAIL-059: sequential IDs — end picker clamping still works (regression guard)', async () => {
const seqDays = [
buildDay({ id: 101, trip_id: 1, date: '2026-06-01' }),
buildDay({ id: 102, trip_id: 1, date: '2026-06-02' }),
buildDay({ id: 103, trip_id: 1, date: '2026-06-03' }),
];
const place = buildPlace({ id: 53, name: 'Seq Hotel' });
let capturedBody: any;
server.use(
http.post('/api/trips/1/accommodations', async ({ request }) => {
capturedBody = await request.json();
return HttpResponse.json({
accommodation: {
id: 102, place_id: 53, place_name: 'Seq Hotel', place_address: null,
start_day_id: capturedBody.start_day_id, end_day_id: capturedBody.end_day_id,
check_in: null, check_out: null, confirmation: null,
},
});
}),
);
render(<DayDetailPanel {...defaultProps} day={seqDays[0]} days={seqDays} places={[place]} />);
await userEvent.click(await screen.findByText(/Add accommodation/i));
await userEvent.click(await screen.findByRole('button', { name: /Seq Hotel/i }));
// Pick end = day 3 (id=103, position 2 > position 0 of start id=101).
await userEvent.click(getDayPickerTriggers()[1]);
await userEvent.click(screen.getAllByRole('button').find(b => b.textContent?.startsWith('Day 3'))!);
await userEvent.click(screen.getByRole('button', { name: /^Save$/i }));
await waitFor(() => {
expect(capturedBody?.start_day_id).toBe(101);
expect(capturedBody?.end_day_id).toBe(103);
});
});
// ── Post-save state filter — non-monotonic IDs (issue #889 follow-up) ────────
it('FE-PLANNER-DAYDETAIL-060: non-monotonic IDs — hotel stays visible after edit-save (issue #889 regression)', async () => {
const days = buildNonMonotonicDays();
let getCallCount = 0;
server.use(
http.get('/api/trips/1/accommodations', () => {
getCallCount++;
const acc = getCallCount === 1
// Initial load: single-day so old filter (17>=17 && 17<=17) passes — hotel visible, edit possible
? { id: 1, place_id: 50, place_name: 'Span Hotel', place_address: null, start_day_id: 17, end_day_id: 17, check_in: null, check_out: null, confirmation: null }
// Post-save relist: full span — old filter (17>=17 && 17<=7) would drop it, new code keeps it
: { id: 1, place_id: 50, place_name: 'Span Hotel', place_address: null, start_day_id: 17, end_day_id: 7, check_in: null, check_out: null, confirmation: null };
return HttpResponse.json({ accommodations: [acc] });
}),
http.put('/api/trips/1/accommodations/1', async ({ request }) => {
const body = await request.json() as any;
return HttpResponse.json({
accommodation: { id: 1, place_id: 50, place_name: 'Span Hotel', place_address: null,
start_day_id: body.start_day_id, end_day_id: body.end_day_id,
check_in: null, check_out: null, confirmation: null },
});
}),
);
render(<DayDetailPanel {...defaultProps} day={days[0]} days={days} />);
await screen.findByText('Span Hotel');
// Pencil = 3rd button (index 2): collapse, close, pencil, remove
const allButtons = screen.getAllByRole('button');
await userEvent.click(allButtons[2]);
// Extend end picker to Day 16 (id=7)
await userEvent.click(getDayPickerTriggers()[1]);
await userEvent.click(screen.getAllByRole('button').find(b => b.textContent?.startsWith('Day 16'))!);
await userEvent.click(screen.getByRole('button', { name: /^Save$/i }));
// Old code: 17>=17 && 17<=7 → false (hotel vanishes). New code: position 0 in [0,15] → visible.
await waitFor(() => {
expect(screen.getByText('Span Hotel')).toBeInTheDocument();
});
});
it('FE-PLANNER-DAYDETAIL-061: non-monotonic IDs — hotel appears after create-save on intermediate day', async () => {
const days = buildNonMonotonicDays();
const place = buildPlace({ id: 55, name: 'Created Hotel' });
// Current day: days[5] = id 22, position 5 (within any full-span range)
const currentDay = days[5];
server.use(
http.post('/api/trips/1/accommodations', async ({ request }) => {
const body = await request.json() as any;
return HttpResponse.json({
accommodation: { id: 200, place_id: 55, place_name: 'Created Hotel', place_address: null,
start_day_id: body.start_day_id, end_day_id: body.end_day_id,
check_in: null, check_out: null, confirmation: null },
});
}),
);
render(<DayDetailPanel {...defaultProps} day={currentDay} days={days} places={[place]} />);
await userEvent.click(await screen.findByText(/Add accommodation/i));
await userEvent.click(await screen.findByRole('button', { name: /Created Hotel/i }));
// Extend end to Day 16 (id=7) — start stays at current day id=22
await userEvent.click(getDayPickerTriggers()[1]);
await userEvent.click(screen.getAllByRole('button').find(b => b.textContent?.startsWith('Day 16'))!);
await userEvent.click(screen.getByRole('button', { name: /^Save$/i }));
// Old code: 22>=22 && 22<=7 → false (hotel vanishes). New code: position 5 in [5,15] → visible.
await waitFor(() => {
expect(screen.getByText('Created Hotel')).toBeInTheDocument();
});
});
it('FE-PLANNER-DAYDETAIL-062: non-monotonic IDs — hotel shown on initial load when it spans the full trip', async () => {
const days = buildNonMonotonicDays();
server.use(
http.get('/api/trips/1/accommodations', () =>
HttpResponse.json({
accommodations: [{ id: 1, place_id: 60, place_name: 'Full Trip Hotel', place_address: null,
start_day_id: 17, end_day_id: 7, check_in: null, check_out: null, confirmation: null }],
})
),
);
// Day 1 (id=17): old filter: 17>=17 && 17<=7 → false. New: position 0 in [0,15] → visible.
render(<DayDetailPanel {...defaultProps} day={days[0]} days={days} />);
await screen.findByText('Full Trip Hotel');
// Intermediate day (id=1, position 9): old filter: 1>=17 → false. New: 9 in [0,15] → visible.
render(<DayDetailPanel {...defaultProps} day={days[9]} days={days} />);
await screen.findByText('Full Trip Hotel');
});
it('FE-PLANNER-DAYDETAIL-040: 12h time format renders reservation time with AM/PM', async () => {
seedStore(useSettingsStore, {
settings: { time_format: '12h', temperature_unit: 'celsius', blur_booking_codes: false },
@@ -12,6 +12,7 @@ import CustomTimePicker from '../shared/CustomTimePicker'
import { useSettingsStore } from '../../store/settingsStore'
import { getLocaleForLanguage, useTranslation } from '../../i18n'
import type { Day, Place, Category, Reservation, AssignmentsMap } from '../../types'
import { isDayInAccommodationRange } from '../../utils/dayOrder'
const WEATHER_ICON_MAP = {
Clear: Sun, Clouds: Cloud, Rain: CloudRain, Drizzle: CloudDrizzle,
@@ -99,7 +100,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
.then(data => {
setAccommodations(data.accommodations || [])
const allForDay = (data.accommodations || []).filter(a =>
days.some(d => d.id >= a.start_day_id && d.id <= a.end_day_id && d.id === day?.id)
day ? isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days) : false
)
setDayAccommodations(allForDay)
setAccommodation(allForDay[0] || null)
@@ -130,7 +131,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
setAccommodations(updated)
setAccommodation(newAcc)
setDayAccommodations(updated.filter(a =>
days.some(d => d.id >= a.start_day_id && d.id <= a.end_day_id && d.id === day?.id)
day ? isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days) : false
))
setShowHotelPicker(false)
setHotelForm({ check_in: '', check_in_end: '', check_out: '', confirmation: '', place_id: null })
@@ -154,7 +155,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
const updated = accommodations.filter(a => a.id !== accommodation.id)
setAccommodations(updated)
setDayAccommodations(updated.filter(a =>
days.some(d => d.id >= a.start_day_id && d.id <= a.end_day_id && d.id === day?.id)
day ? isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days) : false
))
setAccommodation(null)
onAccommodationChange?.()
@@ -463,7 +464,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
<div style={{ flex: 1, minWidth: 0 }}>
<CustomSelect
value={hotelDayRange.start}
onChange={v => setHotelDayRange(prev => ({ start: v, end: Math.max(v, prev.end) }))}
onChange={v => setHotelDayRange(prev => ({ start: v, end: days.findIndex(d => d.id === v) > days.findIndex(d => d.id === prev.end) ? v : prev.end }))}
options={days.map((d, i) => ({
value: d.id,
label: d.title || t('planner.dayN', { n: i + 1 }),
@@ -478,7 +479,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
<div style={{ flex: 1, minWidth: 0 }}>
<CustomSelect
value={hotelDayRange.end}
onChange={v => setHotelDayRange(prev => ({ start: Math.min(prev.start, v), end: v }))}
onChange={v => setHotelDayRange(prev => ({ start: days.findIndex(d => d.id === v) < days.findIndex(d => d.id === prev.start) ? v : prev.start, end: v }))}
options={days.map((d, i) => ({
value: d.id,
label: d.title || t('planner.dayN', { n: i + 1 }),
@@ -598,9 +599,9 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
const all = d.accommodations || []
setAccommodations(all)
setDayAccommodations(all.filter(a =>
days.some(dd => dd.id >= a.start_day_id && dd.id <= a.end_day_id && dd.id === day?.id)
day ? isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days) : false
))
const acc = all.find(a => days.some(dd => dd.id >= a.start_day_id && dd.id <= a.end_day_id && dd.id === day?.id))
const acc = all.find(a => day ? isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days) : false)
setAccommodation(acc || null)
})
onAccommodationChange?.()
@@ -2,7 +2,7 @@
interface DragDataPayload { placeId?: string; assignmentId?: string; noteId?: string; reservationId?: string; fromDayId?: string; phase?: 'single' | 'start' | 'middle' | 'end' }
declare global { interface Window { __dragData: DragDataPayload | null } }
import React, { useState, useEffect, useRef, useMemo } from 'react'
import React, { useState, useEffect, useLayoutEffect, useRef, useMemo } from 'react'
import ReactDOM from 'react-dom'
import { ChevronDown, ChevronRight, ChevronUp, ChevronsDownUp, ChevronsUpDown, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Check, Trash2, Info, MapPin, Star, Heart, Camera, Lightbulb, Flag, Bookmark, Train, Bus, Plane, Car, Ship, Coffee, ShoppingBag, AlertTriangle, FileDown, Lock, Hotel, Utensils, Users, Undo2, X, Route as RouteIcon } from 'lucide-react'
@@ -14,6 +14,7 @@ import PlaceAvatar from '../shared/PlaceAvatar'
import { useContextMenu, ContextMenu } from '../shared/ContextMenu'
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkBreaks from 'remark-breaks'
import WeatherWidget from '../Weather/WeatherWidget'
import { useToast } from '../shared/Toast'
import { getCategoryIcon } from '../shared/categoryIcons'
@@ -21,6 +22,7 @@ import { useTripStore } from '../../store/tripStore'
import { useCanDo } from '../../store/permissionsStore'
import { useSettingsStore } from '../../store/settingsStore'
import { useTranslation } from '../../i18n'
import { isDayInAccommodationRange } from '../../utils/dayOrder'
import { formatDate, formatTime, dayTotalCost, currencyDecimals } from '../../utils/formatters'
import { useDayNotes } from '../../hooks/useDayNotes'
import Tooltip from '../shared/Tooltip'
@@ -189,6 +191,8 @@ interface DayPlanSidebarProps {
onEditTransport?: (reservation: Reservation) => void
onEditReservation?: (reservation: Reservation) => void
onAddBookingToAssignment?: (dayId: number, assignmentId: number) => void
initialScrollTop?: number
onScrollTopChange?: (top: number) => void
}
const DayPlanSidebar = React.memo(function DayPlanSidebar({
@@ -217,6 +221,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
onEditTransport,
onEditReservation,
onAddBookingToAssignment,
initialScrollTop,
onScrollTopChange,
}: DayPlanSidebarProps) {
const toast = useToast()
const { t, language, locale } = useTranslation()
@@ -269,6 +275,12 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
} | null>(null)
const inputRef = useRef(null)
const dragDataRef = useRef(null)
const scrollContainerRef = useRef<HTMLDivElement | null>(null)
useLayoutEffect(() => {
if (scrollContainerRef.current && initialScrollTop) {
scrollContainerRef.current.scrollTop = initialScrollTop
}
}, [])
const initedTransportIds = useRef(new Set<number>()) // Speichert Drag-Daten als Backup (dataTransfer geht bei Re-Render verloren)
// Remember which assignment we last auto-scrolled into view so we don't
// keep yanking the user back whenever they scroll away while the same
@@ -397,7 +409,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
const getTransportForDay = (dayId: number) => {
const dayAssignmentIds = (assignments[String(dayId)] || []).map(a => a.id)
return reservations.filter(r => {
if (r.type === 'hotel') return false
if (!TRANSPORT_TYPES.has(r.type)) return false
if (r.assignment_id && dayAssignmentIds.includes(r.assignment_id)) return false
const startDayId = r.day_id
@@ -1116,7 +1128,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
</div>
{/* Tagesliste */}
<div className={`scroll-container${draggingId ? '' : ' trek-stagger'}`} style={{ flex: 1, overflowY: 'auto', minHeight: 0 }}>
<div className={`scroll-container${draggingId ? '' : ' trek-stagger'}`} style={{ flex: 1, overflowY: 'auto', minHeight: 0 }} ref={scrollContainerRef} onScroll={(e) => onScrollTopChange?.((e.currentTarget as HTMLElement).scrollTop)}>
{days.map((day, index) => {
const isSelected = selectedDayId === day.id
const isExpanded = expandedDays.has(day.id)
@@ -1214,7 +1226,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
</Tooltip>
)}
{(() => {
const dayAccs = accommodations.filter(a => day.id >= a.start_day_id && day.id <= a.end_day_id)
const dayAccs = accommodations.filter(a => isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days))
// Sort: check-out first, then ongoing stays, then check-in last
.sort((a, b) => {
const aIsOut = a.end_day_id === day.id && a.start_day_id !== day.id
@@ -1725,7 +1737,11 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
return (
<React.Fragment key={`transport-${res.id}-${day.id}`}>
<div
onClick={() => canEditDays && onEditTransport?.(res)}
onClick={() => {
if (!canEditDays) return
if (TRANSPORT_TYPES.has(res.type)) onEditTransport?.(res)
else onEditReservation?.(res)
}}
onDragOver={e => {
e.preventDefault(); e.stopPropagation()
const rect = e.currentTarget.getBoundingClientRect()
@@ -2223,7 +2239,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
{res.notes && (
<div style={{ padding: '8px 10px', background: 'var(--bg-tertiary)', borderRadius: 8 }}>
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em', marginBottom: 3 }}>{t('reservations.notes')}</div>
<div className="collab-note-md" style={{ fontSize: 12, color: 'var(--text-primary)', wordBreak: 'break-word' }}><Markdown remarkPlugins={[remarkGfm]}>{res.notes}</Markdown></div>
<div className="collab-note-md" style={{ fontSize: 12, color: 'var(--text-primary)', wordBreak: 'break-word', overflowWrap: 'anywhere' }}><Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{res.notes}</Markdown></div>
</div>
)}
@@ -2,6 +2,7 @@ import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'
import { openFile } from '../../utils/fileDownload'
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkBreaks from 'remark-breaks'
import { X, Clock, MapPin, ExternalLink, Phone, Euro, Edit2, Trash2, Plus, Minus, ChevronDown, ChevronUp, FileText, Upload, File, FileImage, Star, Navigation, Users, Mountain, TrendingUp } from 'lucide-react'
import PlaceAvatar from '../shared/PlaceAvatar'
import { mapsApi } from '../../api/client'
@@ -349,8 +350,8 @@ export default function PlaceInspector({
{/* Notes */}
{place.notes && (
<div className="collab-note-md" style={{ background: 'var(--bg-hover)', borderRadius: 10, overflow: 'hidden', fontSize: 12, color: 'var(--text-muted)', lineHeight: '1.5', padding: '8px 12px' }}>
<Markdown remarkPlugins={[remarkGfm]}>{place.notes}</Markdown>
<div className="collab-note-md" style={{ background: 'var(--bg-hover)', borderRadius: 10, overflow: 'hidden', fontSize: 12, color: 'var(--text-muted)', lineHeight: '1.5', padding: '8px 12px', wordBreak: 'break-word', overflowWrap: 'anywhere' }}>
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{place.notes}</Markdown>
</div>
)}
@@ -399,7 +400,7 @@ export default function PlaceInspector({
</div>
)}
</div>
{res.notes && <div className="collab-note-md" style={{ padding: '0 10px 6px', fontSize: 10, color: 'var(--text-faint)', lineHeight: 1.4 }}><Markdown remarkPlugins={[remarkGfm]}>{res.notes}</Markdown></div>}
{res.notes && <div className="collab-note-md" style={{ padding: '0 10px 6px', fontSize: 10, color: 'var(--text-faint)', lineHeight: 1.4, wordBreak: 'break-word', overflowWrap: 'anywhere' }}><Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{res.notes}</Markdown></div>}
{(() => {
const meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {})
if (!meta || Object.keys(meta).length === 0) return null
@@ -1,6 +1,6 @@
import React from 'react'
import ReactDOM from 'react-dom'
import { useState, useMemo, useEffect, useRef, useCallback } from 'react'
import { useState, useMemo, useEffect, useLayoutEffect, useRef, useCallback } from 'react'
import { Search, Plus, X, CalendarDays, Pencil, Trash2, ExternalLink, Navigation, Upload, ChevronDown, Check, MapPin, Eye, Route } from 'lucide-react'
import PlaceAvatar from '../shared/PlaceAvatar'
import { getCategoryIcon } from '../shared/categoryIcons'
@@ -34,6 +34,8 @@ interface PlacesSidebarProps {
onCategoryFilterChange?: (categoryIds: Set<string>) => void
onPlacesFilterChange?: (filter: string) => void
pushUndo?: (label: string, undoFn: () => Promise<void> | void) => void
initialScrollTop?: number
onScrollTopChange?: (top: number) => void
}
interface MemoPlaceRowProps {
@@ -145,6 +147,7 @@ const MemoPlaceRow = React.memo(function MemoPlaceRow({
const PlacesSidebar = React.memo(function PlacesSidebar({
tripId, places, categories, assignments, selectedDayId, selectedPlaceId,
onPlaceClick, onAddPlace, onAssignToDay, onEditPlace, onDeletePlace, onBulkDeletePlaces, onBulkDeleteConfirm, days, isMobile, onCategoryFilterChange, onPlacesFilterChange, pushUndo,
initialScrollTop, onScrollTopChange,
}: PlacesSidebarProps) {
const { t } = useTranslation()
const toast = useToast()
@@ -159,6 +162,12 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
const [sidebarDropFile, setSidebarDropFile] = useState<File | null>(null)
const [sidebarDragOver, setSidebarDragOver] = useState(false)
const sidebarDragCounter = useRef(0)
const scrollContainerRef = useRef<HTMLDivElement | null>(null)
useLayoutEffect(() => {
if (scrollContainerRef.current && initialScrollTop) {
scrollContainerRef.current.scrollTop = initialScrollTop
}
}, [])
const handleSidebarDragEnter = (e: React.DragEvent) => {
if (!canEditPlaces) return
@@ -636,7 +645,7 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
)}
{/* Liste */}
<div className="trek-stagger" style={{ flex: 1, overflowY: 'auto', minHeight: 0 }}>
<div className="trek-stagger" style={{ flex: 1, overflowY: 'auto', minHeight: 0 }} ref={scrollContainerRef} onScroll={(e) => onScrollTopChange?.((e.currentTarget as HTMLElement).scrollTop)}>
{filtered.length === 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', padding: '40px 16px', gap: 8 }}>
<span style={{ fontSize: 13, color: 'var(--text-faint)' }}>
@@ -1,4 +1,4 @@
// FE-PLANNER-RESMODAL-001 to FE-PLANNER-RESMODAL-035
// FE-PLANNER-RESMODAL-001 to FE-PLANNER-RESMODAL-052
import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
@@ -723,4 +723,103 @@ describe('ReservationModal', () => {
expect.objectContaining({ type: 'hotel' })
);
});
// ── Hotel day-range picker — non-monotonic IDs (issue #929) ───────────────
// Mirrors DayDetailPanel-056/057 for the ReservationModal path.
// ID layout: day_number 1-9 → IDs 17-25, day_number 10-16 → IDs 1-7.
function buildNonMonotonicDaysRM() {
return [
buildDay({ id: 17, trip_id: 1, date: '2026-04-30', day_number: 1 }),
buildDay({ id: 18, trip_id: 1, date: '2026-05-01', day_number: 2 }),
buildDay({ id: 19, trip_id: 1, date: '2026-05-02', day_number: 3 }),
buildDay({ id: 20, trip_id: 1, date: '2026-05-03', day_number: 4 }),
buildDay({ id: 21, trip_id: 1, date: '2026-05-04', day_number: 5 }),
buildDay({ id: 22, trip_id: 1, date: '2026-05-05', day_number: 6 }),
buildDay({ id: 23, trip_id: 1, date: '2026-05-06', day_number: 7 }),
buildDay({ id: 24, trip_id: 1, date: '2026-05-07', day_number: 8 }),
buildDay({ id: 25, trip_id: 1, date: '2026-05-08', day_number: 9 }),
buildDay({ id: 1, trip_id: 1, date: '2026-05-09', day_number: 10 }),
buildDay({ id: 2, trip_id: 1, date: '2026-05-10', day_number: 11 }),
buildDay({ id: 3, trip_id: 1, date: '2026-05-11', day_number: 12 }),
buildDay({ id: 4, trip_id: 1, date: '2026-05-12', day_number: 13 }),
buildDay({ id: 5, trip_id: 1, date: '2026-05-13', day_number: 14 }),
buildDay({ id: 6, trip_id: 1, date: '2026-05-14', day_number: 15 }),
buildDay({ id: 7, trip_id: 1, date: '2026-05-15', day_number: 16 }),
] as any[];
}
it('FE-PLANNER-RESMODAL-050: non-monotonic IDs — end picker with low ID does not clobber start', async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
const days = buildNonMonotonicDaysRM();
render(<ReservationModal {...defaultProps} onSave={onSave} days={days} />);
// Switch to hotel type
await userEvent.click(screen.getByRole('button', { name: /^Accommodation$/i }));
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Overlap Hotel');
// Open start picker (first "Select day" trigger) and select Day 1 (id=17)
const startTrigger = () => screen.getAllByRole('button').filter(b => b.textContent?.includes('Select day') || b.textContent?.startsWith('Day '))[0];
await userEvent.click(startTrigger());
await userEvent.click(screen.getAllByRole('button').find(b => b.textContent?.startsWith('Day 1') && !b.textContent?.startsWith('Day 1 ') || b.textContent?.trim() === 'Day 1')!);
// Open end picker and select Day 16 (id=7, low ID but last positionally)
const endTrigger = () => screen.getAllByRole('button').filter(b => b.textContent?.includes('Select day') || /^Day \d+/.test(b.textContent?.trim() ?? ''))[1];
await userEvent.click(endTrigger());
await userEvent.click(screen.getAllByRole('button').find(b => b.textContent?.startsWith('Day 16'))!);
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
await waitFor(() => expect(onSave).toHaveBeenCalled());
const saved = onSave.mock.calls[0][0];
// start must stay id=17 (Day 1) — old Math.max would clobber it to id=7
expect(saved.create_accommodation?.start_day_id).toBe(17);
expect(saved.create_accommodation?.end_day_id).toBe(7);
});
it('FE-PLANNER-RESMODAL-051: non-monotonic IDs — start picker does not collapse end when start has high ID', async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
const days = buildNonMonotonicDaysRM();
render(<ReservationModal {...defaultProps} onSave={onSave} days={days} />);
await userEvent.click(screen.getByRole('button', { name: /^Accommodation$/i }));
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Span Hotel');
// Set end to Day 16 (id=7) first
const endTrigger = () => screen.getAllByRole('button').filter(b => b.textContent?.includes('Select day') || /^Day \d+/.test(b.textContent?.trim() ?? ''))[1];
await userEvent.click(endTrigger());
await userEvent.click(screen.getAllByRole('button').find(b => b.textContent?.startsWith('Day 16'))!);
// Set start to Day 9 (id=25, high ID but earlier by position than Day 16)
// Old code: Math.max(25, 7) = 25 → end collapses to Day 9.
// New code: position(id=25)=8 < position(id=7)=15 → end stays id=7.
const startTrigger = () => screen.getAllByRole('button').filter(b => b.textContent?.includes('Select day') || /^Day \d+/.test(b.textContent?.trim() ?? ''))[0];
await userEvent.click(startTrigger());
await userEvent.click(screen.getAllByRole('button').find(b => b.textContent?.startsWith('Day 9'))!);
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
await waitFor(() => expect(onSave).toHaveBeenCalled());
const saved = onSave.mock.calls[0][0];
expect(saved.create_accommodation?.start_day_id).toBe(25); // Day 9
expect(saved.create_accommodation?.end_day_id).toBe(7); // Day 16 — must NOT have collapsed
});
it('FE-PLANNER-RESMODAL-052: hotel with no accommodation_id sends assignment_id as null (issue #934)', async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
// Hotel reservation with assignment_id set but no accommodation
const res = buildReservation({
id: 10, title: 'Stale Hotel', type: 'hotel', status: 'confirmed',
accommodation_id: null, assignment_id: 99,
} as any);
render(<ReservationModal {...defaultProps} onSave={onSave} reservation={res} />);
await userEvent.click(screen.getByRole('button', { name: /^Update$/i }));
await waitFor(() => expect(onSave).toHaveBeenCalled());
expect(onSave.mock.calls[0][0].assignment_id).toBeNull();
});
});
@@ -196,7 +196,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
reservation_end_time: form.type === 'hotel' ? null : (combinedEndTime || null),
location: form.location, confirmation_number: form.confirmation_number,
notes: form.notes,
assignment_id: form.assignment_id || null,
assignment_id: (form.type === 'hotel' && !form.accommodation_id) ? null : (form.assignment_id || null),
accommodation_id: form.type === 'hotel' ? (form.accommodation_id || null) : null,
metadata: Object.keys(metadata).length > 0 ? metadata : null,
endpoints: [],
@@ -459,7 +459,12 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
<label style={labelStyle}>{t('reservations.meta.fromDay')}</label>
<CustomSelect
value={form.hotel_start_day}
onChange={value => set('hotel_start_day', value)}
onChange={value => setForm(prev => ({
...prev,
hotel_start_day: value,
hotel_end_day: days.findIndex(d => d.id === value) > days.findIndex(d => d.id === prev.hotel_end_day)
? value : prev.hotel_end_day,
}))}
placeholder={t('reservations.meta.selectDay')}
options={days.map(d => {
const dateBadge = d.date ? (formatDate(d.date, locale) ?? undefined) : undefined
@@ -477,7 +482,12 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
<label style={labelStyle}>{t('reservations.meta.toDay')}</label>
<CustomSelect
value={form.hotel_end_day}
onChange={value => set('hotel_end_day', value)}
onChange={value => setForm(prev => ({
...prev,
hotel_start_day: days.findIndex(d => d.id === value) < days.findIndex(d => d.id === prev.hotel_start_day)
? value : prev.hotel_start_day,
hotel_end_day: value,
}))}
placeholder={t('reservations.meta.selectDay')}
options={days.map(d => {
const dateBadge = d.date ? (formatDate(d.date, locale) ?? undefined) : undefined
@@ -11,6 +11,9 @@ import {
ExternalLink, BookMarked, Lightbulb, Link2, Clock, ArrowRight, AlertCircle,
} from 'lucide-react'
import { openFile } from '../../utils/fileDownload'
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkBreaks from 'remark-breaks'
import type { Reservation, Day, TripFile, AssignmentsMap } from '../../types'
interface AssignmentLookupEntry {
@@ -364,7 +367,9 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
{r.notes && (
<div>
<div style={fieldLabelStyle}>{t('reservations.notes')}</div>
<div style={{ ...fieldValueStyle, fontWeight: 400, lineHeight: 1.5 }}>{r.notes}</div>
<div className="collab-note-md" style={{ ...fieldValueStyle, fontWeight: 400, lineHeight: 1.5, wordBreak: 'break-word', overflowWrap: 'anywhere' }}>
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{r.notes}</Markdown>
</div>
</div>
)}
@@ -0,0 +1,324 @@
// FE-PLANNER-TRANSMODAL-001 to FE-PLANNER-TRANSMODAL-021
import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
import { useAuthStore } from '../../store/authStore';
import { useTripStore } from '../../store/tripStore';
import { useAddonStore } from '../../store/addonStore';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import {
buildUser,
buildTrip,
buildDay,
buildReservation,
buildTripFile,
} from '../../../tests/helpers/factories';
import { TransportModal } from './TransportModal';
vi.mock('react-router-dom', async (importActual) => {
const actual = await importActual<typeof import('react-router-dom')>();
return { ...actual, useParams: () => ({ id: '1' }) };
});
vi.mock('../shared/CustomTimePicker', () => ({
default: ({ value, onChange }: { value: string; onChange: (v: string) => void }) => (
<input data-testid="time-picker" type="text" value={value} onChange={e => onChange(e.target.value)} />
),
}));
vi.mock('./AirportSelect', () => ({
default: ({ onChange }: { onChange: (a: any) => void }) => (
<input data-testid="airport-select" type="text" onChange={e => onChange({ iata: e.target.value, name: e.target.value, city: '', country: '', lat: 0, lng: 0, tz: 'UTC', icao: null })} />
),
}));
vi.mock('./LocationSelect', () => ({
default: ({ onChange }: { onChange: (l: any) => void }) => (
<input data-testid="location-select" type="text" onChange={e => onChange({ name: e.target.value, lat: 0, lng: 0, address: null })} />
),
}));
const defaultProps = {
isOpen: true,
onClose: vi.fn(),
onSave: vi.fn().mockResolvedValue(undefined),
reservation: null,
days: [],
selectedDayId: null,
files: [],
onFileUpload: vi.fn().mockResolvedValue(undefined),
onFileDelete: vi.fn().mockResolvedValue(undefined),
};
beforeEach(() => {
resetAllStores();
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
seedStore(useTripStore, { trip: buildTrip({ id: 1 }), budgetItems: [] });
vi.clearAllMocks();
});
describe('TransportModal', () => {
// ── Rendering ──────────────────────────────────────────────────────────────
it('FE-PLANNER-TRANSMODAL-001: renders without crashing', () => {
render(<TransportModal {...defaultProps} />);
expect(document.body).toBeInTheDocument();
});
it('FE-PLANNER-TRANSMODAL-002: shows "Add transport" title for new transport', () => {
render(<TransportModal {...defaultProps} reservation={null} />);
expect(screen.getByText(/Add transport/i)).toBeInTheDocument();
});
it('FE-PLANNER-TRANSMODAL-003: shows "Edit transport" title when editing', () => {
const res = buildReservation({ title: 'Paris Flight', type: 'flight' });
render(<TransportModal {...defaultProps} reservation={res} />);
expect(screen.getByText(/Edit transport/i)).toBeInTheDocument();
});
it('FE-PLANNER-TRANSMODAL-004: title input is required — onSave not called with empty title', async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
render(<TransportModal {...defaultProps} onSave={onSave} />);
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
expect(onSave).not.toHaveBeenCalled();
});
it('FE-PLANNER-TRANSMODAL-005: all 4 transport type buttons are visible', () => {
render(<TransportModal {...defaultProps} />);
expect(screen.getByRole('button', { name: /^Flight$/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /^Train$/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /^Car$/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /^Cruise$/i })).toBeInTheDocument();
});
it('FE-PLANNER-TRANSMODAL-006: editing pre-fills title', () => {
const res = buildReservation({ title: 'LH123 Frankfurt', type: 'flight' });
render(<TransportModal {...defaultProps} reservation={res} />);
expect(screen.getByDisplayValue('LH123 Frankfurt')).toBeInTheDocument();
});
it('FE-PLANNER-TRANSMODAL-007: edit mode save button shows "Update"', () => {
const res = buildReservation({ title: 'My Train', type: 'train' });
render(<TransportModal {...defaultProps} reservation={res} />);
expect(screen.getByRole('button', { name: /^Update$/i })).toBeInTheDocument();
});
it('FE-PLANNER-TRANSMODAL-008: Cancel button calls onClose', async () => {
const onClose = vi.fn();
render(<TransportModal {...defaultProps} onClose={onClose} />);
await userEvent.click(screen.getByRole('button', { name: /Cancel/i }));
expect(onClose).toHaveBeenCalled();
});
it('FE-PLANNER-TRANSMODAL-009: submitting valid flight calls onSave with correct type', async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
render(<TransportModal {...defaultProps} onSave={onSave} />);
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'LH456');
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
await waitFor(() => expect(onSave).toHaveBeenCalled());
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ title: 'LH456', type: 'flight' }));
});
it('FE-PLANNER-TRANSMODAL-010: switching to train type calls onSave with train type', async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
render(<TransportModal {...defaultProps} onSave={onSave} />);
await userEvent.click(screen.getByRole('button', { name: /^Train$/i }));
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Eurostar');
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
await waitFor(() => expect(onSave).toHaveBeenCalled());
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ type: 'train' }));
});
// ── Budget addon ─────────────────────────────────────────────────────────────
it('FE-PLANNER-TRANSMODAL-011: budget section visible when addon is enabled', () => {
seedStore(useAddonStore, {
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
loaded: true,
});
render(<TransportModal {...defaultProps} />);
expect(screen.getByText(/^Price$/i)).toBeInTheDocument();
expect(screen.getByText(/Budget category/i)).toBeInTheDocument();
});
it('FE-PLANNER-TRANSMODAL-012: budget section not shown when addon is disabled', () => {
render(<TransportModal {...defaultProps} />);
expect(screen.queryByPlaceholderText('0.00')).not.toBeInTheDocument();
});
it('FE-PLANNER-TRANSMODAL-013: budget fields included in onSave when price is set', async () => {
seedStore(useAddonStore, {
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
loaded: true,
});
const onSave = vi.fn().mockResolvedValue(undefined);
render(<TransportModal {...defaultProps} onSave={onSave} />);
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'ICE Train');
await userEvent.type(screen.getByPlaceholderText('0.00'), '85');
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
await waitFor(() => expect(onSave).toHaveBeenCalled());
expect(onSave).toHaveBeenCalledWith(
expect.objectContaining({ create_budget_entry: expect.objectContaining({ total_price: 85 }) })
);
});
// ── File attachment ───────────────────────────────────────────────────────────
it('FE-PLANNER-TRANSMODAL-014: attach file button rendered when onFileUpload provided', () => {
render(<TransportModal {...defaultProps} />);
expect(screen.getByRole('button', { name: /Attach file/i })).toBeInTheDocument();
});
it('FE-PLANNER-TRANSMODAL-015: attach file button absent when onFileUpload is undefined', () => {
render(<TransportModal {...defaultProps} onFileUpload={undefined} />);
expect(screen.queryByRole('button', { name: /Attach file/i })).not.toBeInTheDocument();
});
it('FE-PLANNER-TRANSMODAL-016: attached files shown for existing transport', () => {
const res = buildReservation({ id: 5, type: 'flight' });
const file = buildTripFile({ id: 1, trip_id: 1, original_name: 'boarding-pass.pdf' });
(file as any).reservation_id = 5;
render(<TransportModal {...defaultProps} reservation={res} files={[file]} />);
expect(screen.getByText('boarding-pass.pdf')).toBeInTheDocument();
});
it('FE-PLANNER-TRANSMODAL-017: pending file added for new transport on file input change', async () => {
render(<TransportModal {...defaultProps} reservation={null} />);
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
const testFile = new File(['content'], 'itinerary.pdf', { type: 'application/pdf' });
fireEvent.change(fileInput, { target: { files: [testFile] } });
await waitFor(() => expect(screen.getByText('itinerary.pdf')).toBeInTheDocument());
});
it('FE-PLANNER-TRANSMODAL-018: file upload to existing transport calls onFileUpload with correct FormData', async () => {
const onFileUpload = vi.fn().mockResolvedValue(undefined);
const res = buildReservation({ id: 10, type: 'train', title: 'Eurostar' });
render(<TransportModal {...defaultProps} reservation={res} onFileUpload={onFileUpload} />);
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
const testFile = new File(['content'], 'ticket.pdf', { type: 'application/pdf' });
fireEvent.change(fileInput, { target: { files: [testFile] } });
await waitFor(() => expect(onFileUpload).toHaveBeenCalled());
const [fd] = onFileUpload.mock.calls[0] as [FormData];
expect(fd.get('file')).toBeTruthy();
expect(fd.get('reservation_id')).toBe('10');
});
it('FE-PLANNER-TRANSMODAL-019: link existing file button appears when unattached files exist', () => {
const res = buildReservation({ id: 5, type: 'flight' });
const unattachedFile = buildTripFile({ id: 99, original_name: 'invoice.pdf' });
render(<TransportModal {...defaultProps} reservation={res} files={[unattachedFile]} />);
expect(screen.getByRole('button', { name: /Link existing file/i })).toBeInTheDocument();
});
it('FE-PLANNER-TRANSMODAL-020: clicking "link existing file" shows file picker dropdown', async () => {
const res = buildReservation({ id: 5, type: 'flight' });
const unattachedFile = buildTripFile({ id: 99, original_name: 'invoice.pdf' });
render(<TransportModal {...defaultProps} reservation={res} files={[unattachedFile]} />);
await userEvent.click(screen.getByRole('button', { name: /Link existing file/i }));
expect(screen.getByText('invoice.pdf')).toBeInTheDocument();
});
it('FE-PLANNER-TRANSMODAL-021: clicking file in picker links it and closes picker', async () => {
server.use(
http.post('/api/trips/1/files/99/link', () => HttpResponse.json({ success: true })),
http.get('/api/trips/1/files', () => HttpResponse.json({ files: [] })),
);
const res = buildReservation({ id: 5, type: 'flight' });
const unattachedFile = buildTripFile({ id: 99, original_name: 'invoice.pdf' });
render(<TransportModal {...defaultProps} reservation={res} files={[unattachedFile]} />);
await userEvent.click(screen.getByRole('button', { name: /Link existing file/i }));
await userEvent.click(screen.getByText('invoice.pdf'));
await waitFor(() => {
expect(screen.queryByRole('button', { name: /Link existing file/i })).not.toBeInTheDocument();
});
});
it('FE-PLANNER-TRANSMODAL-022: removing pending file removes it from list', async () => {
render(<TransportModal {...defaultProps} reservation={null} />);
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
const testFile = new File(['content'], 'draft.pdf', { type: 'application/pdf' });
fireEvent.change(fileInput, { target: { files: [testFile] } });
await waitFor(() => expect(screen.getByText('draft.pdf')).toBeInTheDocument());
const pendingFileRow = screen.getByText('draft.pdf').closest('div')!;
const removeBtn = pendingFileRow.querySelector('button')!;
await userEvent.click(removeBtn);
await waitFor(() => expect(screen.queryByText('draft.pdf')).not.toBeInTheDocument());
});
it('FE-PLANNER-TRANSMODAL-023: clicking attach file button triggers file input click', async () => {
render(<TransportModal {...defaultProps} />);
const attachBtn = screen.getByRole('button', { name: /Attach file/i });
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
const clickSpy = vi.spyOn(fileInput, 'click').mockImplementation(() => {});
await userEvent.click(attachBtn);
expect(clickSpy).toHaveBeenCalled();
clickSpy.mockRestore();
});
it('FE-PLANNER-TRANSMODAL-024: unlinking a linked file removes it from attached list', async () => {
server.use(
http.post('/api/trips/1/files/42/link', () => HttpResponse.json({ success: true })),
http.get('/api/trips/1/files/42/links', () => HttpResponse.json({ links: [{ id: 1, reservation_id: 7 }] })),
http.delete('/api/trips/1/files/42/link/1', () => HttpResponse.json({ success: true })),
http.get('/api/trips/1/files', () => HttpResponse.json({ files: [] })),
);
const res = buildReservation({ id: 7, type: 'car' });
const looseFile = buildTripFile({ id: 42, original_name: 'rental-agreement.pdf' });
render(<TransportModal {...defaultProps} reservation={res} files={[looseFile]} />);
await userEvent.click(screen.getByRole('button', { name: /Link existing file/i }));
await waitFor(() => expect(screen.getByText('rental-agreement.pdf')).toBeInTheDocument());
await userEvent.click(screen.getByText('rental-agreement.pdf'));
await waitFor(() =>
expect(screen.queryByRole('button', { name: /Link existing file/i })).not.toBeInTheDocument()
);
const fileRow = screen.getByText('rental-agreement.pdf').closest('div')!;
const unlinkBtn = fileRow.querySelector('button[type="button"]')!;
await userEvent.click(unlinkBtn);
await waitFor(() => {
expect(screen.getByRole('button', { name: /Link existing file/i })).toBeInTheDocument();
});
});
it('FE-PLANNER-TRANSMODAL-025: pending files flushed after saving new transport', async () => {
const savedReservation = buildReservation({ id: 99, type: 'flight' });
const onSave = vi.fn().mockResolvedValue(savedReservation);
const onFileUpload = vi.fn().mockResolvedValue(undefined);
render(<TransportModal {...defaultProps} onSave={onSave} onFileUpload={onFileUpload} reservation={null} />);
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
const testFile = new File(['content'], 'boarding.pdf', { type: 'application/pdf' });
fireEvent.change(fileInput, { target: { files: [testFile] } });
await waitFor(() => expect(screen.getByText('boarding.pdf')).toBeInTheDocument());
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'LH001');
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
await waitFor(() => expect(onFileUpload).toHaveBeenCalled());
const [fd] = onFileUpload.mock.calls[0] as [FormData];
expect(fd.get('reservation_id')).toBe('99');
expect(fd.get('file')).toBeTruthy();
});
});
@@ -1,5 +1,6 @@
import { useState, useEffect, useMemo } from 'react'
import { Plane, Train, Car, Ship } from 'lucide-react'
import { useState, useEffect, useMemo, useRef } from 'react'
import { useParams } from 'react-router-dom'
import { Plane, Train, Car, Ship, Paperclip, FileText, X, ExternalLink, Link2 } from 'lucide-react'
import Modal from '../shared/Modal'
import CustomSelect from '../shared/CustomSelect'
import CustomTimePicker from '../shared/CustomTimePicker'
@@ -10,7 +11,9 @@ import { useToast } from '../shared/Toast'
import { useTripStore } from '../../store/tripStore'
import { useAddonStore } from '../../store/addonStore'
import { formatDate } from '../../utils/formatters'
import type { Day, Reservation, ReservationEndpoint } from '../../types'
import { openFile } from '../../utils/fileDownload'
import apiClient from '../../api/client'
import type { Day, Reservation, ReservationEndpoint, TripFile } from '../../types'
const TRANSPORT_TYPES = ['flight', 'train', 'car', 'cruise'] as const
type TransportType = typeof TRANSPORT_TYPES[number]
@@ -89,26 +92,36 @@ const defaultForm = {
interface TransportModalProps {
isOpen: boolean
onClose: () => void
onSave: (data: Record<string, any>) => Promise<void>
onSave: (data: Record<string, any>) => Promise<Reservation | undefined>
reservation: Reservation | null
days: Day[]
selectedDayId: number | null
files?: TripFile[]
onFileUpload?: (fd: FormData) => Promise<void>
onFileDelete?: (fileId: number) => Promise<void>
}
export function TransportModal({ isOpen, onClose, onSave, reservation, days, selectedDayId }: TransportModalProps) {
export function TransportModal({ isOpen, onClose, onSave, reservation, days, selectedDayId, files = [], onFileUpload, onFileDelete }: TransportModalProps) {
const { t, locale } = useTranslation()
const toast = useToast()
const isBudgetEnabled = useAddonStore(s => s.isEnabled('budget'))
const budgetItems = useTripStore(s => s.budgetItems)
const loadFiles = useTripStore(s => s.loadFiles)
const budgetCategories = useMemo(() => {
const cats = new Set<string>()
budgetItems.forEach(i => { if (i.category) cats.add(i.category) })
return Array.from(cats).sort()
}, [budgetItems])
const { id: tripId } = useParams<{ id: string }>()
const [form, setForm] = useState({ ...defaultForm })
const [isSaving, setIsSaving] = useState(false)
const [fromPick, setFromPick] = useState<EndpointPick>({})
const [toPick, setToPick] = useState<EndpointPick>({})
const [uploadingFile, setUploadingFile] = useState(false)
const [pendingFiles, setPendingFiles] = useState<File[]>([])
const [showFilePicker, setShowFilePicker] = useState(false)
const [linkedFileIds, setLinkedFileIds] = useState<number[]>([])
const fileInputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (!isOpen) return
@@ -222,7 +235,16 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
? { total_price: parseFloat(form.price), category: form.budget_category || t(`reservations.type.${form.type}`) || 'Other' }
: { total_price: 0 }
}
await onSave(payload)
const saved = await onSave(payload)
if (!reservation?.id && saved?.id && pendingFiles.length > 0 && onFileUpload) {
for (const file of pendingFiles) {
const fd = new FormData()
fd.append('file', file)
fd.append('reservation_id', String(saved.id))
fd.append('description', form.title)
await onFileUpload(fd)
}
}
} catch (err: unknown) {
toast.error(err instanceof Error ? err.message : t('common.unknownError'))
} finally {
@@ -230,6 +252,38 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
}
}
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
if (reservation?.id) {
setUploadingFile(true)
try {
const fd = new FormData()
fd.append('file', file)
fd.append('reservation_id', String(reservation.id))
fd.append('description', reservation.title)
await onFileUpload!(fd)
toast.success(t('reservations.toast.fileUploaded'))
} catch {
toast.error(t('reservations.toast.uploadError'))
} finally {
setUploadingFile(false)
e.target.value = ''
}
} else {
setPendingFiles(prev => [...prev, file])
e.target.value = ''
}
}
const attachedFiles = reservation?.id
? files.filter(f =>
f.reservation_id === reservation.id ||
linkedFileIds.includes(f.id) ||
(f.linked_reservation_ids && f.linked_reservation_ids.includes(reservation.id))
)
: []
const inputStyle = {
width: '100%', border: '1px solid var(--border-primary)', borderRadius: 10,
padding: '8px 12px', fontSize: 13, fontFamily: 'inherit',
@@ -444,6 +498,94 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
style={{ ...inputStyle, resize: 'none', lineHeight: 1.5 }} />
</div>
{/* Files */}
<div>
<label style={labelStyle}>{t('files.title')}</label>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{attachedFiles.map(f => (
<div key={f.id} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '5px 10px', background: 'var(--bg-secondary)', borderRadius: 8 }}>
<FileText size={12} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />
<span style={{ flex: 1, fontSize: 12, color: 'var(--text-secondary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
<a href="#" onClick={(e) => { e.preventDefault(); openFile(f.url).catch(() => {}) }} style={{ color: 'var(--text-faint)', display: 'flex', flexShrink: 0, cursor: 'pointer' }}><ExternalLink size={11} /></a>
<button type="button" onClick={async () => {
if (f.reservation_id === reservation?.id) {
try { await apiClient.put(`/trips/${tripId}/files/${f.id}`, { reservation_id: null }) } catch {}
}
try {
const linksRes = await apiClient.get(`/trips/${tripId}/files/${f.id}/links`)
const link = (linksRes.data.links || []).find((l: any) => l.reservation_id === reservation?.id)
if (link) await apiClient.delete(`/trips/${tripId}/files/${f.id}/link/${link.id}`)
} catch {}
setLinkedFileIds(prev => prev.filter(id => id !== f.id))
if (tripId) loadFiles(tripId)
}} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 0, flexShrink: 0 }}>
<X size={11} />
</button>
</div>
))}
{pendingFiles.map((f, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '5px 10px', background: 'var(--bg-secondary)', borderRadius: 8 }}>
<FileText size={12} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />
<span style={{ flex: 1, fontSize: 12, color: 'var(--text-secondary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.name}</span>
<button type="button" onClick={() => setPendingFiles(prev => prev.filter((_, j) => j !== i))}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 0, flexShrink: 0 }}>
<X size={11} />
</button>
</div>
))}
<input ref={fileInputRef} type="file" accept=".pdf,.doc,.docx,.txt,image/*" style={{ display: 'none' }} onChange={handleFileChange} />
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{onFileUpload && <button type="button" onClick={() => fileInputRef.current?.click()} disabled={uploadingFile} style={{
display: 'flex', alignItems: 'center', gap: 5, padding: '6px 10px',
border: '1px dashed var(--border-primary)', borderRadius: 8, background: 'none',
fontSize: 11, color: 'var(--text-faint)', cursor: uploadingFile ? 'default' : 'pointer', fontFamily: 'inherit',
}}>
<Paperclip size={11} />
{uploadingFile ? t('reservations.uploading') : t('reservations.attachFile')}
</button>}
{reservation?.id && files.filter(f => !f.deleted_at && !attachedFiles.some(af => af.id === f.id)).length > 0 && (
<div style={{ position: 'relative' }}>
<button type="button" onClick={() => setShowFilePicker(v => !v)} style={{
display: 'flex', alignItems: 'center', gap: 5, padding: '6px 10px',
border: '1px dashed var(--border-primary)', borderRadius: 8, background: 'none',
fontSize: 11, color: 'var(--text-faint)', cursor: 'pointer', fontFamily: 'inherit',
}}>
<Link2 size={11} /> {t('reservations.linkExisting')}
</button>
{showFilePicker && (
<div style={{
position: 'absolute', bottom: '100%', left: 0, marginBottom: 4, zIndex: 50,
background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 10,
boxShadow: '0 4px 16px rgba(0,0,0,0.12)', padding: 4, minWidth: 220, maxHeight: 200, overflowY: 'auto',
}}>
{files.filter(f => !f.deleted_at && !attachedFiles.some(af => af.id === f.id)).map(f => (
<button key={f.id} type="button" onClick={async () => {
try {
await apiClient.post(`/trips/${tripId}/files/${f.id}/link`, { reservation_id: reservation.id })
setLinkedFileIds(prev => [...prev, f.id])
setShowFilePicker(false)
if (tripId) loadFiles(tripId)
} catch {}
}}
style={{
display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '6px 10px',
background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, fontFamily: 'inherit',
color: 'var(--text-secondary)', borderRadius: 7, textAlign: 'left',
}}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-tertiary)'}
onMouseLeave={e => e.currentTarget.style.background = 'none'}>
<FileText size={12} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
</button>
))}
</div>
)}
</div>
)}
</div>
</div>
</div>
{/* Price + Budget Category */}
{isBudgetEnabled && (
<>
+2
View File
@@ -1249,6 +1249,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'files.toast.deleteError': 'فشل حذف الملف',
'files.sourcePlan': 'خطة اليوم',
'files.sourceBooking': 'الحجز',
'files.sourceTransport': 'النقل',
'files.attach': 'إرفاق',
'files.pasteHint': 'يمكنك أيضًا لصق الصور من الحافظة (Ctrl+V)',
'files.trash': 'سلة المهملات',
@@ -1261,6 +1262,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'files.assignTitle': 'إسناد ملف',
'files.assignPlace': 'المكان',
'files.assignBooking': 'الحجز',
'files.assignTransport': 'النقل',
'files.unassigned': 'غير مسند',
'files.unlink': 'إزالة الرابط',
'files.toast.trashed': 'تم النقل إلى سلة المهملات',
+2
View File
@@ -1218,6 +1218,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'files.toast.deleteError': 'Falha ao excluir arquivo',
'files.sourcePlan': 'Plano do dia',
'files.sourceBooking': 'Reserva',
'files.sourceTransport': 'Transporte',
'files.attach': 'Anexar',
'files.pasteHint': 'Você também pode colar imagens da área de transferência (Ctrl+V)',
'files.trash': 'Lixeira',
@@ -1230,6 +1231,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'files.assignTitle': 'Atribuir arquivo',
'files.assignPlace': 'Lugar',
'files.assignBooking': 'Reserva',
'files.assignTransport': 'Transporte',
'files.unassigned': 'Não atribuído',
'files.unlink': 'Remover vínculo',
'files.toast.trashed': 'Movido para a lixeira',
+2
View File
@@ -1247,6 +1247,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'files.toast.deleteError': 'Nepodařilo se smazat soubor',
'files.sourcePlan': 'Denní plán',
'files.sourceBooking': 'Rezervace',
'files.sourceTransport': 'Doprava',
'files.attach': 'Přiložit',
'files.pasteHint': 'Můžete také vložit obrázek ze schránky (Ctrl+V)',
'files.trash': 'Koš',
@@ -1259,6 +1260,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'files.assignTitle': 'Přiřadit soubor',
'files.assignPlace': 'Místo',
'files.assignBooking': 'Rezervace',
'files.assignTransport': 'Doprava',
'files.unassigned': 'Nepřiřazeno',
'files.unlink': 'Zrušit propojení',
'files.toast.trashed': 'Přesunuto do koše',
+2
View File
@@ -1251,6 +1251,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'files.toast.deleteError': 'Fehler beim Löschen der Datei',
'files.sourcePlan': 'Tagesplan',
'files.sourceBooking': 'Buchung',
'files.sourceTransport': 'Transport',
'files.attach': 'Anhängen',
'files.pasteHint': 'Du kannst auch Bilder aus der Zwischenablage einfügen (Strg+V)',
'files.trash': 'Papierkorb',
@@ -1263,6 +1264,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'files.assignTitle': 'Datei zuweisen',
'files.assignPlace': 'Ort',
'files.assignBooking': 'Buchung',
'files.assignTransport': 'Transport',
'files.unassigned': 'Nicht zugewiesen',
'files.unlink': 'Verknüpfung entfernen',
'files.toast.trashed': 'In den Papierkorb verschoben',
+2
View File
@@ -1322,6 +1322,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'files.toast.deleteError': 'Failed to delete file',
'files.sourcePlan': 'Day Plan',
'files.sourceBooking': 'Booking',
'files.sourceTransport': 'Transport',
'files.attach': 'Attach',
'files.pasteHint': 'You can also paste images from clipboard (Ctrl+V)',
'files.trash': 'Trash',
@@ -1334,6 +1335,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'files.assignTitle': 'Assign File',
'files.assignPlace': 'Place',
'files.assignBooking': 'Booking',
'files.assignTransport': 'Transport',
'files.unassigned': 'Unassigned',
'files.unlink': 'Remove link',
'files.toast.trashed': 'Moved to trash',
+2
View File
@@ -1195,6 +1195,7 @@ const es: Record<string, string> = {
'files.toast.deleteError': 'No se pudo eliminar el archivo',
'files.sourcePlan': 'Plan diario',
'files.sourceBooking': 'Reserva',
'files.sourceTransport': 'Transporte',
'files.attach': 'Adjuntar',
'files.pasteHint': 'También puedes pegar imágenes desde el portapapeles (Ctrl+V)',
@@ -1682,6 +1683,7 @@ const es: Record<string, string> = {
'files.assignTitle': 'Asignar archivo',
'files.assignPlace': 'Lugar',
'files.assignBooking': 'Reserva',
'files.assignTransport': 'Transporte',
'files.unassigned': 'Sin asignar',
'files.unlink': 'Eliminar vínculo',
'files.noteLabel': 'Nota',
+2
View File
@@ -1245,6 +1245,7 @@ const fr: Record<string, string> = {
'files.toast.deleteError': 'Impossible de supprimer le fichier',
'files.sourcePlan': 'Plan du jour',
'files.sourceBooking': 'Réservation',
'files.sourceTransport': 'Transport',
'files.attach': 'Joindre',
'files.pasteHint': 'Vous pouvez aussi coller des images depuis le presse-papiers (Ctrl+V)',
'files.trash': 'Corbeille',
@@ -1257,6 +1258,7 @@ const fr: Record<string, string> = {
'files.assignTitle': 'Assigner le fichier',
'files.assignPlace': 'Lieu',
'files.assignBooking': 'Réservation',
'files.assignTransport': 'Transport',
'files.unassigned': 'Non attribué',
'files.unlink': 'Supprimer le lien',
'files.toast.trashed': 'Déplacé dans la corbeille',
+2
View File
@@ -1246,6 +1246,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'files.toast.deleteError': 'Nem sikerült törölni a fájlt',
'files.sourcePlan': 'Napi terv',
'files.sourceBooking': 'Foglalás',
'files.sourceTransport': 'Közlekedés',
'files.attach': 'Csatolás',
'files.pasteHint': 'Képeket a vágólapról is beillesztheted (Ctrl+V)',
'files.trash': 'Kuka',
@@ -1258,6 +1259,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'files.assignTitle': 'Fájl hozzárendelése',
'files.assignPlace': 'Hely',
'files.assignBooking': 'Foglalás',
'files.assignTransport': 'Közlekedés',
'files.unassigned': 'Nincs hozzárendelve',
'files.unlink': 'Kapcsolat eltávolítása',
'files.toast.trashed': 'Kukába helyezve',
+2
View File
@@ -1306,6 +1306,7 @@ const id: Record<string, string | { name: string; category: string }[]> = {
'files.toast.deleteError': 'Gagal menghapus file',
'files.sourcePlan': 'Rencana Harian',
'files.sourceBooking': 'Pemesanan',
'files.sourceTransport': 'Transportasi',
'files.attach': 'Lampirkan',
'files.pasteHint': 'Kamu juga bisa menempel gambar dari clipboard (Ctrl+V)',
'files.trash': 'Sampah',
@@ -1318,6 +1319,7 @@ const id: Record<string, string | { name: string; category: string }[]> = {
'files.assignTitle': 'Tugaskan File',
'files.assignPlace': 'Tempat',
'files.assignBooking': 'Pemesanan',
'files.assignTransport': 'Transportasi',
'files.unassigned': 'Tidak ditugaskan',
'files.unlink': 'Hapus tautan',
'files.toast.trashed': 'Dipindahkan ke sampah',
+2
View File
@@ -1246,6 +1246,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'files.toast.deleteError': 'Impossibile eliminare il file',
'files.sourcePlan': 'Programma giornaliero',
'files.sourceBooking': 'Prenotazione',
'files.sourceTransport': 'Trasporto',
'files.attach': 'Allega',
'files.pasteHint': 'Puoi anche incollare immagini dagli appunti (Ctrl+V)',
'files.trash': 'Cestino',
@@ -1258,6 +1259,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'files.assignTitle': 'Assegna file',
'files.assignPlace': 'Luogo',
'files.assignBooking': 'Prenotazione',
'files.assignTransport': 'Trasporto',
'files.unassigned': 'Non assegnato',
'files.unlink': 'Rimuovi collegamento',
'files.toast.trashed': 'Spostato nel cestino',
+2
View File
@@ -1245,6 +1245,7 @@ const nl: Record<string, string> = {
'files.toast.deleteError': 'Bestand verwijderen mislukt',
'files.sourcePlan': 'Dagplan',
'files.sourceBooking': 'Boeking',
'files.sourceTransport': 'Transport',
'files.attach': 'Bijvoegen',
'files.pasteHint': 'Je kunt ook afbeeldingen plakken vanuit het klembord (Ctrl+V)',
'files.trash': 'Prullenbak',
@@ -1257,6 +1258,7 @@ const nl: Record<string, string> = {
'files.assignTitle': 'Bestand toewijzen',
'files.assignPlace': 'Plaats',
'files.assignBooking': 'Boeking',
'files.assignTransport': 'Transport',
'files.unassigned': 'Niet toegewezen',
'files.unlink': 'Koppeling verwijderen',
'files.toast.trashed': 'Naar prullenbak verplaatst',
+2
View File
@@ -1197,6 +1197,7 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'files.toast.deleteError': 'Nie udało się usunąć pliku',
'files.sourcePlan': 'Plan dni',
'files.sourceBooking': 'Rezerwacje',
'files.sourceTransport': 'Transport',
'files.attach': 'Załącz',
'files.pasteHint': 'Możesz również wkleić obrazki ze schowka (Ctrl+V)',
'files.trash': 'Kosz',
@@ -1209,6 +1210,7 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'files.assignTitle': 'Przypisz plik',
'files.assignPlace': 'Miejsce',
'files.assignBooking': 'Rezerwacja',
'files.assignTransport': 'Transport',
'files.unassigned': 'Nieprzypisane',
'files.unlink': 'Usuń link',
'files.toast.trashed': 'Przeniesiono do kosza',
+2
View File
@@ -1245,6 +1245,7 @@ const ru: Record<string, string> = {
'files.toast.deleteError': 'Не удалось удалить файл',
'files.sourcePlan': 'План дня',
'files.sourceBooking': 'Бронирование',
'files.sourceTransport': 'Транспорт',
'files.attach': 'Прикрепить',
'files.pasteHint': 'Также можно вставить изображения из буфера обмена (Ctrl+V)',
'files.trash': 'Корзина',
@@ -1257,6 +1258,7 @@ const ru: Record<string, string> = {
'files.assignTitle': 'Назначить файл',
'files.assignPlace': 'Место',
'files.assignBooking': 'Бронирование',
'files.assignTransport': 'Транспорт',
'files.unassigned': 'Не назначен',
'files.unlink': 'Удалить связь',
'files.toast.trashed': 'Перемещено в корзину',
+2
View File
@@ -1245,6 +1245,7 @@ const zh: Record<string, string> = {
'files.toast.deleteError': '删除文件失败',
'files.sourcePlan': '日程计划',
'files.sourceBooking': '预订',
'files.sourceTransport': '交通',
'files.attach': '附加',
'files.pasteHint': '也可以从剪贴板粘贴图片 (Ctrl+V)',
'files.trash': '回收站',
@@ -1257,6 +1258,7 @@ const zh: Record<string, string> = {
'files.assignTitle': '分配文件',
'files.assignPlace': '地点',
'files.assignBooking': '预订',
'files.assignTransport': '交通',
'files.unassigned': '未分配',
'files.unlink': '移除关联',
'files.toast.trashed': '已移至回收站',
+2
View File
@@ -1305,6 +1305,7 @@ const zhTw: Record<string, string> = {
'files.toast.deleteError': '刪除檔案失敗',
'files.sourcePlan': '日程計劃',
'files.sourceBooking': '預訂',
'files.sourceTransport': '交通',
'files.attach': '附加',
'files.pasteHint': '也可以從剪貼簿貼上圖片 (Ctrl+V)',
'files.trash': '回收站',
@@ -1317,6 +1318,7 @@ const zhTw: Record<string, string> = {
'files.assignTitle': '分配檔案',
'files.assignPlace': '地點',
'files.assignBooking': '預訂',
'files.assignTransport': '交通',
'files.unassigned': '未分配',
'files.unlink': '移除關聯',
'files.toast.trashed': '已移至回收站',
+1 -1
View File
@@ -807,7 +807,7 @@ img[alt="TREK"] {
.collab-note-md code, .collab-note-md-full code { font-size: 0.9em; padding: 1px 5px; border-radius: 4px; background: var(--bg-secondary); }
.collab-note-md-full pre { padding: 10px 12px; border-radius: 8px; background: var(--bg-secondary); overflow-x: auto; margin: 0.5em 0; }
.collab-note-md-full pre code { padding: 0; background: none; }
.collab-note-md a, .collab-note-md-full a { color: #3b82f6; text-decoration: underline; }
.collab-note-md a, .collab-note-md-full a { color: #3b82f6; text-decoration: underline; word-break: break-all; }
.collab-note-md blockquote, .collab-note-md-full blockquote { border-left: 3px solid var(--border-primary); padding-left: 12px; margin: 0.5em 0; color: var(--text-muted); }
.collab-note-md-full table { border-collapse: collapse; width: 100%; margin: 0.5em 0; }
.collab-note-md-full th, .collab-note-md-full td { border: 1px solid var(--border-primary); padding: 4px 8px; font-size: 0.9em; }
@@ -0,0 +1,105 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { render, screen, waitFor } from '../../tests/helpers/render';
import { http, HttpResponse } from 'msw';
import { server } from '../../tests/helpers/msw/server';
import { resetAllStores } from '../../tests/helpers/store';
import LoginPage from './LoginPage';
const mockNavigate = vi.fn();
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual('react-router-dom');
return { ...actual, useNavigate: () => mockNavigate };
});
describe('LoginPage — OIDC redirect preservation', () => {
let savedLocation: Location;
beforeEach(() => {
resetAllStores();
mockNavigate.mockClear();
sessionStorage.clear();
savedLocation = window.location;
});
afterEach(() => {
Object.defineProperty(window, 'location', {
configurable: true,
writable: true,
value: savedLocation,
});
});
function setSearch(search: string) {
Object.defineProperty(window, 'location', {
configurable: true,
writable: true,
value: { ...window.location, search },
});
}
describe('FE-PAGE-LOGIN-022: redirect param stashed in sessionStorage on mount', () => {
it('saves decoded redirect to sessionStorage when ?redirect= is present', async () => {
setSearch('?redirect=%2Foauth%2Fauthorize%3Fclient_id%3Dfoo');
render(<LoginPage />);
await waitFor(() => {
expect(sessionStorage.getItem('oidc_redirect')).toBe('/oauth/authorize?client_id=foo');
});
});
it('does not write to sessionStorage when no redirect param is present', async () => {
render(<LoginPage />);
await waitFor(() => {
expect(screen.getByPlaceholderText('your@email.com')).toBeInTheDocument();
});
expect(sessionStorage.getItem('oidc_redirect')).toBeNull();
});
});
describe('FE-PAGE-LOGIN-023: OIDC code exchange navigates to sessionStorage redirect', () => {
beforeEach(() => {
server.use(
http.get('/api/auth/oidc/exchange', () =>
HttpResponse.json({ token: 'mock-oidc-token' })
),
);
});
it('navigates to the saved sessionStorage redirect after successful OIDC exchange', async () => {
sessionStorage.setItem('oidc_redirect', '/oauth/authorize?client_id=foo&state=xyz');
setSearch('?oidc_code=testcode123');
render(<LoginPage />);
await waitFor(() => {
expect(mockNavigate).toHaveBeenCalledWith(
'/oauth/authorize?client_id=foo&state=xyz',
{ replace: true },
);
});
expect(sessionStorage.getItem('oidc_redirect')).toBeNull();
});
it('falls back to /dashboard when no sessionStorage redirect is set', async () => {
setSearch('?oidc_code=testcode123');
render(<LoginPage />);
await waitFor(() => {
expect(mockNavigate).toHaveBeenCalledWith('/dashboard', { replace: true });
});
});
});
describe('FE-PAGE-LOGIN-024: OIDC error clears sessionStorage redirect', () => {
it('removes oidc_redirect from sessionStorage on OIDC error', async () => {
sessionStorage.setItem('oidc_redirect', '/oauth/authorize?client_id=foo');
setSearch('?oidc_error=token_failed');
render(<LoginPage />);
await waitFor(() => {
expect(sessionStorage.getItem('oidc_redirect')).toBeNull();
});
});
});
});
+10 -1
View File
@@ -55,6 +55,12 @@ export default function LoginPage(): React.ReactElement {
return '/dashboard'
}, [])
useEffect(() => {
if (redirectTarget !== '/dashboard') {
sessionStorage.setItem('oidc_redirect', redirectTarget)
}
}, [redirectTarget])
useEffect(() => {
const params = new URLSearchParams(window.location.search)
@@ -83,7 +89,9 @@ export default function LoginPage(): React.ReactElement {
window.history.replaceState({}, '', '/login')
if (data.token) {
await loadUser()
navigate('/dashboard', { replace: true })
const savedRedirect = sessionStorage.getItem('oidc_redirect') || '/dashboard'
sessionStorage.removeItem('oidc_redirect')
navigate(savedRedirect, { replace: true })
} else {
setError(data.error || t('login.oidcFailed'))
}
@@ -104,6 +112,7 @@ export default function LoginPage(): React.ReactElement {
invalid_state: t('login.oidc.invalidState'),
}
setError(errorMessages[oidcError] || oidcError)
sessionStorage.removeItem('oidc_redirect')
window.history.replaceState({}, '', '/login')
return
}
+1 -1
View File
@@ -124,7 +124,7 @@ export default function OAuthAuthorizePage(): React.ReactElement {
}
function handleLoginRedirect() {
const next = '/oauth/authorize?' + params.toString()
const next = '/oauth/authorize?' + params.toString() + window.location.hash
window.location.href = '/login?redirect=' + encodeURIComponent(next)
}
+2 -1
View File
@@ -10,6 +10,7 @@ import { getCategoryIcon } from '../components/shared/categoryIcons'
import { createElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { Clock, MapPin, FileText, Train, Plane, Bus, Car, Ship, Ticket, Hotel, Map, Luggage, Wallet, MessageCircle } from 'lucide-react'
import { isDayInAccommodationRange } from '../utils/dayOrder'
const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'cruise'])
const TRANSPORT_ICONS = { flight: Plane, train: Train, bus: Bus, car: Car, cruise: Ship }
@@ -184,7 +185,7 @@ export default function SharedTripPage() {
const da = assignments[String(day.id)] || []
const notes = (dayNotes[String(day.id)] || [])
const dayTransport = (reservations || []).filter((r: any) => TRANSPORT_TYPES.has(r.type) && r.reservation_time?.split('T')[0] === day.date)
const dayAccs = (accommodations || []).filter((a: any) => day.id >= a.start_day_id && day.id <= a.end_day_id)
const dayAccs = (accommodations || []).filter((a: any) => isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, sortedDays))
const merged = [
...da.map((a: any) => ({ type: 'place', k: a.order_index, data: a })),
+50
View File
@@ -1474,6 +1474,56 @@ describe('TripPlannerPage', () => {
});
});
describe('FE-PAGE-PLANNER-051: Mobile Plan sidebar stays mounted after onPlaceClick (issue #932)', () => {
it('does not unmount the mobile Plan portal when a place is tapped, preserving scroll position', async () => {
vi.useFakeTimers();
Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 375 });
const place = buildPlace({ id: 1, trip_id: 42, lat: 48.8566, lng: 2.3522 });
const assignment = buildAssignment({ id: 10, day_id: 99, place, order_index: 0 });
seedTripStore({ id: 42 });
seedStore(useTripStore, {
places: [place],
assignments: { '99': [assignment] },
} as any);
renderPlannerPage(42);
act(() => { vi.runAllTimers(); });
vi.useRealTimers();
await waitFor(() => {
expect(screen.getByTestId('day-plan-sidebar')).toBeInTheDocument();
});
// Open the mobile Plan portal via the bottom-nav Plan button (selector mirrors FE-PAGE-PLANNER-049).
const mobilePlanBtn = Array.from(document.body.querySelectorAll('button')).find(
b => b.textContent === 'Plan' && !b.getAttribute('title'),
);
expect(mobilePlanBtn).toBeTruthy();
await act(async () => { fireEvent.click(mobilePlanBtn!); });
await waitFor(() => {
expect(screen.getAllByTestId('day-plan-sidebar').length).toBe(2);
});
// The mock factory overwrites capturedDayPlanSidebarProps on each mount,
// so current holds the mobile portal instance's props.
const mobileOnPlaceClick = capturedDayPlanSidebarProps.current.onPlaceClick;
expect(typeof mobileOnPlaceClick).toBe('function');
await act(async () => {
mobileOnPlaceClick(place.id, assignment.id);
});
// Invariant: portal must NOT unmount — both instances persist.
// Pre-fix: collapses to 1 (setMobileSidebarOpen(null) destroyed scroll container).
// Post-fix: stays at 2, browser preserves scrollTop on the living DOM node.
expect(screen.getAllByTestId('day-plan-sidebar').length).toBe(2);
Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 1024 });
});
});
describe('FE-PAGE-PLANNER-037: onExpandedDaysChange covers mapPlaces hidden logic', () => {
it('calls onExpandedDaysChange to trigger mapPlaces hidden set computation', async () => {
vi.useFakeTimers();
+15 -8
View File
@@ -272,6 +272,8 @@ export default function TripPlannerPage(): React.ReactElement | null {
const [fitKey, setFitKey] = useState<number>(0)
const initialFitTripId = useRef<number | null>(null)
const [mobileSidebarOpen, setMobileSidebarOpen] = useState<'left' | 'right' | null>(null)
const mobilePlanScrollTopRef = useRef<number>(0)
const mobilePlacesScrollTopRef = useRef<number>(0)
const [deletePlaceId, setDeletePlaceId] = useState<number | null>(null)
const [deletePlaceIds, setDeletePlaceIds] = useState<number[] | null>(null)
@@ -666,15 +668,20 @@ export default function TripPlannerPage(): React.ReactElement | null {
const handleSaveTransport = async (data) => {
try {
if (editingTransport) {
await tripActions.updateReservation(tripId, editingTransport.id, data)
const r = await tripActions.updateReservation(tripId, editingTransport.id, data)
toast.success(t('trip.toast.reservationUpdated'))
setShowTransportModal(false)
setEditingTransport(null)
setTransportModalDayId(null)
return r
} else {
await tripActions.addReservation(tripId, data)
const r = await tripActions.addReservation(tripId, data)
toast.success(t('trip.toast.reservationAdded'))
setShowTransportModal(false)
setEditingTransport(null)
setTransportModalDayId(null)
return r
}
setShowTransportModal(false)
setEditingTransport(null)
setTransportModalDayId(null)
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
}
@@ -1109,8 +1116,8 @@ export default function TripPlannerPage(): React.ReactElement | null {
</div>
<div style={{ flex: 1, overflow: 'auto' }}>
{mobileSidebarOpen === 'left'
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId); setMobileSidebarOpen(null) }} onReorder={handleReorder} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText }) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null); setMobileSidebarOpen(null) }} accommodations={tripAccommodations} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} />
: <PlacesSidebar tripId={tripId} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onPlaceClick={(placeId) => { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { setEditingPlace(place); setEditingAssignmentId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)} onBulkDeleteConfirm={(ids) => confirmDeletePlaces(ids)} days={days} isMobile onCategoryFilterChange={setMapCategoryFilter} onPlacesFilterChange={setMapPlacesFilter} pushUndo={pushUndo} />
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId) }} onReorder={handleReorder} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText }) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null); setMobileSidebarOpen(null) }} accommodations={tripAccommodations} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} initialScrollTop={mobilePlanScrollTopRef.current} onScrollTopChange={(top) => { mobilePlanScrollTopRef.current = top }} />
: <PlacesSidebar tripId={tripId} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onPlaceClick={(placeId) => { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { setEditingPlace(place); setEditingAssignmentId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)} onBulkDeleteConfirm={(ids) => confirmDeletePlaces(ids)} days={days} isMobile onCategoryFilterChange={setMapCategoryFilter} onPlacesFilterChange={setMapPlacesFilter} pushUndo={pushUndo} initialScrollTop={mobilePlacesScrollTopRef.current} onScrollTopChange={(top) => { mobilePlacesScrollTopRef.current = top }} />
}
</div>
</div>
@@ -1194,7 +1201,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
<TripFormModal isOpen={showTripForm} onClose={() => setShowTripForm(false)} onSave={async (data) => { await tripActions.updateTrip(tripId, data); toast.success(t('trip.toast.tripUpdated')) }} trip={trip} />
<TripMembersModal isOpen={showMembersModal} onClose={() => setShowMembersModal(false)} tripId={tripId} tripTitle={trip?.title} />
<ReservationModal isOpen={showReservationModal} onClose={() => { setShowReservationModal(false); setEditingReservation(null); setBookingForAssignmentId(null) }} onSave={handleSaveReservation} reservation={editingReservation} days={days} places={places} assignments={assignments} selectedDayId={selectedDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} accommodations={tripAccommodations} defaultAssignmentId={bookingForAssignmentId} />
{showTransportModal && <TransportModal isOpen={showTransportModal} onClose={() => { setShowTransportModal(false); setEditingTransport(null); setTransportModalDayId(null) }} onSave={handleSaveTransport} reservation={editingTransport} days={days} selectedDayId={transportModalDayId} />}
{showTransportModal && <TransportModal isOpen={showTransportModal} onClose={() => { setShowTransportModal(false); setEditingTransport(null); setTransportModalDayId(null) }} onSave={handleSaveTransport} reservation={editingTransport} days={days} selectedDayId={transportModalDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} />}
<ConfirmDialog
isOpen={!!deletePlaceId}
onClose={() => setDeletePlaceId(null)}
+1
View File
@@ -31,6 +31,7 @@ export interface Trip {
export interface Day {
id: number
trip_id: number
day_number?: number
date: string
title: string | null
notes: string | null
+23
View File
@@ -0,0 +1,23 @@
import type { Day } from '../types'
export const getDayOrder = (day: Day, days: Day[]): number =>
day.day_number ?? days.indexOf(day)
export const isDayInAccommodationRange = (
day: Day,
startDayId: number,
endDayId: number,
days: Day[],
): boolean => {
const startDay = days.find(d => d.id === startDayId)
const endDay = days.find(d => d.id === endDayId)
if (!startDay || !endDay) {
// Endpoint days not in the loaded array (e.g. sparse test data or partial load).
// Fall back to numeric ID range — acceptable since non-monotonic IDs only arise when
// both endpoints are present in a fully-loaded trip's days list.
return day.id >= Math.min(startDayId, endDayId) && day.id <= Math.max(startDayId, endDayId)
}
const lo = Math.min(getDayOrder(startDay, days), getDayOrder(endDay, days))
const hi = Math.max(getDayOrder(startDay, days), getDayOrder(endDay, days))
return getDayOrder(day, days) >= lo && getDayOrder(day, days) <= hi
}
-56
View File
@@ -1,56 +0,0 @@
# compiled output
/dist
/node_modules
/build
# Logs
logs
*.log
npm-debug.log*
pnpm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# OS
.DS_Store
# Tests
/coverage
/.nyc_output
# IDEs and editors
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# temp directory
.temp
.tmp
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
-4
View File
@@ -1,4 +0,0 @@
{
"singleQuote": true,
"trailingComma": "all"
}
-98
View File
@@ -1,98 +0,0 @@
<p align="center">
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="120" alt="Nest Logo" /></a>
</p>
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
[circleci-url]: https://circleci.com/gh/nestjs/nest
<p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
<p align="center">
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a>
<a href="https://circleci.com/gh/nestjs/nest" target="_blank"><img src="https://img.shields.io/circleci/build/github/nestjs/nest/master" alt="CircleCI" /></a>
<a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a>
<a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a>
<a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg" alt="Donate us"/></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a>
<a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow" alt="Follow us on Twitter"></a>
</p>
<!--[![Backers on Open Collective](https://opencollective.com/nest/backers/badge.svg)](https://opencollective.com/nest#backer)
[![Sponsors on Open Collective](https://opencollective.com/nest/sponsors/badge.svg)](https://opencollective.com/nest#sponsor)-->
## Description
[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
## Project setup
```bash
$ npm install
```
## Compile and run the project
```bash
# development
$ npm run start
# watch mode
$ npm run start:dev
# production mode
$ npm run start:prod
```
## Run tests
```bash
# unit tests
$ npm run test
# e2e tests
$ npm run test:e2e
# test coverage
$ npm run test:cov
```
## Deployment
When you're ready to deploy your NestJS application to production, there are some key steps you can take to ensure it runs as efficiently as possible. Check out the [deployment documentation](https://docs.nestjs.com/deployment) for more information.
If you are looking for a cloud-based platform to deploy your NestJS application, check out [Mau](https://mau.nestjs.com), our official platform for deploying NestJS applications on AWS. Mau makes deployment straightforward and fast, requiring just a few simple steps:
```bash
$ npm install -g @nestjs/mau
$ mau deploy
```
With Mau, you can deploy your application in just a few clicks, allowing you to focus on building features rather than managing infrastructure.
## Resources
Check out a few resources that may come in handy when working with NestJS:
- Visit the [NestJS Documentation](https://docs.nestjs.com) to learn more about the framework.
- For questions and support, please visit our [Discord channel](https://discord.gg/G7Qnnhy).
- To dive deeper and get more hands-on experience, check out our official video [courses](https://courses.nestjs.com/).
- Deploy your application to AWS with the help of [NestJS Mau](https://mau.nestjs.com) in just a few clicks.
- Visualize your application graph and interact with the NestJS application in real-time using [NestJS Devtools](https://devtools.nestjs.com).
- Need help with your project (part-time to full-time)? Check out our official [enterprise support](https://enterprise.nestjs.com).
- To stay in the loop and get updates, follow us on [X](https://x.com/nestframework) and [LinkedIn](https://linkedin.com/company/nestjs).
- Looking for a job, or have a job to offer? Check out our official [Jobs board](https://jobs.nestjs.com).
## Support
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
## Stay in touch
- Author - [Kamil Myśliwiec](https://twitter.com/kammysliwiec)
- Website - [https://nestjs.com](https://nestjs.com/)
- Twitter - [@nestframework](https://twitter.com/nestframework)
## License
Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE).
-35
View File
@@ -1,35 +0,0 @@
// @ts-check
import eslint from '@eslint/js';
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
import globals from 'globals';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{
ignores: ['eslint.config.mjs'],
},
eslint.configs.recommended,
...tseslint.configs.recommendedTypeChecked,
eslintPluginPrettierRecommended,
{
languageOptions: {
globals: {
...globals.node,
...globals.jest,
},
sourceType: 'commonjs',
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
},
{
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-floating-promises': 'warn',
'@typescript-eslint/no-unsafe-argument': 'warn',
"prettier/prettier": ["error", { endOfLine: "auto" }],
},
},
);
-8
View File
@@ -1,8 +0,0 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}
-11956
View File
File diff suppressed because it is too large Load Diff
-87
View File
@@ -1,87 +0,0 @@
{
"name": "server-nest-2",
"version": "0.0.1",
"description": "",
"author": "",
"private": true,
"license": "UNLICENSED",
"scripts": {
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@better-auth/oauth-provider": "^1.6.9",
"@better-auth/passkey": "^1.6.9",
"@hedystia/better-auth-typeorm": "^0.8.2",
"@nestjs/common": "^11.1.19",
"@nestjs/config": "^4.0.4",
"@nestjs/core": "^11.1.19",
"@nestjs/platform-express": "^11.1.19",
"@nestjs/platform-socket.io": "^11.1.19",
"@nestjs/throttler": "^6.5.0",
"@nestjs/typeorm": "^11.0.1",
"@nestjs/websockets": "^11.1.19",
"@thallesp/nestjs-better-auth": "^2.6.0",
"better-auth": "^1.6.9",
"better-sqlite3": "^12.9.0",
"csrf-csrf": "^4.0.3",
"helmet": "^8.1.0",
"mysql2": "^3.22.2",
"pg": "^8.20.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2",
"typeorm": "^0.3.28"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.18.0",
"@nestjs/cli": "^11.0.0",
"@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.1",
"@types/express": "^5.0.0",
"@types/jest": "^30.0.0",
"@types/node": "^24.0.0",
"@types/supertest": "^7.0.0",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.2.2",
"globals": "^17.0.0",
"jest": "^30.0.0",
"prettier": "^3.4.2",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"ts-jest": "^29.2.5",
"ts-loader": "^9.5.2",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.7.3",
"typescript-eslint": "^8.20.0"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
}
-46
View File
@@ -1,46 +0,0 @@
import authConfig from './config/auth.config.js';
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuthModule } from './auth/auth.module.js';
type SupportedDbType = 'mysql' | 'mariadb' | 'postgres' | 'sqlite';
function resolveDriver(type: SupportedDbType) {
switch (type) {
case 'mysql':
case 'mariadb':
return require('mysql2');
case 'postgres':
return require('pg');
case 'sqlite':
return require('better-sqlite3');
}
}
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true, load: [authConfig] }),
AuthModule,
TypeOrmModule.forRootAsync({
imports: [ConfigModule],
useFactory: (config: ConfigService) => {
const type = config.get<SupportedDbType>('DB_TYPE', 'sqlite');
return {
type,
driver: resolveDriver(type),
host: config.get<string>('DB_HOST', 'localhost'),
port: config.get<number>('DB_PORT', 5432),
username: config.get<string>('DB_USER', 'usr'),
password: config.get<string>('DB_PASS', 'pwd'),
database: config.get<string>('DB_NAME', 'data/travel.db'),
autoLoadEntities: true,
synchronize: config.get<string>('NODE_ENV') !== 'production',
};
},
inject: [ConfigService],
}),
],
})
export class AppModule {}
-20
View File
@@ -1,20 +0,0 @@
import { betterAuth } from 'better-auth';
import { typeormAdapter } from '@hedystia/better-auth-typeorm';
import { DataSource } from 'typeorm';
import { AuthConfig } from '../config/auth.config.js';
export function createAuth(dataSource: DataSource, cfg: AuthConfig) {
return betterAuth({
database: typeormAdapter(dataSource, { debugLogs: cfg.debugLogs }),
secret: cfg.secret,
baseURL: cfg.baseURL,
basePath: '/api/auth',
trustedOrigins: cfg.frontendUrl ? [cfg.frontendUrl] : [],
advanced: {
cookies: { session_token: { name: 'trek_session' } },
useSecureCookies: cfg.cookieSecure,
},
emailAndPassword: { enabled: true },
plugins: [],
});
}
-39
View File
@@ -1,39 +0,0 @@
import { betterAuth } from 'better-auth';
import { typeormAdapter } from '@hedystia/better-auth-typeorm';
import { magicLink } from 'better-auth/plugins/magic-link';
import { genericOAuth } from 'better-auth/plugins/generic-oauth';
import { jwt } from 'better-auth/plugins/jwt';
import { oauthProvider } from '@better-auth/oauth-provider';
import { passkey } from '@better-auth/passkey';
import { DataSource } from 'typeorm';
// Used only by `npx @better-auth/cli generate`.
// Not imported at runtime — auth.factory.ts uses the DI DataSource.
const dataSource = new DataSource({
type: 'better-sqlite3',
database: ':memory:',
});
export const auth = betterAuth({
baseURL: 'http://localhost:3000',
database: typeormAdapter(dataSource, {
entitiesDir: './src/models/entities/auth',
migrationsDir: './src/database/migrations',
}),
emailAndPassword: {
enabled: true,
requireEmailVerification: true,
sendVerificationEmail: async () => {},
sendResetPassword: async () => {},
},
emailVerification: {
sendVerificationEmail: async () => {},
},
plugins: [
jwt(),
magicLink({ sendMagicLink: async () => {} }),
genericOAuth({ config: [] }),
oauthProvider({ loginPage: '/login' }),
passkey(),
],
});
-43
View File
@@ -1,43 +0,0 @@
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuthModule as BetterAuthNestModule } from '@thallesp/nestjs-better-auth';
import { DataSource } from 'typeorm';
import { createAuth } from './auth.factory.js';
import { AuthConfig } from '../config/auth.config.js';
import { User } from '../models/entities/auth/User.js';
import { Account } from '../models/entities/auth/Account.js';
import { Session } from '../models/entities/auth/Session.js';
import { Verification } from '../models/entities/auth/Verification.js';
import { Passkey } from '../models/entities/auth/Passkey.js';
import { Jwks } from '../models/entities/auth/Jwks.js';
import { OauthClient } from '../models/entities/auth/OauthClient.js';
import { OauthAccessToken } from '../models/entities/auth/OauthAccessToken.js';
import { OauthRefreshToken } from '../models/entities/auth/OauthRefreshToken.js';
import { OauthConsent } from '../models/entities/auth/OauthConsent.js';
@Module({
imports: [
TypeOrmModule.forFeature([
User,
Account,
Session,
Verification,
Passkey,
Jwks,
OauthClient,
OauthAccessToken,
OauthRefreshToken,
OauthConsent,
]),
BetterAuthNestModule.forRootAsync({
imports: [ConfigModule],
inject: [DataSource, ConfigService],
useFactory: (ds: DataSource, config: ConfigService) => ({
auth: createAuth(ds, config.get<AuthConfig>('auth')!),
}),
}),
],
exports: [BetterAuthNestModule],
})
export class AuthModule {}
-26
View File
@@ -1,26 +0,0 @@
import { registerAs } from '@nestjs/config';
import { boolean } from 'better-auth';
export interface AuthConfig {
secret: string;
baseURL: string;
frontendUrl: string | undefined;
cookieSecure: boolean;
debugLogs: boolean;
}
export default registerAs(
'auth',
(): AuthConfig => ({
secret: process.env.BETTER_AUTH_SECRET ?? 'changeme',
baseURL: process.env.BETTER_AUTH_URL ?? 'http://localhost:3000',
frontendUrl: process.env.BASE_URL,
cookieSecure:
process.env.COOKIE_SECURE === 'true' &&
process.env.NODE_ENV === 'production' &&
process.env.BASE_URL?.startsWith('https') === true,
debugLogs:
process.env.BETTER_AUTH_DEBUG_LOGS === 'true' ||
process.env.NODE_ENV === 'development',
}),
);
@@ -1,103 +0,0 @@
import {
type MigrationInterface,
type QueryRunner,
Table,
TableColumn,
TableForeignKey,
TableIndex,
} from 'typeorm';
export class CreateAccount1777216318138 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.createTable(
new Table({
name: 'account',
columns: [
{
name: 'id',
type: 'text',
isPrimary: true,
},
{
name: 'accountId',
type: 'text',
},
{
name: 'providerId',
type: 'text',
},
{
name: 'userId',
type: 'text',
},
{
name: 'accessToken',
type: 'text',
isNullable: true,
},
{
name: 'refreshToken',
type: 'text',
isNullable: true,
},
{
name: 'idToken',
type: 'text',
isNullable: true,
},
{
name: 'accessTokenExpiresAt',
type: 'datetime',
isNullable: true,
},
{
name: 'refreshTokenExpiresAt',
type: 'datetime',
isNullable: true,
},
{
name: 'scope',
type: 'text',
isNullable: true,
},
{
name: 'password',
type: 'text',
isNullable: true,
},
{
name: 'createdAt',
type: 'datetime',
default: 'CURRENT_TIMESTAMP',
},
{
name: 'updatedAt',
type: 'datetime',
},
],
}),
);
await queryRunner.createIndex(
'account',
new TableIndex({
name: 'account_userId_idx',
columnNames: ['userId'],
}),
);
await queryRunner.createForeignKey(
'account',
new TableForeignKey({
columnNames: ['userId'],
referencedTableName: 'user',
referencedColumnNames: ['id'],
onDelete: 'CASCADE',
}),
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable('account');
}
}
@@ -1,79 +0,0 @@
import {
type MigrationInterface,
type QueryRunner,
Table,
TableColumn,
TableForeignKey,
TableIndex,
} from 'typeorm';
export class CreateSession1777216318138 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.createTable(
new Table({
name: 'session',
columns: [
{
name: 'id',
type: 'text',
isPrimary: true,
},
{
name: 'expiresAt',
type: 'datetime',
},
{
name: 'token',
type: 'text',
isUnique: true,
},
{
name: 'createdAt',
type: 'datetime',
default: 'CURRENT_TIMESTAMP',
},
{
name: 'updatedAt',
type: 'datetime',
},
{
name: 'ipAddress',
type: 'text',
isNullable: true,
},
{
name: 'userAgent',
type: 'text',
isNullable: true,
},
{
name: 'userId',
type: 'text',
},
],
}),
);
await queryRunner.createIndex(
'session',
new TableIndex({
name: 'session_userId_idx',
columnNames: ['userId'],
}),
);
await queryRunner.createForeignKey(
'session',
new TableForeignKey({
columnNames: ['userId'],
referencedTableName: 'user',
referencedColumnNames: ['id'],
onDelete: 'CASCADE',
}),
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable('session');
}
}
@@ -1,58 +0,0 @@
import {
type MigrationInterface,
type QueryRunner,
Table,
TableColumn,
TableForeignKey,
TableIndex,
} from 'typeorm';
export class CreateUser1777216318138 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.createTable(
new Table({
name: 'user',
columns: [
{
name: 'id',
type: 'text',
isPrimary: true,
},
{
name: 'name',
type: 'text',
},
{
name: 'email',
type: 'text',
isUnique: true,
},
{
name: 'emailVerified',
type: 'boolean',
default: false,
},
{
name: 'image',
type: 'text',
isNullable: true,
},
{
name: 'createdAt',
type: 'datetime',
default: 'CURRENT_TIMESTAMP',
},
{
name: 'updatedAt',
type: 'datetime',
default: 'CURRENT_TIMESTAMP',
},
],
}),
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable('user');
}
}
@@ -1,59 +0,0 @@
import {
type MigrationInterface,
type QueryRunner,
Table,
TableColumn,
TableForeignKey,
TableIndex,
} from 'typeorm';
export class CreateVerification1777216318138 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.createTable(
new Table({
name: 'verification',
columns: [
{
name: 'id',
type: 'text',
isPrimary: true,
},
{
name: 'identifier',
type: 'text',
},
{
name: 'value',
type: 'text',
},
{
name: 'expiresAt',
type: 'datetime',
},
{
name: 'createdAt',
type: 'datetime',
default: 'CURRENT_TIMESTAMP',
},
{
name: 'updatedAt',
type: 'datetime',
default: 'CURRENT_TIMESTAMP',
},
],
}),
);
await queryRunner.createIndex(
'verification',
new TableIndex({
name: 'verification_identifier_idx',
columnNames: ['identifier'],
}),
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable('verification');
}
}
@@ -1,103 +0,0 @@
import {
type MigrationInterface,
type QueryRunner,
Table,
TableColumn,
TableForeignKey,
TableIndex,
} from 'typeorm';
export class CreateAccount1777217712285 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.createTable(
new Table({
name: 'account',
columns: [
{
name: 'id',
type: 'text',
isPrimary: true,
},
{
name: 'accountId',
type: 'text',
},
{
name: 'providerId',
type: 'text',
},
{
name: 'userId',
type: 'text',
},
{
name: 'accessToken',
type: 'text',
isNullable: true,
},
{
name: 'refreshToken',
type: 'text',
isNullable: true,
},
{
name: 'idToken',
type: 'text',
isNullable: true,
},
{
name: 'accessTokenExpiresAt',
type: 'datetime',
isNullable: true,
},
{
name: 'refreshTokenExpiresAt',
type: 'datetime',
isNullable: true,
},
{
name: 'scope',
type: 'text',
isNullable: true,
},
{
name: 'password',
type: 'text',
isNullable: true,
},
{
name: 'createdAt',
type: 'datetime',
default: 'CURRENT_TIMESTAMP',
},
{
name: 'updatedAt',
type: 'datetime',
},
],
}),
);
await queryRunner.createIndex(
'account',
new TableIndex({
name: 'account_userId_idx',
columnNames: ['userId'],
}),
);
await queryRunner.createForeignKey(
'account',
new TableForeignKey({
columnNames: ['userId'],
referencedTableName: 'user',
referencedColumnNames: ['id'],
onDelete: 'CASCADE',
}),
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable('account');
}
}
@@ -1,112 +0,0 @@
import {
type MigrationInterface,
type QueryRunner,
Table,
TableColumn,
TableForeignKey,
TableIndex,
} from 'typeorm';
export class CreateOauthAccessToken1777217712285 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.createTable(
new Table({
name: 'oauthAccessToken',
columns: [
{
name: 'id',
type: 'text',
isPrimary: true,
},
{
name: 'accessToken',
type: 'text',
isNullable: true,
isUnique: true,
},
{
name: 'refreshToken',
type: 'text',
isNullable: true,
isUnique: true,
},
{
name: 'accessTokenExpiresAt',
type: 'datetime',
isNullable: true,
},
{
name: 'refreshTokenExpiresAt',
type: 'datetime',
isNullable: true,
},
{
name: 'clientId',
type: 'text',
isNullable: true,
},
{
name: 'userId',
type: 'text',
isNullable: true,
},
{
name: 'scopes',
type: 'text',
isNullable: true,
},
{
name: 'createdAt',
type: 'datetime',
isNullable: true,
},
{
name: 'updatedAt',
type: 'datetime',
isNullable: true,
},
],
}),
);
await queryRunner.createIndex(
'oauthAccessToken',
new TableIndex({
name: 'oauthAccessToken_clientId_idx',
columnNames: ['clientId'],
}),
);
await queryRunner.createForeignKey(
'oauthAccessToken',
new TableForeignKey({
columnNames: ['clientId'],
referencedTableName: 'oauthApplication',
referencedColumnNames: ['clientId'],
onDelete: 'CASCADE',
}),
);
await queryRunner.createIndex(
'oauthAccessToken',
new TableIndex({
name: 'oauthAccessToken_userId_idx',
columnNames: ['userId'],
}),
);
await queryRunner.createForeignKey(
'oauthAccessToken',
new TableForeignKey({
columnNames: ['userId'],
referencedTableName: 'user',
referencedColumnNames: ['id'],
onDelete: 'CASCADE',
}),
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable('oauthAccessToken');
}
}
@@ -1,104 +0,0 @@
import {
type MigrationInterface,
type QueryRunner,
Table,
TableColumn,
TableForeignKey,
TableIndex,
} from 'typeorm';
export class CreateOauthApplication1777217712285 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.createTable(
new Table({
name: 'oauthApplication',
columns: [
{
name: 'id',
type: 'text',
isPrimary: true,
},
{
name: 'name',
type: 'text',
isNullable: true,
},
{
name: 'icon',
type: 'text',
isNullable: true,
},
{
name: 'metadata',
type: 'text',
isNullable: true,
},
{
name: 'clientId',
type: 'text',
isNullable: true,
isUnique: true,
},
{
name: 'clientSecret',
type: 'text',
isNullable: true,
},
{
name: 'redirectUrls',
type: 'text',
isNullable: true,
},
{
name: 'type',
type: 'text',
isNullable: true,
},
{
name: 'disabled',
type: 'boolean',
isNullable: true,
default: false,
},
{
name: 'userId',
type: 'text',
isNullable: true,
},
{
name: 'createdAt',
type: 'datetime',
isNullable: true,
},
{
name: 'updatedAt',
type: 'datetime',
isNullable: true,
},
],
}),
);
await queryRunner.createIndex(
'oauthApplication',
new TableIndex({
name: 'oauthApplication_userId_idx',
columnNames: ['userId'],
}),
);
await queryRunner.createForeignKey(
'oauthApplication',
new TableForeignKey({
columnNames: ['userId'],
referencedTableName: 'user',
referencedColumnNames: ['id'],
onDelete: 'CASCADE',
}),
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable('oauthApplication');
}
}
@@ -1,95 +0,0 @@
import {
type MigrationInterface,
type QueryRunner,
Table,
TableColumn,
TableForeignKey,
TableIndex,
} from 'typeorm';
export class CreateOauthConsent1777217712285 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.createTable(
new Table({
name: 'oauthConsent',
columns: [
{
name: 'id',
type: 'text',
isPrimary: true,
},
{
name: 'clientId',
type: 'text',
isNullable: true,
},
{
name: 'userId',
type: 'text',
isNullable: true,
},
{
name: 'scopes',
type: 'text',
isNullable: true,
},
{
name: 'createdAt',
type: 'datetime',
isNullable: true,
},
{
name: 'updatedAt',
type: 'datetime',
isNullable: true,
},
{
name: 'consentGiven',
type: 'boolean',
isNullable: true,
},
],
}),
);
await queryRunner.createIndex(
'oauthConsent',
new TableIndex({
name: 'oauthConsent_clientId_idx',
columnNames: ['clientId'],
}),
);
await queryRunner.createForeignKey(
'oauthConsent',
new TableForeignKey({
columnNames: ['clientId'],
referencedTableName: 'oauthApplication',
referencedColumnNames: ['clientId'],
onDelete: 'CASCADE',
}),
);
await queryRunner.createIndex(
'oauthConsent',
new TableIndex({
name: 'oauthConsent_userId_idx',
columnNames: ['userId'],
}),
);
await queryRunner.createForeignKey(
'oauthConsent',
new TableForeignKey({
columnNames: ['userId'],
referencedTableName: 'user',
referencedColumnNames: ['id'],
onDelete: 'CASCADE',
}),
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable('oauthConsent');
}
}
@@ -1,99 +0,0 @@
import {
type MigrationInterface,
type QueryRunner,
Table,
TableColumn,
TableForeignKey,
TableIndex,
} from 'typeorm';
export class CreatePasskey1777217712285 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.createTable(
new Table({
name: 'passkey',
columns: [
{
name: 'id',
type: 'text',
isPrimary: true,
},
{
name: 'name',
type: 'text',
isNullable: true,
},
{
name: 'publicKey',
type: 'text',
},
{
name: 'userId',
type: 'text',
},
{
name: 'credentialID',
type: 'text',
},
{
name: 'counter',
type: 'integer',
},
{
name: 'deviceType',
type: 'text',
},
{
name: 'backedUp',
type: 'boolean',
},
{
name: 'transports',
type: 'text',
isNullable: true,
},
{
name: 'createdAt',
type: 'datetime',
isNullable: true,
},
{
name: 'aaguid',
type: 'text',
isNullable: true,
},
],
}),
);
await queryRunner.createIndex(
'passkey',
new TableIndex({
name: 'passkey_userId_idx',
columnNames: ['userId'],
}),
);
await queryRunner.createForeignKey(
'passkey',
new TableForeignKey({
columnNames: ['userId'],
referencedTableName: 'user',
referencedColumnNames: ['id'],
onDelete: 'CASCADE',
}),
);
await queryRunner.createIndex(
'passkey',
new TableIndex({
name: 'passkey_credentialID_idx',
columnNames: ['credentialID'],
}),
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable('passkey');
}
}
@@ -1,79 +0,0 @@
import {
type MigrationInterface,
type QueryRunner,
Table,
TableColumn,
TableForeignKey,
TableIndex,
} from 'typeorm';
export class CreateSession1777217712285 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.createTable(
new Table({
name: 'session',
columns: [
{
name: 'id',
type: 'text',
isPrimary: true,
},
{
name: 'expiresAt',
type: 'datetime',
},
{
name: 'token',
type: 'text',
isUnique: true,
},
{
name: 'createdAt',
type: 'datetime',
default: 'CURRENT_TIMESTAMP',
},
{
name: 'updatedAt',
type: 'datetime',
},
{
name: 'ipAddress',
type: 'text',
isNullable: true,
},
{
name: 'userAgent',
type: 'text',
isNullable: true,
},
{
name: 'userId',
type: 'text',
},
],
}),
);
await queryRunner.createIndex(
'session',
new TableIndex({
name: 'session_userId_idx',
columnNames: ['userId'],
}),
);
await queryRunner.createForeignKey(
'session',
new TableForeignKey({
columnNames: ['userId'],
referencedTableName: 'user',
referencedColumnNames: ['id'],
onDelete: 'CASCADE',
}),
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable('session');
}
}
@@ -1,58 +0,0 @@
import {
type MigrationInterface,
type QueryRunner,
Table,
TableColumn,
TableForeignKey,
TableIndex,
} from 'typeorm';
export class CreateUser1777217712285 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.createTable(
new Table({
name: 'user',
columns: [
{
name: 'id',
type: 'text',
isPrimary: true,
},
{
name: 'name',
type: 'text',
},
{
name: 'email',
type: 'text',
isUnique: true,
},
{
name: 'emailVerified',
type: 'boolean',
default: false,
},
{
name: 'image',
type: 'text',
isNullable: true,
},
{
name: 'createdAt',
type: 'datetime',
default: 'CURRENT_TIMESTAMP',
},
{
name: 'updatedAt',
type: 'datetime',
default: 'CURRENT_TIMESTAMP',
},
],
}),
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable('user');
}
}
@@ -1,59 +0,0 @@
import {
type MigrationInterface,
type QueryRunner,
Table,
TableColumn,
TableForeignKey,
TableIndex,
} from 'typeorm';
export class CreateVerification1777217712285 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.createTable(
new Table({
name: 'verification',
columns: [
{
name: 'id',
type: 'text',
isPrimary: true,
},
{
name: 'identifier',
type: 'text',
},
{
name: 'value',
type: 'text',
},
{
name: 'expiresAt',
type: 'datetime',
},
{
name: 'createdAt',
type: 'datetime',
default: 'CURRENT_TIMESTAMP',
},
{
name: 'updatedAt',
type: 'datetime',
default: 'CURRENT_TIMESTAMP',
},
],
}),
);
await queryRunner.createIndex(
'verification',
new TableIndex({
name: 'verification_identifier_idx',
columnNames: ['identifier'],
}),
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable('verification');
}
}
@@ -1,103 +0,0 @@
import {
type MigrationInterface,
type QueryRunner,
Table,
TableColumn,
TableForeignKey,
TableIndex,
} from 'typeorm';
export class CreateAccount1777217820713 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.createTable(
new Table({
name: 'account',
columns: [
{
name: 'id',
type: 'text',
isPrimary: true,
},
{
name: 'accountId',
type: 'text',
},
{
name: 'providerId',
type: 'text',
},
{
name: 'userId',
type: 'text',
},
{
name: 'accessToken',
type: 'text',
isNullable: true,
},
{
name: 'refreshToken',
type: 'text',
isNullable: true,
},
{
name: 'idToken',
type: 'text',
isNullable: true,
},
{
name: 'accessTokenExpiresAt',
type: 'datetime',
isNullable: true,
},
{
name: 'refreshTokenExpiresAt',
type: 'datetime',
isNullable: true,
},
{
name: 'scope',
type: 'text',
isNullable: true,
},
{
name: 'password',
type: 'text',
isNullable: true,
},
{
name: 'createdAt',
type: 'datetime',
default: 'CURRENT_TIMESTAMP',
},
{
name: 'updatedAt',
type: 'datetime',
},
],
}),
);
await queryRunner.createIndex(
'account',
new TableIndex({
name: 'account_userId_idx',
columnNames: ['userId'],
}),
);
await queryRunner.createForeignKey(
'account',
new TableForeignKey({
columnNames: ['userId'],
referencedTableName: 'user',
referencedColumnNames: ['id'],
onDelete: 'CASCADE',
}),
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable('account');
}
}
@@ -1,113 +0,0 @@
import {
type MigrationInterface,
type QueryRunner,
Table,
TableColumn,
TableForeignKey,
TableIndex,
} from 'typeorm';
export class CreateOauthAccessToken1777217820713 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.createTable(
new Table({
name: 'oauthAccessToken',
columns: [
{
name: 'id',
type: 'text',
isPrimary: true,
},
{
name: 'token',
type: 'text',
isNullable: true,
isUnique: true,
},
{
name: 'clientId',
type: 'text',
},
{
name: 'sessionId',
type: 'text',
isNullable: true,
},
{
name: 'userId',
type: 'text',
isNullable: true,
},
{
name: 'referenceId',
type: 'text',
isNullable: true,
},
{
name: 'refreshId',
type: 'text',
isNullable: true,
},
{
name: 'expiresAt',
type: 'datetime',
isNullable: true,
},
{
name: 'createdAt',
type: 'datetime',
isNullable: true,
},
{
name: 'scopes',
type: 'text',
},
],
}),
);
await queryRunner.createForeignKey(
'oauthAccessToken',
new TableForeignKey({
columnNames: ['clientId'],
referencedTableName: 'oauthClient',
referencedColumnNames: ['clientId'],
onDelete: 'CASCADE',
}),
);
await queryRunner.createForeignKey(
'oauthAccessToken',
new TableForeignKey({
columnNames: ['sessionId'],
referencedTableName: 'session',
referencedColumnNames: ['id'],
onDelete: 'SET NULL',
}),
);
await queryRunner.createForeignKey(
'oauthAccessToken',
new TableForeignKey({
columnNames: ['userId'],
referencedTableName: 'user',
referencedColumnNames: ['id'],
onDelete: 'CASCADE',
}),
);
await queryRunner.createForeignKey(
'oauthAccessToken',
new TableForeignKey({
columnNames: ['refreshId'],
referencedTableName: 'oauthRefreshToken',
referencedColumnNames: ['id'],
onDelete: 'CASCADE',
}),
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable('oauthAccessToken');
}
}
@@ -1,184 +0,0 @@
import {
type MigrationInterface,
type QueryRunner,
Table,
TableColumn,
TableForeignKey,
TableIndex,
} from 'typeorm';
export class CreateOauthClient1777217820713 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.createTable(
new Table({
name: 'oauthClient',
columns: [
{
name: 'id',
type: 'text',
isPrimary: true,
},
{
name: 'clientId',
type: 'text',
isUnique: true,
},
{
name: 'clientSecret',
type: 'text',
isNullable: true,
},
{
name: 'disabled',
type: 'boolean',
isNullable: true,
default: false,
},
{
name: 'skipConsent',
type: 'boolean',
isNullable: true,
},
{
name: 'enableEndSession',
type: 'boolean',
isNullable: true,
},
{
name: 'subjectType',
type: 'text',
isNullable: true,
},
{
name: 'scopes',
type: 'text',
isNullable: true,
},
{
name: 'userId',
type: 'text',
isNullable: true,
},
{
name: 'createdAt',
type: 'datetime',
isNullable: true,
},
{
name: 'updatedAt',
type: 'datetime',
isNullable: true,
},
{
name: 'name',
type: 'text',
isNullable: true,
},
{
name: 'uri',
type: 'text',
isNullable: true,
},
{
name: 'icon',
type: 'text',
isNullable: true,
},
{
name: 'contacts',
type: 'text',
isNullable: true,
},
{
name: 'tos',
type: 'text',
isNullable: true,
},
{
name: 'policy',
type: 'text',
isNullable: true,
},
{
name: 'softwareId',
type: 'text',
isNullable: true,
},
{
name: 'softwareVersion',
type: 'text',
isNullable: true,
},
{
name: 'softwareStatement',
type: 'text',
isNullable: true,
},
{
name: 'redirectUris',
type: 'text',
},
{
name: 'postLogoutRedirectUris',
type: 'text',
isNullable: true,
},
{
name: 'tokenEndpointAuthMethod',
type: 'text',
isNullable: true,
},
{
name: 'grantTypes',
type: 'text',
isNullable: true,
},
{
name: 'responseTypes',
type: 'text',
isNullable: true,
},
{
name: 'public',
type: 'boolean',
isNullable: true,
},
{
name: 'type',
type: 'text',
isNullable: true,
},
{
name: 'requirePKCE',
type: 'boolean',
isNullable: true,
},
{
name: 'referenceId',
type: 'text',
isNullable: true,
},
{
name: 'metadata',
type: 'text',
isNullable: true,
},
],
}),
);
await queryRunner.createForeignKey(
'oauthClient',
new TableForeignKey({
columnNames: ['userId'],
referencedTableName: 'user',
referencedColumnNames: ['id'],
onDelete: 'CASCADE',
}),
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable('oauthClient');
}
}
@@ -1,77 +0,0 @@
import {
type MigrationInterface,
type QueryRunner,
Table,
TableColumn,
TableForeignKey,
TableIndex,
} from 'typeorm';
export class CreateOauthConsent1777217820713 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.createTable(
new Table({
name: 'oauthConsent',
columns: [
{
name: 'id',
type: 'text',
isPrimary: true,
},
{
name: 'clientId',
type: 'text',
},
{
name: 'userId',
type: 'text',
isNullable: true,
},
{
name: 'referenceId',
type: 'text',
isNullable: true,
},
{
name: 'scopes',
type: 'text',
},
{
name: 'createdAt',
type: 'datetime',
isNullable: true,
},
{
name: 'updatedAt',
type: 'datetime',
isNullable: true,
},
],
}),
);
await queryRunner.createForeignKey(
'oauthConsent',
new TableForeignKey({
columnNames: ['clientId'],
referencedTableName: 'oauthClient',
referencedColumnNames: ['clientId'],
onDelete: 'CASCADE',
}),
);
await queryRunner.createForeignKey(
'oauthConsent',
new TableForeignKey({
columnNames: ['userId'],
referencedTableName: 'user',
referencedColumnNames: ['id'],
onDelete: 'CASCADE',
}),
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable('oauthConsent');
}
}
@@ -1,106 +0,0 @@
import {
type MigrationInterface,
type QueryRunner,
Table,
TableColumn,
TableForeignKey,
TableIndex,
} from 'typeorm';
export class CreateOauthRefreshToken1777217820713 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.createTable(
new Table({
name: 'oauthRefreshToken',
columns: [
{
name: 'id',
type: 'text',
isPrimary: true,
},
{
name: 'token',
type: 'text',
isUnique: true,
},
{
name: 'clientId',
type: 'text',
},
{
name: 'sessionId',
type: 'text',
isNullable: true,
},
{
name: 'userId',
type: 'text',
},
{
name: 'referenceId',
type: 'text',
isNullable: true,
},
{
name: 'expiresAt',
type: 'datetime',
isNullable: true,
},
{
name: 'createdAt',
type: 'datetime',
isNullable: true,
},
{
name: 'revoked',
type: 'datetime',
isNullable: true,
},
{
name: 'authTime',
type: 'datetime',
isNullable: true,
},
{
name: 'scopes',
type: 'text',
},
],
}),
);
await queryRunner.createForeignKey(
'oauthRefreshToken',
new TableForeignKey({
columnNames: ['clientId'],
referencedTableName: 'oauthClient',
referencedColumnNames: ['clientId'],
onDelete: 'CASCADE',
}),
);
await queryRunner.createForeignKey(
'oauthRefreshToken',
new TableForeignKey({
columnNames: ['sessionId'],
referencedTableName: 'session',
referencedColumnNames: ['id'],
onDelete: 'SET NULL',
}),
);
await queryRunner.createForeignKey(
'oauthRefreshToken',
new TableForeignKey({
columnNames: ['userId'],
referencedTableName: 'user',
referencedColumnNames: ['id'],
onDelete: 'CASCADE',
}),
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable('oauthRefreshToken');
}
}
@@ -1,99 +0,0 @@
import {
type MigrationInterface,
type QueryRunner,
Table,
TableColumn,
TableForeignKey,
TableIndex,
} from 'typeorm';
export class CreatePasskey1777217820713 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.createTable(
new Table({
name: 'passkey',
columns: [
{
name: 'id',
type: 'text',
isPrimary: true,
},
{
name: 'name',
type: 'text',
isNullable: true,
},
{
name: 'publicKey',
type: 'text',
},
{
name: 'userId',
type: 'text',
},
{
name: 'credentialID',
type: 'text',
},
{
name: 'counter',
type: 'integer',
},
{
name: 'deviceType',
type: 'text',
},
{
name: 'backedUp',
type: 'boolean',
},
{
name: 'transports',
type: 'text',
isNullable: true,
},
{
name: 'createdAt',
type: 'datetime',
isNullable: true,
},
{
name: 'aaguid',
type: 'text',
isNullable: true,
},
],
}),
);
await queryRunner.createIndex(
'passkey',
new TableIndex({
name: 'passkey_userId_idx',
columnNames: ['userId'],
}),
);
await queryRunner.createForeignKey(
'passkey',
new TableForeignKey({
columnNames: ['userId'],
referencedTableName: 'user',
referencedColumnNames: ['id'],
onDelete: 'CASCADE',
}),
);
await queryRunner.createIndex(
'passkey',
new TableIndex({
name: 'passkey_credentialID_idx',
columnNames: ['credentialID'],
}),
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable('passkey');
}
}
@@ -1,79 +0,0 @@
import {
type MigrationInterface,
type QueryRunner,
Table,
TableColumn,
TableForeignKey,
TableIndex,
} from 'typeorm';
export class CreateSession1777217820713 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.createTable(
new Table({
name: 'session',
columns: [
{
name: 'id',
type: 'text',
isPrimary: true,
},
{
name: 'expiresAt',
type: 'datetime',
},
{
name: 'token',
type: 'text',
isUnique: true,
},
{
name: 'createdAt',
type: 'datetime',
default: 'CURRENT_TIMESTAMP',
},
{
name: 'updatedAt',
type: 'datetime',
},
{
name: 'ipAddress',
type: 'text',
isNullable: true,
},
{
name: 'userAgent',
type: 'text',
isNullable: true,
},
{
name: 'userId',
type: 'text',
},
],
}),
);
await queryRunner.createIndex(
'session',
new TableIndex({
name: 'session_userId_idx',
columnNames: ['userId'],
}),
);
await queryRunner.createForeignKey(
'session',
new TableForeignKey({
columnNames: ['userId'],
referencedTableName: 'user',
referencedColumnNames: ['id'],
onDelete: 'CASCADE',
}),
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable('session');
}
}
@@ -1,58 +0,0 @@
import {
type MigrationInterface,
type QueryRunner,
Table,
TableColumn,
TableForeignKey,
TableIndex,
} from 'typeorm';
export class CreateUser1777217820713 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.createTable(
new Table({
name: 'user',
columns: [
{
name: 'id',
type: 'text',
isPrimary: true,
},
{
name: 'name',
type: 'text',
},
{
name: 'email',
type: 'text',
isUnique: true,
},
{
name: 'emailVerified',
type: 'boolean',
default: false,
},
{
name: 'image',
type: 'text',
isNullable: true,
},
{
name: 'createdAt',
type: 'datetime',
default: 'CURRENT_TIMESTAMP',
},
{
name: 'updatedAt',
type: 'datetime',
default: 'CURRENT_TIMESTAMP',
},
],
}),
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable('user');
}
}
@@ -1,59 +0,0 @@
import {
type MigrationInterface,
type QueryRunner,
Table,
TableColumn,
TableForeignKey,
TableIndex,
} from 'typeorm';
export class CreateVerification1777217820713 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.createTable(
new Table({
name: 'verification',
columns: [
{
name: 'id',
type: 'text',
isPrimary: true,
},
{
name: 'identifier',
type: 'text',
},
{
name: 'value',
type: 'text',
},
{
name: 'expiresAt',
type: 'datetime',
},
{
name: 'createdAt',
type: 'datetime',
default: 'CURRENT_TIMESTAMP',
},
{
name: 'updatedAt',
type: 'datetime',
default: 'CURRENT_TIMESTAMP',
},
],
}),
);
await queryRunner.createIndex(
'verification',
new TableIndex({
name: 'verification_identifier_idx',
columnNames: ['identifier'],
}),
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable('verification');
}
}
@@ -1,103 +0,0 @@
import {
type MigrationInterface,
type QueryRunner,
Table,
TableColumn,
TableForeignKey,
TableIndex,
} from 'typeorm';
export class CreateAccount1777217882945 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.createTable(
new Table({
name: 'account',
columns: [
{
name: 'id',
type: 'text',
isPrimary: true,
},
{
name: 'accountId',
type: 'text',
},
{
name: 'providerId',
type: 'text',
},
{
name: 'userId',
type: 'text',
},
{
name: 'accessToken',
type: 'text',
isNullable: true,
},
{
name: 'refreshToken',
type: 'text',
isNullable: true,
},
{
name: 'idToken',
type: 'text',
isNullable: true,
},
{
name: 'accessTokenExpiresAt',
type: 'datetime',
isNullable: true,
},
{
name: 'refreshTokenExpiresAt',
type: 'datetime',
isNullable: true,
},
{
name: 'scope',
type: 'text',
isNullable: true,
},
{
name: 'password',
type: 'text',
isNullable: true,
},
{
name: 'createdAt',
type: 'datetime',
default: 'CURRENT_TIMESTAMP',
},
{
name: 'updatedAt',
type: 'datetime',
},
],
}),
);
await queryRunner.createIndex(
'account',
new TableIndex({
name: 'account_userId_idx',
columnNames: ['userId'],
}),
);
await queryRunner.createForeignKey(
'account',
new TableForeignKey({
columnNames: ['userId'],
referencedTableName: 'user',
referencedColumnNames: ['id'],
onDelete: 'CASCADE',
}),
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable('account');
}
}
@@ -1,46 +0,0 @@
import {
type MigrationInterface,
type QueryRunner,
Table,
TableColumn,
TableForeignKey,
TableIndex,
} from 'typeorm';
export class CreateJwks1777217882945 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.createTable(
new Table({
name: 'jwks',
columns: [
{
name: 'id',
type: 'text',
isPrimary: true,
},
{
name: 'publicKey',
type: 'text',
},
{
name: 'privateKey',
type: 'text',
},
{
name: 'createdAt',
type: 'datetime',
},
{
name: 'expiresAt',
type: 'datetime',
isNullable: true,
},
],
}),
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable('jwks');
}
}
@@ -1,113 +0,0 @@
import {
type MigrationInterface,
type QueryRunner,
Table,
TableColumn,
TableForeignKey,
TableIndex,
} from 'typeorm';
export class CreateOauthAccessToken1777217882945 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.createTable(
new Table({
name: 'oauthAccessToken',
columns: [
{
name: 'id',
type: 'text',
isPrimary: true,
},
{
name: 'token',
type: 'text',
isNullable: true,
isUnique: true,
},
{
name: 'clientId',
type: 'text',
},
{
name: 'sessionId',
type: 'text',
isNullable: true,
},
{
name: 'userId',
type: 'text',
isNullable: true,
},
{
name: 'referenceId',
type: 'text',
isNullable: true,
},
{
name: 'refreshId',
type: 'text',
isNullable: true,
},
{
name: 'expiresAt',
type: 'datetime',
isNullable: true,
},
{
name: 'createdAt',
type: 'datetime',
isNullable: true,
},
{
name: 'scopes',
type: 'text',
},
],
}),
);
await queryRunner.createForeignKey(
'oauthAccessToken',
new TableForeignKey({
columnNames: ['clientId'],
referencedTableName: 'oauthClient',
referencedColumnNames: ['clientId'],
onDelete: 'CASCADE',
}),
);
await queryRunner.createForeignKey(
'oauthAccessToken',
new TableForeignKey({
columnNames: ['sessionId'],
referencedTableName: 'session',
referencedColumnNames: ['id'],
onDelete: 'SET NULL',
}),
);
await queryRunner.createForeignKey(
'oauthAccessToken',
new TableForeignKey({
columnNames: ['userId'],
referencedTableName: 'user',
referencedColumnNames: ['id'],
onDelete: 'CASCADE',
}),
);
await queryRunner.createForeignKey(
'oauthAccessToken',
new TableForeignKey({
columnNames: ['refreshId'],
referencedTableName: 'oauthRefreshToken',
referencedColumnNames: ['id'],
onDelete: 'CASCADE',
}),
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable('oauthAccessToken');
}
}
@@ -1,184 +0,0 @@
import {
type MigrationInterface,
type QueryRunner,
Table,
TableColumn,
TableForeignKey,
TableIndex,
} from 'typeorm';
export class CreateOauthClient1777217882945 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.createTable(
new Table({
name: 'oauthClient',
columns: [
{
name: 'id',
type: 'text',
isPrimary: true,
},
{
name: 'clientId',
type: 'text',
isUnique: true,
},
{
name: 'clientSecret',
type: 'text',
isNullable: true,
},
{
name: 'disabled',
type: 'boolean',
isNullable: true,
default: false,
},
{
name: 'skipConsent',
type: 'boolean',
isNullable: true,
},
{
name: 'enableEndSession',
type: 'boolean',
isNullable: true,
},
{
name: 'subjectType',
type: 'text',
isNullable: true,
},
{
name: 'scopes',
type: 'text',
isNullable: true,
},
{
name: 'userId',
type: 'text',
isNullable: true,
},
{
name: 'createdAt',
type: 'datetime',
isNullable: true,
},
{
name: 'updatedAt',
type: 'datetime',
isNullable: true,
},
{
name: 'name',
type: 'text',
isNullable: true,
},
{
name: 'uri',
type: 'text',
isNullable: true,
},
{
name: 'icon',
type: 'text',
isNullable: true,
},
{
name: 'contacts',
type: 'text',
isNullable: true,
},
{
name: 'tos',
type: 'text',
isNullable: true,
},
{
name: 'policy',
type: 'text',
isNullable: true,
},
{
name: 'softwareId',
type: 'text',
isNullable: true,
},
{
name: 'softwareVersion',
type: 'text',
isNullable: true,
},
{
name: 'softwareStatement',
type: 'text',
isNullable: true,
},
{
name: 'redirectUris',
type: 'text',
},
{
name: 'postLogoutRedirectUris',
type: 'text',
isNullable: true,
},
{
name: 'tokenEndpointAuthMethod',
type: 'text',
isNullable: true,
},
{
name: 'grantTypes',
type: 'text',
isNullable: true,
},
{
name: 'responseTypes',
type: 'text',
isNullable: true,
},
{
name: 'public',
type: 'boolean',
isNullable: true,
},
{
name: 'type',
type: 'text',
isNullable: true,
},
{
name: 'requirePKCE',
type: 'boolean',
isNullable: true,
},
{
name: 'referenceId',
type: 'text',
isNullable: true,
},
{
name: 'metadata',
type: 'text',
isNullable: true,
},
],
}),
);
await queryRunner.createForeignKey(
'oauthClient',
new TableForeignKey({
columnNames: ['userId'],
referencedTableName: 'user',
referencedColumnNames: ['id'],
onDelete: 'CASCADE',
}),
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable('oauthClient');
}
}
@@ -1,77 +0,0 @@
import {
type MigrationInterface,
type QueryRunner,
Table,
TableColumn,
TableForeignKey,
TableIndex,
} from 'typeorm';
export class CreateOauthConsent1777217882945 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.createTable(
new Table({
name: 'oauthConsent',
columns: [
{
name: 'id',
type: 'text',
isPrimary: true,
},
{
name: 'clientId',
type: 'text',
},
{
name: 'userId',
type: 'text',
isNullable: true,
},
{
name: 'referenceId',
type: 'text',
isNullable: true,
},
{
name: 'scopes',
type: 'text',
},
{
name: 'createdAt',
type: 'datetime',
isNullable: true,
},
{
name: 'updatedAt',
type: 'datetime',
isNullable: true,
},
],
}),
);
await queryRunner.createForeignKey(
'oauthConsent',
new TableForeignKey({
columnNames: ['clientId'],
referencedTableName: 'oauthClient',
referencedColumnNames: ['clientId'],
onDelete: 'CASCADE',
}),
);
await queryRunner.createForeignKey(
'oauthConsent',
new TableForeignKey({
columnNames: ['userId'],
referencedTableName: 'user',
referencedColumnNames: ['id'],
onDelete: 'CASCADE',
}),
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable('oauthConsent');
}
}
@@ -1,106 +0,0 @@
import {
type MigrationInterface,
type QueryRunner,
Table,
TableColumn,
TableForeignKey,
TableIndex,
} from 'typeorm';
export class CreateOauthRefreshToken1777217882945 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.createTable(
new Table({
name: 'oauthRefreshToken',
columns: [
{
name: 'id',
type: 'text',
isPrimary: true,
},
{
name: 'token',
type: 'text',
isUnique: true,
},
{
name: 'clientId',
type: 'text',
},
{
name: 'sessionId',
type: 'text',
isNullable: true,
},
{
name: 'userId',
type: 'text',
},
{
name: 'referenceId',
type: 'text',
isNullable: true,
},
{
name: 'expiresAt',
type: 'datetime',
isNullable: true,
},
{
name: 'createdAt',
type: 'datetime',
isNullable: true,
},
{
name: 'revoked',
type: 'datetime',
isNullable: true,
},
{
name: 'authTime',
type: 'datetime',
isNullable: true,
},
{
name: 'scopes',
type: 'text',
},
],
}),
);
await queryRunner.createForeignKey(
'oauthRefreshToken',
new TableForeignKey({
columnNames: ['clientId'],
referencedTableName: 'oauthClient',
referencedColumnNames: ['clientId'],
onDelete: 'CASCADE',
}),
);
await queryRunner.createForeignKey(
'oauthRefreshToken',
new TableForeignKey({
columnNames: ['sessionId'],
referencedTableName: 'session',
referencedColumnNames: ['id'],
onDelete: 'SET NULL',
}),
);
await queryRunner.createForeignKey(
'oauthRefreshToken',
new TableForeignKey({
columnNames: ['userId'],
referencedTableName: 'user',
referencedColumnNames: ['id'],
onDelete: 'CASCADE',
}),
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable('oauthRefreshToken');
}
}
@@ -1,99 +0,0 @@
import {
type MigrationInterface,
type QueryRunner,
Table,
TableColumn,
TableForeignKey,
TableIndex,
} from 'typeorm';
export class CreatePasskey1777217882945 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.createTable(
new Table({
name: 'passkey',
columns: [
{
name: 'id',
type: 'text',
isPrimary: true,
},
{
name: 'name',
type: 'text',
isNullable: true,
},
{
name: 'publicKey',
type: 'text',
},
{
name: 'userId',
type: 'text',
},
{
name: 'credentialID',
type: 'text',
},
{
name: 'counter',
type: 'integer',
},
{
name: 'deviceType',
type: 'text',
},
{
name: 'backedUp',
type: 'boolean',
},
{
name: 'transports',
type: 'text',
isNullable: true,
},
{
name: 'createdAt',
type: 'datetime',
isNullable: true,
},
{
name: 'aaguid',
type: 'text',
isNullable: true,
},
],
}),
);
await queryRunner.createIndex(
'passkey',
new TableIndex({
name: 'passkey_userId_idx',
columnNames: ['userId'],
}),
);
await queryRunner.createForeignKey(
'passkey',
new TableForeignKey({
columnNames: ['userId'],
referencedTableName: 'user',
referencedColumnNames: ['id'],
onDelete: 'CASCADE',
}),
);
await queryRunner.createIndex(
'passkey',
new TableIndex({
name: 'passkey_credentialID_idx',
columnNames: ['credentialID'],
}),
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable('passkey');
}
}
@@ -1,79 +0,0 @@
import {
type MigrationInterface,
type QueryRunner,
Table,
TableColumn,
TableForeignKey,
TableIndex,
} from 'typeorm';
export class CreateSession1777217882945 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.createTable(
new Table({
name: 'session',
columns: [
{
name: 'id',
type: 'text',
isPrimary: true,
},
{
name: 'expiresAt',
type: 'datetime',
},
{
name: 'token',
type: 'text',
isUnique: true,
},
{
name: 'createdAt',
type: 'datetime',
default: 'CURRENT_TIMESTAMP',
},
{
name: 'updatedAt',
type: 'datetime',
},
{
name: 'ipAddress',
type: 'text',
isNullable: true,
},
{
name: 'userAgent',
type: 'text',
isNullable: true,
},
{
name: 'userId',
type: 'text',
},
],
}),
);
await queryRunner.createIndex(
'session',
new TableIndex({
name: 'session_userId_idx',
columnNames: ['userId'],
}),
);
await queryRunner.createForeignKey(
'session',
new TableForeignKey({
columnNames: ['userId'],
referencedTableName: 'user',
referencedColumnNames: ['id'],
onDelete: 'CASCADE',
}),
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable('session');
}
}
@@ -1,58 +0,0 @@
import {
type MigrationInterface,
type QueryRunner,
Table,
TableColumn,
TableForeignKey,
TableIndex,
} from 'typeorm';
export class CreateUser1777217882945 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.createTable(
new Table({
name: 'user',
columns: [
{
name: 'id',
type: 'text',
isPrimary: true,
},
{
name: 'name',
type: 'text',
},
{
name: 'email',
type: 'text',
isUnique: true,
},
{
name: 'emailVerified',
type: 'boolean',
default: false,
},
{
name: 'image',
type: 'text',
isNullable: true,
},
{
name: 'createdAt',
type: 'datetime',
default: 'CURRENT_TIMESTAMP',
},
{
name: 'updatedAt',
type: 'datetime',
default: 'CURRENT_TIMESTAMP',
},
],
}),
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable('user');
}
}
@@ -1,59 +0,0 @@
import {
type MigrationInterface,
type QueryRunner,
Table,
TableColumn,
TableForeignKey,
TableIndex,
} from 'typeorm';
export class CreateVerification1777217882945 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.createTable(
new Table({
name: 'verification',
columns: [
{
name: 'id',
type: 'text',
isPrimary: true,
},
{
name: 'identifier',
type: 'text',
},
{
name: 'value',
type: 'text',
},
{
name: 'expiresAt',
type: 'datetime',
},
{
name: 'createdAt',
type: 'datetime',
default: 'CURRENT_TIMESTAMP',
},
{
name: 'updatedAt',
type: 'datetime',
default: 'CURRENT_TIMESTAMP',
},
],
}),
);
await queryRunner.createIndex(
'verification',
new TableIndex({
name: 'verification_identifier_idx',
columnNames: ['identifier'],
}),
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable('verification');
}
}
@@ -1,103 +0,0 @@
import {
type MigrationInterface,
type QueryRunner,
Table,
TableColumn,
TableForeignKey,
TableIndex,
} from 'typeorm';
export class CreateAccount1777217895075 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.createTable(
new Table({
name: 'account',
columns: [
{
name: 'id',
type: 'text',
isPrimary: true,
},
{
name: 'accountId',
type: 'text',
},
{
name: 'providerId',
type: 'text',
},
{
name: 'userId',
type: 'text',
},
{
name: 'accessToken',
type: 'text',
isNullable: true,
},
{
name: 'refreshToken',
type: 'text',
isNullable: true,
},
{
name: 'idToken',
type: 'text',
isNullable: true,
},
{
name: 'accessTokenExpiresAt',
type: 'datetime',
isNullable: true,
},
{
name: 'refreshTokenExpiresAt',
type: 'datetime',
isNullable: true,
},
{
name: 'scope',
type: 'text',
isNullable: true,
},
{
name: 'password',
type: 'text',
isNullable: true,
},
{
name: 'createdAt',
type: 'datetime',
default: 'CURRENT_TIMESTAMP',
},
{
name: 'updatedAt',
type: 'datetime',
},
],
}),
);
await queryRunner.createIndex(
'account',
new TableIndex({
name: 'account_userId_idx',
columnNames: ['userId'],
}),
);
await queryRunner.createForeignKey(
'account',
new TableForeignKey({
columnNames: ['userId'],
referencedTableName: 'user',
referencedColumnNames: ['id'],
onDelete: 'CASCADE',
}),
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable('account');
}
}
@@ -1,46 +0,0 @@
import {
type MigrationInterface,
type QueryRunner,
Table,
TableColumn,
TableForeignKey,
TableIndex,
} from 'typeorm';
export class CreateJwks1777217895075 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.createTable(
new Table({
name: 'jwks',
columns: [
{
name: 'id',
type: 'text',
isPrimary: true,
},
{
name: 'publicKey',
type: 'text',
},
{
name: 'privateKey',
type: 'text',
},
{
name: 'createdAt',
type: 'datetime',
},
{
name: 'expiresAt',
type: 'datetime',
isNullable: true,
},
],
}),
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable('jwks');
}
}
@@ -1,113 +0,0 @@
import {
type MigrationInterface,
type QueryRunner,
Table,
TableColumn,
TableForeignKey,
TableIndex,
} from 'typeorm';
export class CreateOauthAccessToken1777217895075 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.createTable(
new Table({
name: 'oauthAccessToken',
columns: [
{
name: 'id',
type: 'text',
isPrimary: true,
},
{
name: 'token',
type: 'text',
isNullable: true,
isUnique: true,
},
{
name: 'clientId',
type: 'text',
},
{
name: 'sessionId',
type: 'text',
isNullable: true,
},
{
name: 'userId',
type: 'text',
isNullable: true,
},
{
name: 'referenceId',
type: 'text',
isNullable: true,
},
{
name: 'refreshId',
type: 'text',
isNullable: true,
},
{
name: 'expiresAt',
type: 'datetime',
isNullable: true,
},
{
name: 'createdAt',
type: 'datetime',
isNullable: true,
},
{
name: 'scopes',
type: 'text',
},
],
}),
);
await queryRunner.createForeignKey(
'oauthAccessToken',
new TableForeignKey({
columnNames: ['clientId'],
referencedTableName: 'oauthClient',
referencedColumnNames: ['clientId'],
onDelete: 'CASCADE',
}),
);
await queryRunner.createForeignKey(
'oauthAccessToken',
new TableForeignKey({
columnNames: ['sessionId'],
referencedTableName: 'session',
referencedColumnNames: ['id'],
onDelete: 'SET NULL',
}),
);
await queryRunner.createForeignKey(
'oauthAccessToken',
new TableForeignKey({
columnNames: ['userId'],
referencedTableName: 'user',
referencedColumnNames: ['id'],
onDelete: 'CASCADE',
}),
);
await queryRunner.createForeignKey(
'oauthAccessToken',
new TableForeignKey({
columnNames: ['refreshId'],
referencedTableName: 'oauthRefreshToken',
referencedColumnNames: ['id'],
onDelete: 'CASCADE',
}),
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable('oauthAccessToken');
}
}
@@ -1,184 +0,0 @@
import {
type MigrationInterface,
type QueryRunner,
Table,
TableColumn,
TableForeignKey,
TableIndex,
} from 'typeorm';
export class CreateOauthClient1777217895075 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.createTable(
new Table({
name: 'oauthClient',
columns: [
{
name: 'id',
type: 'text',
isPrimary: true,
},
{
name: 'clientId',
type: 'text',
isUnique: true,
},
{
name: 'clientSecret',
type: 'text',
isNullable: true,
},
{
name: 'disabled',
type: 'boolean',
isNullable: true,
default: false,
},
{
name: 'skipConsent',
type: 'boolean',
isNullable: true,
},
{
name: 'enableEndSession',
type: 'boolean',
isNullable: true,
},
{
name: 'subjectType',
type: 'text',
isNullable: true,
},
{
name: 'scopes',
type: 'text',
isNullable: true,
},
{
name: 'userId',
type: 'text',
isNullable: true,
},
{
name: 'createdAt',
type: 'datetime',
isNullable: true,
},
{
name: 'updatedAt',
type: 'datetime',
isNullable: true,
},
{
name: 'name',
type: 'text',
isNullable: true,
},
{
name: 'uri',
type: 'text',
isNullable: true,
},
{
name: 'icon',
type: 'text',
isNullable: true,
},
{
name: 'contacts',
type: 'text',
isNullable: true,
},
{
name: 'tos',
type: 'text',
isNullable: true,
},
{
name: 'policy',
type: 'text',
isNullable: true,
},
{
name: 'softwareId',
type: 'text',
isNullable: true,
},
{
name: 'softwareVersion',
type: 'text',
isNullable: true,
},
{
name: 'softwareStatement',
type: 'text',
isNullable: true,
},
{
name: 'redirectUris',
type: 'text',
},
{
name: 'postLogoutRedirectUris',
type: 'text',
isNullable: true,
},
{
name: 'tokenEndpointAuthMethod',
type: 'text',
isNullable: true,
},
{
name: 'grantTypes',
type: 'text',
isNullable: true,
},
{
name: 'responseTypes',
type: 'text',
isNullable: true,
},
{
name: 'public',
type: 'boolean',
isNullable: true,
},
{
name: 'type',
type: 'text',
isNullable: true,
},
{
name: 'requirePKCE',
type: 'boolean',
isNullable: true,
},
{
name: 'referenceId',
type: 'text',
isNullable: true,
},
{
name: 'metadata',
type: 'text',
isNullable: true,
},
],
}),
);
await queryRunner.createForeignKey(
'oauthClient',
new TableForeignKey({
columnNames: ['userId'],
referencedTableName: 'user',
referencedColumnNames: ['id'],
onDelete: 'CASCADE',
}),
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable('oauthClient');
}
}
@@ -1,77 +0,0 @@
import {
type MigrationInterface,
type QueryRunner,
Table,
TableColumn,
TableForeignKey,
TableIndex,
} from 'typeorm';
export class CreateOauthConsent1777217895075 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.createTable(
new Table({
name: 'oauthConsent',
columns: [
{
name: 'id',
type: 'text',
isPrimary: true,
},
{
name: 'clientId',
type: 'text',
},
{
name: 'userId',
type: 'text',
isNullable: true,
},
{
name: 'referenceId',
type: 'text',
isNullable: true,
},
{
name: 'scopes',
type: 'text',
},
{
name: 'createdAt',
type: 'datetime',
isNullable: true,
},
{
name: 'updatedAt',
type: 'datetime',
isNullable: true,
},
],
}),
);
await queryRunner.createForeignKey(
'oauthConsent',
new TableForeignKey({
columnNames: ['clientId'],
referencedTableName: 'oauthClient',
referencedColumnNames: ['clientId'],
onDelete: 'CASCADE',
}),
);
await queryRunner.createForeignKey(
'oauthConsent',
new TableForeignKey({
columnNames: ['userId'],
referencedTableName: 'user',
referencedColumnNames: ['id'],
onDelete: 'CASCADE',
}),
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable('oauthConsent');
}
}
@@ -1,106 +0,0 @@
import {
type MigrationInterface,
type QueryRunner,
Table,
TableColumn,
TableForeignKey,
TableIndex,
} from 'typeorm';
export class CreateOauthRefreshToken1777217895075 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.createTable(
new Table({
name: 'oauthRefreshToken',
columns: [
{
name: 'id',
type: 'text',
isPrimary: true,
},
{
name: 'token',
type: 'text',
isUnique: true,
},
{
name: 'clientId',
type: 'text',
},
{
name: 'sessionId',
type: 'text',
isNullable: true,
},
{
name: 'userId',
type: 'text',
},
{
name: 'referenceId',
type: 'text',
isNullable: true,
},
{
name: 'expiresAt',
type: 'datetime',
isNullable: true,
},
{
name: 'createdAt',
type: 'datetime',
isNullable: true,
},
{
name: 'revoked',
type: 'datetime',
isNullable: true,
},
{
name: 'authTime',
type: 'datetime',
isNullable: true,
},
{
name: 'scopes',
type: 'text',
},
],
}),
);
await queryRunner.createForeignKey(
'oauthRefreshToken',
new TableForeignKey({
columnNames: ['clientId'],
referencedTableName: 'oauthClient',
referencedColumnNames: ['clientId'],
onDelete: 'CASCADE',
}),
);
await queryRunner.createForeignKey(
'oauthRefreshToken',
new TableForeignKey({
columnNames: ['sessionId'],
referencedTableName: 'session',
referencedColumnNames: ['id'],
onDelete: 'SET NULL',
}),
);
await queryRunner.createForeignKey(
'oauthRefreshToken',
new TableForeignKey({
columnNames: ['userId'],
referencedTableName: 'user',
referencedColumnNames: ['id'],
onDelete: 'CASCADE',
}),
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable('oauthRefreshToken');
}
}

Some files were not shown because too many files have changed in this diff Show More