Compare commits

..

42 Commits

Author SHA1 Message Date
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
306 changed files with 1836 additions and 190518 deletions
+1
View File
@@ -62,6 +62,7 @@ body:
- Docker (standalone) - Docker (standalone)
- Kubernetes / Helm - Kubernetes / Helm
- Unraid template - Unraid template
- Proxmox Community Script
- Sources - Sources
- Other - Other
validations: validations:
@@ -26,6 +26,9 @@ jobs:
const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000); const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
for (const pull of pulls) { 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'); const hasLabel = pull.labels.some(l => l.name === 'wrong-base-branch');
if (!hasLabel) continue; if (!hasLabel) continue;
+4 -1
View File
@@ -7,7 +7,10 @@ on:
- 'docs/**' - 'docs/**'
- '**/*.md' - '**/*.md'
- 'wiki/**' - 'wiki/**'
- '.github/workflows/wiki.yml' - '.github/workflows/**'
- '.github/ISSUE_TEMPLATE/**'
- '.github/FUNDING.yml'
- '.github/PULL_REQUEST_TEMPLATE.md'
workflow_dispatch: workflow_dispatch:
inputs: inputs:
bump: bump:
@@ -21,6 +21,12 @@ jobs:
const labels = context.payload.pull_request.labels.map(l => l.name); const labels = context.payload.pull_request.labels.map(l => l.name);
const prNumber = context.payload.pull_request.number; 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 the base was fixed, remove the label and let it through
if (base !== 'main') { if (base !== 'main') {
if (labels.includes('wrong-base-branch')) { if (labels.includes('wrong-base-branch')) {
+2 -2
View File
@@ -1,5 +1,5 @@
apiVersion: v2 apiVersion: v2
name: trek name: trek
version: 3.0.9 version: 3.0.12
description: Minimal Helm chart for TREK app description: Minimal Helm chart for TREK app
appVersion: "3.0.9" appVersion: "3.0.12"
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "trek-client", "name": "trek-client",
"version": "3.0.9", "version": "3.0.12",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "trek-client", "name": "trek-client",
"version": "3.0.9", "version": "3.0.12",
"dependencies": { "dependencies": {
"@react-pdf/renderer": "^4.3.2", "@react-pdf/renderer": "^4.3.2",
"axios": "^1.6.7", "axios": "^1.6.7",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "trek-client", "name": "trek-client",
"version": "3.0.9", "version": "3.0.12",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+1 -1
View File
@@ -58,7 +58,7 @@ function ProtectedRoute({ children, adminRequired = false, addonId }: ProtectedR
} }
if (!isAuthenticated) { 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 /> 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') { if (error.response?.status === 401 && (error.response?.data as { code?: string } | undefined)?.code === 'AUTH_REQUIRED') {
const { pathname } = window.location const { pathname } = window.location
if (!isAuthPublicPath(pathname)) { 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) window.location.href = '/login?redirect=' + encodeURIComponent(currentPath)
} }
} }
+73 -46
View File
@@ -1,7 +1,7 @@
import ReactDOM from 'react-dom' import ReactDOM from 'react-dom'
import { useState, useCallback, useRef, useEffect } from 'react' import { useState, useCallback, useRef, useEffect } from 'react'
import { useDropzone } from 'react-dropzone' 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 { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
import { filesApi } from '../../api/client' 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 { interface FileManagerProps {
files?: TripFile[] files?: TripFile[]
onUpload: (fd: FormData) => Promise<any> 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}`} /> <SourceBadge key={p.id} icon={MapPin} label={`${t('files.sourcePlan')} · ${p.name}`} />
))} ))}
{linkedReservations.map(r => ( {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 && ( {file.note_id && (
<SourceBadge icon={StickyNote} label={t('files.sourceCollab') || 'Collab Notes'} /> <SourceBadge icon={StickyNote} label={t('files.sourceCollab') || 'Collab Notes'} />
@@ -673,52 +684,68 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
</div> </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 && ( const bookingsSection = reservations.length > 0 && (
<div style={{ flex: 1, minWidth: 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 }}> {bookingReservations.length > 0 && (
{t('files.assignBooking')} <>
</div> <div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', padding: '8px 10px 4px', textTransform: 'uppercase', letterSpacing: 0.5 }}>
{reservations.map(r => { {t('files.assignBooking')}
const isLinked = file.reservation_id === r.id || (file.linked_reservation_ids || []).includes(r.id) </div>
return ( {bookingReservations.map(reservationBtn)}
<button key={r.id} onClick={async () => { </>
if (isLinked) { )}
// Unlink: if primary reservation_id, clear it; if via file_links, remove link {transportReservations.length > 0 && (
if (file.reservation_id === r.id) { <>
await handleAssign(file.id, { reservation_id: null }) <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 }}>
} else { {t('files.assignTransport')}
try { </div>
const linksRes = await filesApi.getLinks(tripId, file.id) {transportReservations.map(reservationBtn)}
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>
)
})}
</div> </div>
) )
+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 { 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 { accommodationsApi, mapsApi } from '../../api/client'
import type { Trip, Day, Place, Category, AssignmentsMap, DayNotesMap } from '../../types' import type { Trip, Day, Place, Category, AssignmentsMap, DayNotesMap } from '../../types'
import { isDayInAccommodationRange, getDayOrder } from '../../utils/dayOrder'
function renderLucideIcon(icon:LucideIcon, props = {}) { function renderLucideIcon(icon:LucideIcon, props = {}) {
if (!_renderToStaticMarkup) return '' if (!_renderToStaticMarkup) return ''
@@ -285,8 +286,12 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
}).join('') }).join('')
const accommodationsForDay = (accommodations.accommodations || []).filter(a => 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) day ? isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days) : false
).sort((a, b) => a.start_day_id - b.start_day_id) ).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 accommodationDetails = accommodationsForDay.map(item => {
const isCheckIn = day.id === item.start_day_id const isCheckIn = day.id === item.start_day_id
@@ -892,6 +892,277 @@ describe('DayDetailPanel', () => {
expect(screen.getByText(/June|15/i)).toBeInTheDocument(); 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 () => { it('FE-PLANNER-DAYDETAIL-040: 12h time format renders reservation time with AM/PM', async () => {
seedStore(useSettingsStore, { seedStore(useSettingsStore, {
settings: { time_format: '12h', temperature_unit: 'celsius', blur_booking_codes: false }, 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 { useSettingsStore } from '../../store/settingsStore'
import { getLocaleForLanguage, useTranslation } from '../../i18n' import { getLocaleForLanguage, useTranslation } from '../../i18n'
import type { Day, Place, Category, Reservation, AssignmentsMap } from '../../types' import type { Day, Place, Category, Reservation, AssignmentsMap } from '../../types'
import { isDayInAccommodationRange } from '../../utils/dayOrder'
const WEATHER_ICON_MAP = { const WEATHER_ICON_MAP = {
Clear: Sun, Clouds: Cloud, Rain: CloudRain, Drizzle: CloudDrizzle, Clear: Sun, Clouds: Cloud, Rain: CloudRain, Drizzle: CloudDrizzle,
@@ -99,7 +100,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
.then(data => { .then(data => {
setAccommodations(data.accommodations || []) setAccommodations(data.accommodations || [])
const allForDay = (data.accommodations || []).filter(a => 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) setDayAccommodations(allForDay)
setAccommodation(allForDay[0] || null) setAccommodation(allForDay[0] || null)
@@ -130,7 +131,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
setAccommodations(updated) setAccommodations(updated)
setAccommodation(newAcc) setAccommodation(newAcc)
setDayAccommodations(updated.filter(a => 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) setShowHotelPicker(false)
setHotelForm({ check_in: '', check_in_end: '', check_out: '', confirmation: '', place_id: null }) 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) const updated = accommodations.filter(a => a.id !== accommodation.id)
setAccommodations(updated) setAccommodations(updated)
setDayAccommodations(updated.filter(a => 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) setAccommodation(null)
onAccommodationChange?.() onAccommodationChange?.()
@@ -463,7 +464,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
<CustomSelect <CustomSelect
value={hotelDayRange.start} 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) => ({ options={days.map((d, i) => ({
value: d.id, value: d.id,
label: d.title || t('planner.dayN', { n: i + 1 }), 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 }}> <div style={{ flex: 1, minWidth: 0 }}>
<CustomSelect <CustomSelect
value={hotelDayRange.end} 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) => ({ options={days.map((d, i) => ({
value: d.id, value: d.id,
label: d.title || t('planner.dayN', { n: i + 1 }), 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 || [] const all = d.accommodations || []
setAccommodations(all) setAccommodations(all)
setDayAccommodations(all.filter(a => 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) setAccommodation(acc || null)
}) })
onAccommodationChange?.() onAccommodationChange?.()
@@ -21,6 +21,7 @@ import { useTripStore } from '../../store/tripStore'
import { useCanDo } from '../../store/permissionsStore' import { useCanDo } from '../../store/permissionsStore'
import { useSettingsStore } from '../../store/settingsStore' import { useSettingsStore } from '../../store/settingsStore'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
import { isDayInAccommodationRange } from '../../utils/dayOrder'
import { formatDate, formatTime, dayTotalCost, currencyDecimals } from '../../utils/formatters' import { formatDate, formatTime, dayTotalCost, currencyDecimals } from '../../utils/formatters'
import { useDayNotes } from '../../hooks/useDayNotes' import { useDayNotes } from '../../hooks/useDayNotes'
import Tooltip from '../shared/Tooltip' import Tooltip from '../shared/Tooltip'
@@ -397,7 +398,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
const getTransportForDay = (dayId: number) => { const getTransportForDay = (dayId: number) => {
const dayAssignmentIds = (assignments[String(dayId)] || []).map(a => a.id) const dayAssignmentIds = (assignments[String(dayId)] || []).map(a => a.id)
return reservations.filter(r => { 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 if (r.assignment_id && dayAssignmentIds.includes(r.assignment_id)) return false
const startDayId = r.day_id const startDayId = r.day_id
@@ -1214,7 +1215,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
</Tooltip> </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: check-out first, then ongoing stays, then check-in last
.sort((a, b) => { .sort((a, b) => {
const aIsOut = a.end_day_id === day.id && a.start_day_id !== day.id const aIsOut = a.end_day_id === day.id && a.start_day_id !== day.id
@@ -1725,7 +1726,11 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
return ( return (
<React.Fragment key={`transport-${res.id}-${day.id}`}> <React.Fragment key={`transport-${res.id}-${day.id}`}>
<div <div
onClick={() => canEditDays && onEditTransport?.(res)} onClick={() => {
if (!canEditDays) return
if (TRANSPORT_TYPES.has(res.type)) onEditTransport?.(res)
else onEditReservation?.(res)
}}
onDragOver={e => { onDragOver={e => {
e.preventDefault(); e.stopPropagation() e.preventDefault(); e.stopPropagation()
const rect = e.currentTarget.getBoundingClientRect() const rect = e.currentTarget.getBoundingClientRect()
@@ -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 { useState, useEffect, useMemo, useRef } from 'react'
import { Plane, Train, Car, Ship } from 'lucide-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 Modal from '../shared/Modal'
import CustomSelect from '../shared/CustomSelect' import CustomSelect from '../shared/CustomSelect'
import CustomTimePicker from '../shared/CustomTimePicker' import CustomTimePicker from '../shared/CustomTimePicker'
@@ -10,7 +11,9 @@ import { useToast } from '../shared/Toast'
import { useTripStore } from '../../store/tripStore' import { useTripStore } from '../../store/tripStore'
import { useAddonStore } from '../../store/addonStore' import { useAddonStore } from '../../store/addonStore'
import { formatDate } from '../../utils/formatters' 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 const TRANSPORT_TYPES = ['flight', 'train', 'car', 'cruise'] as const
type TransportType = typeof TRANSPORT_TYPES[number] type TransportType = typeof TRANSPORT_TYPES[number]
@@ -89,26 +92,36 @@ const defaultForm = {
interface TransportModalProps { interface TransportModalProps {
isOpen: boolean isOpen: boolean
onClose: () => void onClose: () => void
onSave: (data: Record<string, any>) => Promise<void> onSave: (data: Record<string, any>) => Promise<Reservation | undefined>
reservation: Reservation | null reservation: Reservation | null
days: Day[] days: Day[]
selectedDayId: number | null 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 { t, locale } = useTranslation()
const toast = useToast() const toast = useToast()
const isBudgetEnabled = useAddonStore(s => s.isEnabled('budget')) const isBudgetEnabled = useAddonStore(s => s.isEnabled('budget'))
const budgetItems = useTripStore(s => s.budgetItems) const budgetItems = useTripStore(s => s.budgetItems)
const loadFiles = useTripStore(s => s.loadFiles)
const budgetCategories = useMemo(() => { const budgetCategories = useMemo(() => {
const cats = new Set<string>() const cats = new Set<string>()
budgetItems.forEach(i => { if (i.category) cats.add(i.category) }) budgetItems.forEach(i => { if (i.category) cats.add(i.category) })
return Array.from(cats).sort() return Array.from(cats).sort()
}, [budgetItems]) }, [budgetItems])
const { id: tripId } = useParams<{ id: string }>()
const [form, setForm] = useState({ ...defaultForm }) const [form, setForm] = useState({ ...defaultForm })
const [isSaving, setIsSaving] = useState(false) const [isSaving, setIsSaving] = useState(false)
const [fromPick, setFromPick] = useState<EndpointPick>({}) const [fromPick, setFromPick] = useState<EndpointPick>({})
const [toPick, setToPick] = 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(() => { useEffect(() => {
if (!isOpen) return 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: parseFloat(form.price), category: form.budget_category || t(`reservations.type.${form.type}`) || 'Other' }
: { total_price: 0 } : { 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) { } catch (err: unknown) {
toast.error(err instanceof Error ? err.message : t('common.unknownError')) toast.error(err instanceof Error ? err.message : t('common.unknownError'))
} finally { } 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 = { const inputStyle = {
width: '100%', border: '1px solid var(--border-primary)', borderRadius: 10, width: '100%', border: '1px solid var(--border-primary)', borderRadius: 10,
padding: '8px 12px', fontSize: 13, fontFamily: 'inherit', 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 }} /> style={{ ...inputStyle, resize: 'none', lineHeight: 1.5 }} />
</div> </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 */} {/* Price + Budget Category */}
{isBudgetEnabled && ( {isBudgetEnabled && (
<> <>
+2
View File
@@ -1249,6 +1249,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'files.toast.deleteError': 'فشل حذف الملف', 'files.toast.deleteError': 'فشل حذف الملف',
'files.sourcePlan': 'خطة اليوم', 'files.sourcePlan': 'خطة اليوم',
'files.sourceBooking': 'الحجز', 'files.sourceBooking': 'الحجز',
'files.sourceTransport': 'النقل',
'files.attach': 'إرفاق', 'files.attach': 'إرفاق',
'files.pasteHint': 'يمكنك أيضًا لصق الصور من الحافظة (Ctrl+V)', 'files.pasteHint': 'يمكنك أيضًا لصق الصور من الحافظة (Ctrl+V)',
'files.trash': 'سلة المهملات', 'files.trash': 'سلة المهملات',
@@ -1261,6 +1262,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'files.assignTitle': 'إسناد ملف', 'files.assignTitle': 'إسناد ملف',
'files.assignPlace': 'المكان', 'files.assignPlace': 'المكان',
'files.assignBooking': 'الحجز', 'files.assignBooking': 'الحجز',
'files.assignTransport': 'النقل',
'files.unassigned': 'غير مسند', 'files.unassigned': 'غير مسند',
'files.unlink': 'إزالة الرابط', 'files.unlink': 'إزالة الرابط',
'files.toast.trashed': 'تم النقل إلى سلة المهملات', '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.toast.deleteError': 'Falha ao excluir arquivo',
'files.sourcePlan': 'Plano do dia', 'files.sourcePlan': 'Plano do dia',
'files.sourceBooking': 'Reserva', 'files.sourceBooking': 'Reserva',
'files.sourceTransport': 'Transporte',
'files.attach': 'Anexar', 'files.attach': 'Anexar',
'files.pasteHint': 'Você também pode colar imagens da área de transferência (Ctrl+V)', 'files.pasteHint': 'Você também pode colar imagens da área de transferência (Ctrl+V)',
'files.trash': 'Lixeira', 'files.trash': 'Lixeira',
@@ -1230,6 +1231,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'files.assignTitle': 'Atribuir arquivo', 'files.assignTitle': 'Atribuir arquivo',
'files.assignPlace': 'Lugar', 'files.assignPlace': 'Lugar',
'files.assignBooking': 'Reserva', 'files.assignBooking': 'Reserva',
'files.assignTransport': 'Transporte',
'files.unassigned': 'Não atribuído', 'files.unassigned': 'Não atribuído',
'files.unlink': 'Remover vínculo', 'files.unlink': 'Remover vínculo',
'files.toast.trashed': 'Movido para a lixeira', '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.toast.deleteError': 'Nepodařilo se smazat soubor',
'files.sourcePlan': 'Denní plán', 'files.sourcePlan': 'Denní plán',
'files.sourceBooking': 'Rezervace', 'files.sourceBooking': 'Rezervace',
'files.sourceTransport': 'Doprava',
'files.attach': 'Přiložit', 'files.attach': 'Přiložit',
'files.pasteHint': 'Můžete také vložit obrázek ze schránky (Ctrl+V)', 'files.pasteHint': 'Můžete také vložit obrázek ze schránky (Ctrl+V)',
'files.trash': 'Koš', 'files.trash': 'Koš',
@@ -1259,6 +1260,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'files.assignTitle': 'Přiřadit soubor', 'files.assignTitle': 'Přiřadit soubor',
'files.assignPlace': 'Místo', 'files.assignPlace': 'Místo',
'files.assignBooking': 'Rezervace', 'files.assignBooking': 'Rezervace',
'files.assignTransport': 'Doprava',
'files.unassigned': 'Nepřiřazeno', 'files.unassigned': 'Nepřiřazeno',
'files.unlink': 'Zrušit propojení', 'files.unlink': 'Zrušit propojení',
'files.toast.trashed': 'Přesunuto do koše', '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.toast.deleteError': 'Fehler beim Löschen der Datei',
'files.sourcePlan': 'Tagesplan', 'files.sourcePlan': 'Tagesplan',
'files.sourceBooking': 'Buchung', 'files.sourceBooking': 'Buchung',
'files.sourceTransport': 'Transport',
'files.attach': 'Anhängen', 'files.attach': 'Anhängen',
'files.pasteHint': 'Du kannst auch Bilder aus der Zwischenablage einfügen (Strg+V)', 'files.pasteHint': 'Du kannst auch Bilder aus der Zwischenablage einfügen (Strg+V)',
'files.trash': 'Papierkorb', 'files.trash': 'Papierkorb',
@@ -1263,6 +1264,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'files.assignTitle': 'Datei zuweisen', 'files.assignTitle': 'Datei zuweisen',
'files.assignPlace': 'Ort', 'files.assignPlace': 'Ort',
'files.assignBooking': 'Buchung', 'files.assignBooking': 'Buchung',
'files.assignTransport': 'Transport',
'files.unassigned': 'Nicht zugewiesen', 'files.unassigned': 'Nicht zugewiesen',
'files.unlink': 'Verknüpfung entfernen', 'files.unlink': 'Verknüpfung entfernen',
'files.toast.trashed': 'In den Papierkorb verschoben', '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.toast.deleteError': 'Failed to delete file',
'files.sourcePlan': 'Day Plan', 'files.sourcePlan': 'Day Plan',
'files.sourceBooking': 'Booking', 'files.sourceBooking': 'Booking',
'files.sourceTransport': 'Transport',
'files.attach': 'Attach', 'files.attach': 'Attach',
'files.pasteHint': 'You can also paste images from clipboard (Ctrl+V)', 'files.pasteHint': 'You can also paste images from clipboard (Ctrl+V)',
'files.trash': 'Trash', 'files.trash': 'Trash',
@@ -1334,6 +1335,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'files.assignTitle': 'Assign File', 'files.assignTitle': 'Assign File',
'files.assignPlace': 'Place', 'files.assignPlace': 'Place',
'files.assignBooking': 'Booking', 'files.assignBooking': 'Booking',
'files.assignTransport': 'Transport',
'files.unassigned': 'Unassigned', 'files.unassigned': 'Unassigned',
'files.unlink': 'Remove link', 'files.unlink': 'Remove link',
'files.toast.trashed': 'Moved to trash', '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.toast.deleteError': 'No se pudo eliminar el archivo',
'files.sourcePlan': 'Plan diario', 'files.sourcePlan': 'Plan diario',
'files.sourceBooking': 'Reserva', 'files.sourceBooking': 'Reserva',
'files.sourceTransport': 'Transporte',
'files.attach': 'Adjuntar', 'files.attach': 'Adjuntar',
'files.pasteHint': 'También puedes pegar imágenes desde el portapapeles (Ctrl+V)', '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.assignTitle': 'Asignar archivo',
'files.assignPlace': 'Lugar', 'files.assignPlace': 'Lugar',
'files.assignBooking': 'Reserva', 'files.assignBooking': 'Reserva',
'files.assignTransport': 'Transporte',
'files.unassigned': 'Sin asignar', 'files.unassigned': 'Sin asignar',
'files.unlink': 'Eliminar vínculo', 'files.unlink': 'Eliminar vínculo',
'files.noteLabel': 'Nota', 'files.noteLabel': 'Nota',
+2
View File
@@ -1245,6 +1245,7 @@ const fr: Record<string, string> = {
'files.toast.deleteError': 'Impossible de supprimer le fichier', 'files.toast.deleteError': 'Impossible de supprimer le fichier',
'files.sourcePlan': 'Plan du jour', 'files.sourcePlan': 'Plan du jour',
'files.sourceBooking': 'Réservation', 'files.sourceBooking': 'Réservation',
'files.sourceTransport': 'Transport',
'files.attach': 'Joindre', 'files.attach': 'Joindre',
'files.pasteHint': 'Vous pouvez aussi coller des images depuis le presse-papiers (Ctrl+V)', 'files.pasteHint': 'Vous pouvez aussi coller des images depuis le presse-papiers (Ctrl+V)',
'files.trash': 'Corbeille', 'files.trash': 'Corbeille',
@@ -1257,6 +1258,7 @@ const fr: Record<string, string> = {
'files.assignTitle': 'Assigner le fichier', 'files.assignTitle': 'Assigner le fichier',
'files.assignPlace': 'Lieu', 'files.assignPlace': 'Lieu',
'files.assignBooking': 'Réservation', 'files.assignBooking': 'Réservation',
'files.assignTransport': 'Transport',
'files.unassigned': 'Non attribué', 'files.unassigned': 'Non attribué',
'files.unlink': 'Supprimer le lien', 'files.unlink': 'Supprimer le lien',
'files.toast.trashed': 'Déplacé dans la corbeille', '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.toast.deleteError': 'Nem sikerült törölni a fájlt',
'files.sourcePlan': 'Napi terv', 'files.sourcePlan': 'Napi terv',
'files.sourceBooking': 'Foglalás', 'files.sourceBooking': 'Foglalás',
'files.sourceTransport': 'Közlekedés',
'files.attach': 'Csatolás', 'files.attach': 'Csatolás',
'files.pasteHint': 'Képeket a vágólapról is beillesztheted (Ctrl+V)', 'files.pasteHint': 'Képeket a vágólapról is beillesztheted (Ctrl+V)',
'files.trash': 'Kuka', 'files.trash': 'Kuka',
@@ -1258,6 +1259,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'files.assignTitle': 'Fájl hozzárendelése', 'files.assignTitle': 'Fájl hozzárendelése',
'files.assignPlace': 'Hely', 'files.assignPlace': 'Hely',
'files.assignBooking': 'Foglalás', 'files.assignBooking': 'Foglalás',
'files.assignTransport': 'Közlekedés',
'files.unassigned': 'Nincs hozzárendelve', 'files.unassigned': 'Nincs hozzárendelve',
'files.unlink': 'Kapcsolat eltávolítása', 'files.unlink': 'Kapcsolat eltávolítása',
'files.toast.trashed': 'Kukába helyezve', '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.toast.deleteError': 'Gagal menghapus file',
'files.sourcePlan': 'Rencana Harian', 'files.sourcePlan': 'Rencana Harian',
'files.sourceBooking': 'Pemesanan', 'files.sourceBooking': 'Pemesanan',
'files.sourceTransport': 'Transportasi',
'files.attach': 'Lampirkan', 'files.attach': 'Lampirkan',
'files.pasteHint': 'Kamu juga bisa menempel gambar dari clipboard (Ctrl+V)', 'files.pasteHint': 'Kamu juga bisa menempel gambar dari clipboard (Ctrl+V)',
'files.trash': 'Sampah', 'files.trash': 'Sampah',
@@ -1318,6 +1319,7 @@ const id: Record<string, string | { name: string; category: string }[]> = {
'files.assignTitle': 'Tugaskan File', 'files.assignTitle': 'Tugaskan File',
'files.assignPlace': 'Tempat', 'files.assignPlace': 'Tempat',
'files.assignBooking': 'Pemesanan', 'files.assignBooking': 'Pemesanan',
'files.assignTransport': 'Transportasi',
'files.unassigned': 'Tidak ditugaskan', 'files.unassigned': 'Tidak ditugaskan',
'files.unlink': 'Hapus tautan', 'files.unlink': 'Hapus tautan',
'files.toast.trashed': 'Dipindahkan ke sampah', '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.toast.deleteError': 'Impossibile eliminare il file',
'files.sourcePlan': 'Programma giornaliero', 'files.sourcePlan': 'Programma giornaliero',
'files.sourceBooking': 'Prenotazione', 'files.sourceBooking': 'Prenotazione',
'files.sourceTransport': 'Trasporto',
'files.attach': 'Allega', 'files.attach': 'Allega',
'files.pasteHint': 'Puoi anche incollare immagini dagli appunti (Ctrl+V)', 'files.pasteHint': 'Puoi anche incollare immagini dagli appunti (Ctrl+V)',
'files.trash': 'Cestino', 'files.trash': 'Cestino',
@@ -1258,6 +1259,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'files.assignTitle': 'Assegna file', 'files.assignTitle': 'Assegna file',
'files.assignPlace': 'Luogo', 'files.assignPlace': 'Luogo',
'files.assignBooking': 'Prenotazione', 'files.assignBooking': 'Prenotazione',
'files.assignTransport': 'Trasporto',
'files.unassigned': 'Non assegnato', 'files.unassigned': 'Non assegnato',
'files.unlink': 'Rimuovi collegamento', 'files.unlink': 'Rimuovi collegamento',
'files.toast.trashed': 'Spostato nel cestino', '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.toast.deleteError': 'Bestand verwijderen mislukt',
'files.sourcePlan': 'Dagplan', 'files.sourcePlan': 'Dagplan',
'files.sourceBooking': 'Boeking', 'files.sourceBooking': 'Boeking',
'files.sourceTransport': 'Transport',
'files.attach': 'Bijvoegen', 'files.attach': 'Bijvoegen',
'files.pasteHint': 'Je kunt ook afbeeldingen plakken vanuit het klembord (Ctrl+V)', 'files.pasteHint': 'Je kunt ook afbeeldingen plakken vanuit het klembord (Ctrl+V)',
'files.trash': 'Prullenbak', 'files.trash': 'Prullenbak',
@@ -1257,6 +1258,7 @@ const nl: Record<string, string> = {
'files.assignTitle': 'Bestand toewijzen', 'files.assignTitle': 'Bestand toewijzen',
'files.assignPlace': 'Plaats', 'files.assignPlace': 'Plaats',
'files.assignBooking': 'Boeking', 'files.assignBooking': 'Boeking',
'files.assignTransport': 'Transport',
'files.unassigned': 'Niet toegewezen', 'files.unassigned': 'Niet toegewezen',
'files.unlink': 'Koppeling verwijderen', 'files.unlink': 'Koppeling verwijderen',
'files.toast.trashed': 'Naar prullenbak verplaatst', '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.toast.deleteError': 'Nie udało się usunąć pliku',
'files.sourcePlan': 'Plan dni', 'files.sourcePlan': 'Plan dni',
'files.sourceBooking': 'Rezerwacje', 'files.sourceBooking': 'Rezerwacje',
'files.sourceTransport': 'Transport',
'files.attach': 'Załącz', 'files.attach': 'Załącz',
'files.pasteHint': 'Możesz również wkleić obrazki ze schowka (Ctrl+V)', 'files.pasteHint': 'Możesz również wkleić obrazki ze schowka (Ctrl+V)',
'files.trash': 'Kosz', 'files.trash': 'Kosz',
@@ -1209,6 +1210,7 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'files.assignTitle': 'Przypisz plik', 'files.assignTitle': 'Przypisz plik',
'files.assignPlace': 'Miejsce', 'files.assignPlace': 'Miejsce',
'files.assignBooking': 'Rezerwacja', 'files.assignBooking': 'Rezerwacja',
'files.assignTransport': 'Transport',
'files.unassigned': 'Nieprzypisane', 'files.unassigned': 'Nieprzypisane',
'files.unlink': 'Usuń link', 'files.unlink': 'Usuń link',
'files.toast.trashed': 'Przeniesiono do kosza', 'files.toast.trashed': 'Przeniesiono do kosza',
+2
View File
@@ -1245,6 +1245,7 @@ const ru: Record<string, string> = {
'files.toast.deleteError': 'Не удалось удалить файл', 'files.toast.deleteError': 'Не удалось удалить файл',
'files.sourcePlan': 'План дня', 'files.sourcePlan': 'План дня',
'files.sourceBooking': 'Бронирование', 'files.sourceBooking': 'Бронирование',
'files.sourceTransport': 'Транспорт',
'files.attach': 'Прикрепить', 'files.attach': 'Прикрепить',
'files.pasteHint': 'Также можно вставить изображения из буфера обмена (Ctrl+V)', 'files.pasteHint': 'Также можно вставить изображения из буфера обмена (Ctrl+V)',
'files.trash': 'Корзина', 'files.trash': 'Корзина',
@@ -1257,6 +1258,7 @@ const ru: Record<string, string> = {
'files.assignTitle': 'Назначить файл', 'files.assignTitle': 'Назначить файл',
'files.assignPlace': 'Место', 'files.assignPlace': 'Место',
'files.assignBooking': 'Бронирование', 'files.assignBooking': 'Бронирование',
'files.assignTransport': 'Транспорт',
'files.unassigned': 'Не назначен', 'files.unassigned': 'Не назначен',
'files.unlink': 'Удалить связь', 'files.unlink': 'Удалить связь',
'files.toast.trashed': 'Перемещено в корзину', 'files.toast.trashed': 'Перемещено в корзину',
+2
View File
@@ -1245,6 +1245,7 @@ const zh: Record<string, string> = {
'files.toast.deleteError': '删除文件失败', 'files.toast.deleteError': '删除文件失败',
'files.sourcePlan': '日程计划', 'files.sourcePlan': '日程计划',
'files.sourceBooking': '预订', 'files.sourceBooking': '预订',
'files.sourceTransport': '交通',
'files.attach': '附加', 'files.attach': '附加',
'files.pasteHint': '也可以从剪贴板粘贴图片 (Ctrl+V)', 'files.pasteHint': '也可以从剪贴板粘贴图片 (Ctrl+V)',
'files.trash': '回收站', 'files.trash': '回收站',
@@ -1257,6 +1258,7 @@ const zh: Record<string, string> = {
'files.assignTitle': '分配文件', 'files.assignTitle': '分配文件',
'files.assignPlace': '地点', 'files.assignPlace': '地点',
'files.assignBooking': '预订', 'files.assignBooking': '预订',
'files.assignTransport': '交通',
'files.unassigned': '未分配', 'files.unassigned': '未分配',
'files.unlink': '移除关联', 'files.unlink': '移除关联',
'files.toast.trashed': '已移至回收站', 'files.toast.trashed': '已移至回收站',
+2
View File
@@ -1305,6 +1305,7 @@ const zhTw: Record<string, string> = {
'files.toast.deleteError': '刪除檔案失敗', 'files.toast.deleteError': '刪除檔案失敗',
'files.sourcePlan': '日程計劃', 'files.sourcePlan': '日程計劃',
'files.sourceBooking': '預訂', 'files.sourceBooking': '預訂',
'files.sourceTransport': '交通',
'files.attach': '附加', 'files.attach': '附加',
'files.pasteHint': '也可以從剪貼簿貼上圖片 (Ctrl+V)', 'files.pasteHint': '也可以從剪貼簿貼上圖片 (Ctrl+V)',
'files.trash': '回收站', 'files.trash': '回收站',
@@ -1317,6 +1318,7 @@ const zhTw: Record<string, string> = {
'files.assignTitle': '分配檔案', 'files.assignTitle': '分配檔案',
'files.assignPlace': '地點', 'files.assignPlace': '地點',
'files.assignBooking': '預訂', 'files.assignBooking': '預訂',
'files.assignTransport': '交通',
'files.unassigned': '未分配', 'files.unassigned': '未分配',
'files.unlink': '移除關聯', 'files.unlink': '移除關聯',
'files.toast.trashed': '已移至回收站', 'files.toast.trashed': '已移至回收站',
@@ -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' return '/dashboard'
}, []) }, [])
useEffect(() => {
if (redirectTarget !== '/dashboard') {
sessionStorage.setItem('oidc_redirect', redirectTarget)
}
}, [redirectTarget])
useEffect(() => { useEffect(() => {
const params = new URLSearchParams(window.location.search) const params = new URLSearchParams(window.location.search)
@@ -83,7 +89,9 @@ export default function LoginPage(): React.ReactElement {
window.history.replaceState({}, '', '/login') window.history.replaceState({}, '', '/login')
if (data.token) { if (data.token) {
await loadUser() await loadUser()
navigate('/dashboard', { replace: true }) const savedRedirect = sessionStorage.getItem('oidc_redirect') || '/dashboard'
sessionStorage.removeItem('oidc_redirect')
navigate(savedRedirect, { replace: true })
} else { } else {
setError(data.error || t('login.oidcFailed')) setError(data.error || t('login.oidcFailed'))
} }
@@ -104,6 +112,7 @@ export default function LoginPage(): React.ReactElement {
invalid_state: t('login.oidc.invalidState'), invalid_state: t('login.oidc.invalidState'),
} }
setError(errorMessages[oidcError] || oidcError) setError(errorMessages[oidcError] || oidcError)
sessionStorage.removeItem('oidc_redirect')
window.history.replaceState({}, '', '/login') window.history.replaceState({}, '', '/login')
return return
} }
+1 -1
View File
@@ -124,7 +124,7 @@ export default function OAuthAuthorizePage(): React.ReactElement {
} }
function handleLoginRedirect() { function handleLoginRedirect() {
const next = '/oauth/authorize?' + params.toString() const next = '/oauth/authorize?' + params.toString() + window.location.hash
window.location.href = '/login?redirect=' + encodeURIComponent(next) 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 { createElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server' 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 { 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_TYPES = new Set(['flight', 'train', 'bus', 'car', 'cruise'])
const TRANSPORT_ICONS = { flight: Plane, train: Train, bus: Bus, car: Car, cruise: Ship } 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 da = assignments[String(day.id)] || []
const notes = (dayNotes[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 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 = [ const merged = [
...da.map((a: any) => ({ type: 'place', k: a.order_index, data: a })), ...da.map((a: any) => ({ type: 'place', k: a.order_index, data: a })),
+11 -6
View File
@@ -666,15 +666,20 @@ export default function TripPlannerPage(): React.ReactElement | null {
const handleSaveTransport = async (data) => { const handleSaveTransport = async (data) => {
try { try {
if (editingTransport) { if (editingTransport) {
await tripActions.updateReservation(tripId, editingTransport.id, data) const r = await tripActions.updateReservation(tripId, editingTransport.id, data)
toast.success(t('trip.toast.reservationUpdated')) toast.success(t('trip.toast.reservationUpdated'))
setShowTransportModal(false)
setEditingTransport(null)
setTransportModalDayId(null)
return r
} else { } else {
await tripActions.addReservation(tripId, data) const r = await tripActions.addReservation(tripId, data)
toast.success(t('trip.toast.reservationAdded')) 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')) } } catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
} }
@@ -1194,7 +1199,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} /> <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} /> <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} /> <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 <ConfirmDialog
isOpen={!!deletePlaceId} isOpen={!!deletePlaceId}
onClose={() => setDeletePlaceId(null)} onClose={() => setDeletePlaceId(null)}
+1
View File
@@ -31,6 +31,7 @@ export interface Trip {
export interface Day { export interface Day {
id: number id: number
trip_id: number trip_id: number
day_number?: number
date: string date: string
title: string | null title: string | null
notes: 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');
}
}
@@ -1,99 +0,0 @@
import {
type MigrationInterface,
type QueryRunner,
Table,
TableColumn,
TableForeignKey,
TableIndex,
} from 'typeorm';
export class CreatePasskey1777217895075 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 CreateSession1777217895075 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 CreateUser1777217895075 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 CreateVerification1777217895075 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');
}
}
-8
View File
@@ -1,8 +0,0 @@
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule, { bodyParser: false });
await app.listen(process.env.PORT ?? 3001);
}
bootstrap();
@@ -1,34 +0,0 @@
import { Column, Entity, JoinColumn, ManyToOne } from 'typeorm';
import { IntBaseEntity } from '../base/BaseEntity';
import { SqliteUsers } from '../old-entities/SqliteUsers';
@Entity('bucket_list')
export class BucketList extends IntBaseEntity {
@Column({ name: 'name' })
name: string;
@Column({ name: 'lat', nullable: true })
lat: number | null;
@Column({ name: 'lng', nullable: true })
lng: number | null;
@Column({ name: 'country_code', nullable: true })
countryCode: string | null;
@Column('text', { name: 'notes', nullable: true })
notes: string | null;
@Column({
name: 'target_date',
nullable: true,
default: null,
})
targetDate: string | null;
@ManyToOne(() => SqliteUsers, (users) => users.bucketLists, {
onDelete: 'CASCADE',
})
@JoinColumn([{ name: 'user_id', referencedColumnName: 'id' }])
user: SqliteUsers;
}
@@ -1,15 +0,0 @@
import { Column, Entity, JoinColumn, ManyToOne } from 'typeorm';
import { IntBaseEntity } from '../base/BaseEntity';
import { SqliteUsers } from '../old-entities/SqliteUsers';
@Entity('visited_countries')
export class VisitedCountries extends IntBaseEntity {
@Column('text', { name: 'country_code' })
countryCode: string;
@ManyToOne(() => SqliteUsers, (users) => users.visitedCountries, {
onDelete: 'CASCADE',
})
@JoinColumn([{ name: 'user_id', referencedColumnName: 'id' }])
user: SqliteUsers;
}
@@ -1,22 +0,0 @@
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { IntBaseEntity } from '../base/BaseEntity';
import { SqliteUsers } from '../old-entities/SqliteUsers';
@Index('idx_visited_regions_country', ['countryCode'], {})
@Entity('visited_regions')
export class VisitedRegions extends IntBaseEntity {
@Column({ name: 'region_code' })
regionCode: string;
@Column({ name: 'region_name' })
regionName: string;
@Column({ name: 'country_code' })
countryCode: string;
@ManyToOne(() => SqliteUsers, (users) => users.visitedRegions, {
onDelete: 'CASCADE',
})
@JoinColumn([{ name: 'user_id', referencedColumnName: 'id' }])
user: SqliteUsers;
}
@@ -1,59 +0,0 @@
/**
* AUTOGENERATED
*/
import {
Column,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryColumn,
} from 'typeorm';
import { User } from './User';
@Entity('account')
export class Account {
@PrimaryColumn('text')
id: string;
@Column('text', { name: 'accountId' })
accountId: string;
@Column('text', { name: 'providerId' })
providerId: string;
@Index('account_userId_idx')
@Column('text', { name: 'userId' })
userId: string;
@ManyToOne(() => User, { onDelete: 'CASCADE', nullable: false })
@JoinColumn({ name: 'userId', referencedColumnName: 'id' })
user: User;
@Column('text', { name: 'accessToken', nullable: true })
accessToken: string | null;
@Column('text', { name: 'refreshToken', nullable: true })
refreshToken: string | null;
@Column('text', { name: 'idToken', nullable: true })
idToken: string | null;
@Column({ type: 'timestamp', name: 'accessTokenExpiresAt', nullable: true })
accessTokenExpiresAt: Date | null;
@Column({ type: 'timestamp', name: 'refreshTokenExpiresAt', nullable: true })
refreshTokenExpiresAt: Date | null;
@Column('text', { name: 'scope', nullable: true })
scope: string | null;
@Column('text', { name: 'password', nullable: true })
password: string | null;
@Column({ type: 'timestamp', name: 'createdAt' })
createdAt: Date;
@Column({ type: 'timestamp', name: 'updatedAt' })
updatedAt: Date;
}
@@ -1,22 +0,0 @@
/**
* AUTOGENERATED
*/
import { Column, Entity, PrimaryColumn } from 'typeorm';
@Entity('jwks')
export class Jwks {
@PrimaryColumn('text')
id: string;
@Column('text', { name: 'publicKey' })
publicKey: string;
@Column('text', { name: 'privateKey' })
privateKey: string;
@Column({ type: 'timestamp', name: 'createdAt' })
createdAt: Date;
@Column({ type: 'timestamp', name: 'expiresAt', nullable: true })
expiresAt: Date | null;
}
@@ -1,57 +0,0 @@
/**
* AUTOGENERATED
*/
import { Column, Entity, JoinColumn, ManyToOne, PrimaryColumn } from 'typeorm';
import { OauthClient } from './OauthClient';
import { OauthRefreshToken } from './OauthRefreshToken';
import { Session } from './Session';
import { User } from './User';
@Entity('oauthAccessToken')
export class OauthAccessToken {
@PrimaryColumn('text')
id: string;
@Column('text', { name: 'token', nullable: true, unique: true })
token: string | null;
@Column('text', { name: 'clientId' })
clientId: string;
@ManyToOne(() => OauthClient, { onDelete: 'CASCADE', nullable: false })
@JoinColumn({ name: 'clientId', referencedColumnName: 'clientId' })
client: OauthClient;
@Column('text', { name: 'sessionId', nullable: true })
sessionId: string | null;
@ManyToOne(() => Session, { onDelete: 'SET NULL', nullable: true })
@JoinColumn({ name: 'sessionId', referencedColumnName: 'id' })
session?: Session;
@Column('text', { name: 'userId', nullable: true })
userId: string | null;
@ManyToOne(() => User, { onDelete: 'CASCADE', nullable: true })
@JoinColumn({ name: 'userId', referencedColumnName: 'id' })
user?: User;
@Column('text', { name: 'referenceId', nullable: true })
referenceId: string | null;
@Column('text', { name: 'refreshId', nullable: true })
refreshId: string | null;
@ManyToOne(() => OauthRefreshToken, { onDelete: 'CASCADE', nullable: true })
@JoinColumn({ name: 'refreshId', referencedColumnName: 'id' })
refresh?: OauthRefreshToken;
@Column({ type: 'timestamp', name: 'expiresAt', nullable: true })
expiresAt: Date | null;
@Column({ type: 'timestamp', name: 'createdAt', nullable: true })
createdAt: Date | null;
@Column('text', { name: 'scopes' })
scopes: string;
}
@@ -1,56 +0,0 @@
/**
* AUTOGENERATED
*/
import {
Column,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryColumn,
} from 'typeorm';
import { User } from './User';
@Entity('oauthApplication')
export class OauthApplication {
@PrimaryColumn('text')
id: string;
@Column('text', { name: 'name', nullable: true })
name: string | null;
@Column('text', { name: 'icon', nullable: true })
icon: string | null;
@Column('text', { name: 'metadata', nullable: true })
metadata: string | null;
@Column('text', { name: 'clientId', nullable: true, unique: true })
clientId: string | null;
@Column('text', { name: 'clientSecret', nullable: true })
clientSecret: string | null;
@Column('text', { name: 'redirectUrls', nullable: true })
redirectUrls: string | null;
@Column('text', { name: 'type', nullable: true })
type: string | null;
@Column('boolean', { name: 'disabled', nullable: true, default: false })
disabled: boolean | null;
@Index('oauthApplication_userId_idx')
@Column('text', { name: 'userId', nullable: true })
userId: string | null;
@ManyToOne(() => User, { onDelete: 'CASCADE', nullable: true })
@JoinColumn({ name: 'userId', referencedColumnName: 'id' })
user?: User;
@Column({ type: 'timestamp', name: 'createdAt', nullable: true })
createdAt: Date | null;
@Column({ type: 'timestamp', name: 'updatedAt', nullable: true })
updatedAt: Date | null;
}

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