165 Commits

Author SHA1 Message Date
3dtours 03ee337e28 xóa các tập tin ảnh up public 2026-06-25 20:42:31 +07:00
3dtours 48ffd7e49b Thêm README.md cho dự án 2026-06-25 20:07:52 +07:00
3dtours 8df94bca26 fix: sửa lỗi hiển thị trên màn hình android 2026-06-25 17:11:19 +07:00
3dtours d2aced396f fix: bản đồ xoay mượt trên Iphone 2026-06-25 16:48:26 +07:00
3dtours 4e93722ba5 fix: bản đồ không zoom hay thu nhỏ được, không hiển thị nút vị trí của tôi trên bản đồ 2026-06-25 15:57:25 +07:00
3dtours 33a5996bee fix: Ngăn bản đồ tự động reset về trung tâm khi người dùng đã tương tác
- TourDetailPage: MapTourBounds chỉ fitBounds 1 lần cho mỗi lần dữ liệu lộ trình thay đổi thực sự (theo locKey). Nếu người dùng đã pan/zoom rồi thì không refit khi data reload.
- TourNavigationPage: FitBounds chỉ chạy khi người dùng CHƯA tương tác (hasInteractedRef). Sau khi người dùng pan/zoom/drag, bản đồ giữ nguyên vị trí.
- Cả hai bản đồ đều tôn trọng vị trí người dùng đã chọn, không reset về mặc định khi có thay đổi dữ liệu phụ.
2026-06-25 12:28:37 +07:00
3dtours 90475d4130 fix: Ngăn bản đồ ExploreMap tự động nhảy về vị trí người dùng khi thay đổi zoom trên mobile
- Tách userPos (dùng cho marker) và mapCenter (dùng cho center bản đồ) thành 2 state riêng biệt
- MapContainer dùng center={mapCenter} cố định thay vì center={userPos} bị GPS thay đổi liên tục
- Xóa component RecenterMap tự động re-center khi userPos thay đổi
- Thêm nút 'Vị trí của tôi' để người dùng chủ động nhấn mới nhảy bản đồ đến vị trí của mình
- requestGpsPosition cập nhật cả mapCenter lẫn store để map thực sự nhảy khi người dùng chủ động yêu cầu
- Các thao tác khác như click gợi ý tìm kiếm, click ảnh trên bản đồ cũng cập nhật mapCenter để map nhảy đúng vị trí
- Áp dụng cho ExploreMap.tsx - bản đồ chính khi người dùng mở app
2026-06-25 11:58:32 +07:00
3dtours 8e984e609b feat: Thêm nút định vị người dùng trên bản đồ điều hướng mobile
- Thêm nút 'Vị trí của tôi' ở góc dưới bên trái bản đồ
- Khi bật: map tự động center theo vị trí GPS của người dùng (dùng watchPosition)
- Cho phép thu phóng tự do ngay cả khi đang định vị
- Khi người dùng kéo thả bản đồ thủ công: tự động tắt chế độ định vị
- Nút hoạt động độc lập với nút la bàn (compass)
- Sửa import leaflet.css bị lỗi path
2026-06-25 11:34:09 +07:00
3dtours b48502c34a fix: Bản đồ không reset khi người dùng thay đổi zoom trên mobile
- Thêm MapInteractionWatcher lưu zoom/center khi người dùng tương tác
- MapSizeHandler dùng ResizeObserver thay vì setTimeout: chỉ gọi invalidateSize khi container thực sự thay đổi (>2px)
- Sau invalidateSize, tự động restore zoom mà người dùng đã chọn
- canFitRef đảm bảo fitBounds chỉ chạy 1 lần duy nhất khi route vừa load
- Áp dụng cho cả chế độ la bàn bật (xoay theo hướng) và tắt (hướng Bắc)
2026-06-25 11:22:29 +07:00
3dtours 978992057a fix: Luôn giữ kích thước bản đồ người dùng chọn khi tìm đường ngắn nhất
- Thay userInteractedRef bằng canFitRef - chỉ fitBounds đúng 1 lần khi route vừa load
- Sau lần fit đầu, canFitRef = false mãi mãi, không bao giờ reset khi route đổi
- FitBounds chỉ chạy khi canFitRef còn true VÀ compass đang tắt
- MapSizeHandler chỉ gọi invalidateSize() mà không setView để không kích hoạt zoomend/moveend
- Loại bỏ useEffect chết đăng ký zoomend/moveend ở page level (đã có trong MapRefSetter)
- Áp dụng cho cả 2 chế độ: compass bật (xoay theo hướng) và tắt (hướng Bắc)
2026-06-25 11:15:09 +07:00
3dtours ed0fb8dd64 fix: Giữ nguyên zoom/center của người dùng khi bản đồ resize trên mobile
- Thêm userInteractedRef để theo dõi người dùng đã zoom/drag chưa
- FitBounds chỉ chạy khi người dùng chưa tương tác
- MapSizeHandler lưu center/zoom trước khi invalidateSize, khôi phục sau nếu user đã tương tác
- MapRotationHandler xóa transform thay vì set rotate(0) khi compass tắt
- Ngăn map tự động reset về mức zoom ban đầu sau khi người dùng pinch-zoom
2026-06-25 11:08:13 +07:00
3dtours 828bc89890 fix: Sửa lỗi bản đồ mobile chỉ hiển thị kích thước mặc định và không zoom
- Xóa state viewportHeight phụ thuộc documentElement.clientHeight (không ổn định trên mobile)
- Xóa style dvh/-webkit-fill-available gây lỗi trên iOS Safari
- Đổi layout sang flex-1 thuần túy, không tính chiều cao bằng JS
- Xóa CSS transform khi rotation = 0 để tránh lỗi render trên mobile
- Tăng delay invalidateSize lên 100ms để Leaflet đo đúng kích thước container sau layout
2026-06-25 11:03:45 +07:00
3dtours b8bf4f88cc fix: Sửa lỗi bản đồ không full màn hình và không zoom được trên iOS
- Dùng viewportHeight từ JS thay vì CSS dvh để tránh vấn đề iOS Safari
- Thêm MapSizeHandler gọi invalidateSize() để Leaflet tính lại kích thước
- Bật scrollWheelZoom, touchZoom, doubleClickZoom, dragging, inertia
- Đặt maxZoom=20, minZoom=2 rõ ràng
- Loại bỏ max-width/overflow-x trong index.css để không ảnh hưởng trang khác
2026-06-25 10:33:01 +07:00
3dtours f74587376e feat: Tạo trang bản đồ chỉ đường độc lập TourNavigationPage
- Thêm trang TourNavigationPage.tsx hiển thị full màn hình với
  top-bar, bản đồ fullscreen và nút la bàn nổi ở góc dưới bên phải
- Cập nhật ItineraryTimeline.tsx: thay thế LocationNavigationModal
  bằng callback onOpenNavigationPage để chuyển đến trang mới
- Đăng ký trang tourNavigation trong App.tsx với navigationPayload
- Cập nhật TourDetailPage.tsx truyền onOpenNavigationPage xuống
- Thêm INSTALL_MAP.md hướng dẫn thực hiện tính năng
2026-06-25 10:17:23 +07:00
3dtours a135196cb5 fix: tính năng tìm đường ngắn nhất và chỉ đường cho người dùng 2026-06-24 22:05:41 +07:00
3dtours cfdcb573de feat: khi người dùng click vào nút đầu mỗi điểm để tìm đường đi trên bản đồ 2026-06-24 21:53:52 +07:00
3dtours f17215f236 feat: khi người dùng click vào nút Chi tiết hành trình thì sẽ tự cuộn đến mốc thời gian hiện tại 2026-06-24 21:25:04 +07:00
3dtours 5e60614588 feat: khi người dùng click vào nút Chi tiết hành trình thì sẽ dựa vào ngày của hệ thống mà chuyển đến Chặng đó 2026-06-24 21:17:26 +07:00
3dtours e385049652 fix: in đậm cho tiêu đề của cột khi xuất pdf 2026-06-24 20:52:17 +07:00
3dtours ef295e0994 fix: hiển thị ngày ở các chặng 2026-06-24 20:43:49 +07:00
3dtours c3382a422d fix: hiển thị ngày ở các chặng 2026-06-24 20:41:15 +07:00
3dtours 5d47e6d291 fix: lỗi nút xuất PDF và chèn link ở Địa điểm khi xuất PDF 2026-06-24 19:54:14 +07:00
3dtours 957ba6c72c fix: lỗi font Bold của xuất pdf 2026-06-24 18:17:42 +07:00
3dtours 4b551ccc31 fix: thêm múi giờ vào docker-compose.prod.yml để sửa lỗi hiển thị sai giờ 2026-06-24 17:45:02 +07:00
3dtours 9dc1faee6f fix: lưu ghi chú ở địa điểm vào ghi chú 2026-06-24 17:05:21 +07:00
3dtours b998833371 fix: thay đổi màu theme tổi của timeline hành trình 2026-06-24 10:40:59 +07:00
3dtours 13cd68707e fix: sửa lỗi hiển thị trong itineraryTimeline và MemberDashboard 2026-06-24 09:56:05 +07:00
3dtours f355b9471a fix: chọn camera hay thư viện 2026-06-23 18:42:56 +07:00
3dtours a64af5f9cf feat: apply theme colors to all pages and modals - implement light/dark theme CSS variables across UI components 2026-06-23 18:28:05 +07:00
3dtours 7182391241 fix: paste trực tiếp tọa độ vào kinh độ vĩ độ 2026-06-23 17:50:05 +07:00
3dtours 28ea9abccd fix: tạo tags cho ảnh public của guest 2026-06-23 16:01:46 +07:00
3dtours 9f41400bd8 fix: hiển thị 2 nút google sheets và pdf 2026-06-23 14:58:07 +07:00
3dtours 78c5754655 fix: lỗi xem ảnh trong Tour 2026-06-23 12:46:59 +07:00
3dtours 5145835c8a fix: gửi vị trí khẩn cấp trong Dashboard 2026-06-23 12:44:38 +07:00
3dtours 6b15e7ff02 fix: lỗi hiển thị trong itineraryTimeline 2026-06-23 11:40:41 +07:00
3dtours a65be48d36 fix: lướt xem ảnh ở màn hình index của người dùng public 2026-06-22 22:16:51 +07:00
3dtours ac91f6e4c8 fix: admin login khi close tự động logout 2026-06-22 20:22:13 +07:00
3dtours 09b1ee882d fix: login với Google Oauth lỗi token invite 2026-06-22 20:08:11 +07:00
3dtours acf867a375 fix: guest hiển thị lời mời này không tồn tại 2026-06-22 16:54:32 +07:00
3dtours dd2635a44d fix: guest reload page to accessed MemberDashboard 2 2026-06-22 15:00:42 +07:00
3dtours 29c19c6372 fix: guest reload page to accessed MemberDashboard 2026-06-22 12:45:29 +07:00
3dtours 01d6d7439f fix: admin empty thùng rác 2026-06-22 12:28:21 +07:00
3dtours 860395cb14 fix: guest and admin logic 2026-06-22 11:41:34 +07:00
3dtours b1a539235b fix: lỗi hiển thị ở frontend 2026-06-21 21:53:14 +07:00
3dtours 403c169ddd feat: tính năng chia sẻ khẩn cấp 2026-06-21 12:52:47 +07:00
3dtours 8d79cd76f6 feat: tíng năng trò chuyện giữa các thành viên và tin nhắn trực tiếp cho bạn bè 2026-06-21 09:46:37 +07:00
3dtours 392a4d4766 fix: khách mời join vào tour gán với tên thêm thủ công 2026-06-21 07:37:20 +07:00
3dtours 55fba75fda fix: khách mời join vào tour thông qua link mời 2026-06-21 07:11:26 +07:00
3dtours 36157bd53b bugs: lỗi khi người dùng link để tham gia tour nhưng không xuất hiện trong danh sách thành viên 2026-06-20 20:59:01 +07:00
3dtours 52706cab7d fix: thêm thành viên ngoài hệ thống vào Tour 2026-06-20 17:56:56 +07:00
3dtours ae97f061e8 fix: sửa lỗi hiển thị logo trên trang pdf 2026-06-20 17:02:26 +07:00
3dtours e34d197dd0 fix: sửa lỗi hiển thị thumbnail khi chia sẻ trên facebook 2026-06-20 14:44:31 +07:00
3dtours fdf41e05fc fix: sửa lỗi hiển thị trên browser của điện thoại 2026-06-20 12:19:30 +07:00
3dtours ae74035ad5 fix: cho phép người đã tải ảnh lên được quyền chỉnh sửa 2026-06-20 08:27:58 +07:00
3dtours ac11bb56db fix: bố cục lại cách hiển thị thông tin ảnh 2026-06-20 08:04:12 +07:00
3dtours 5081546cdf feat: cho phép người dùng chọn tọa độ trên bản đồ khi sửa ảnh 2026-06-20 07:40:50 +07:00
3dtours 9da8a9494d feat: cho phép người dùng chỉnh sửa thông tin ảnh đã upload 2026-06-20 07:37:01 +07:00
3dtours 4373ed684a fix: ảnh phải giữ nguyên hướng chụp 2026-06-20 07:28:28 +07:00
3dtours def0e0d0f9 fix: sửa lỗi các nút bị che dưới thanh địa chỉ của chrome, hỗ trợ iphone upload ảnh heic 2026-06-20 07:05:25 +07:00
3dtours 36e8658dad fix: sửa lỗi iphone ko thể upload 2026-06-19 22:49:51 +07:00
3dtours 6c1b77d7d2 feat: tải ảnh lên bằng tài khoản public và cho phép bình luận 2026-06-19 22:38:16 +07:00
3dtours 4640526e4b fix: thay đổi ảnh nền trang chủ 2026-06-19 12:38:17 +07:00
3dtours c7692ab9b6 fix: thay đổi giao diện trang chủ 2026-06-19 12:00:19 +07:00
3dtours 36fbbb9386 fix: remove leaflet liên kết trên bản đồ 2026-06-19 10:34:03 +07:00
3dtours ef11309399 feat: chuẩn bị backend cho tính năng anonymous user upload ảnh không cần đăng nhập 2026-06-19 08:46:59 +07:00
3dtours 6f2dd0fc01 fix: lỗi đọc nhầm .env ở thư mục backend 2026-06-19 08:13:35 +07:00
3dtours 623f0164c8 feat: cài đặt tính năng gửi mã OTP qua mail xác thực 2026-06-18 23:00:12 +07:00
3dtours 88b47099b1 feat: cài đặt tính năng gửi mã OTP qua mail xác thực 2026-06-18 23:00:03 +07:00
3dtours 659d2a0840 fix: hiển thị market chỉ hướng người dùng di chuyển trên iOS 2026-06-18 21:24:08 +07:00
3dtours 68950dba10 fix: hiển thị market chỉ hướng người dùng di chuyển 2026-06-18 10:51:47 +07:00
3dtours 3e215ae8e8 fix: xoay màn hình theo hướng người nhìn trong màn hình bản đồ đã mượt 2026-06-17 22:41:45 +07:00
3dtours e7b8c672e0 fix: xoay màn hình theo hướng người nhìn trong màn hình bản đồ đã mượt nhưng đến góc là xoay tròn 2026-06-17 22:36:05 +07:00
3dtours 2462850e2e fix: xoay màn hình theo hướng người nhìn trong màn hình bản đồ vẫn còn xoay tròn 2026-06-17 22:23:01 +07:00
3dtours abb61f65cd feat: xoay màn hình theo hướng người nhìn trong màn hình bản đồ vẫn còn lag 2026-06-17 22:20:57 +07:00
3dtours da32d59161 feat: nhấn nút toggle status full bản đồ, nhấn nút bản đồ thì giao diện cũ 2026-06-17 21:53:46 +07:00
3dtours 40b1c4f2ab feat: nhấn nút toggle status bản đồ sẽ hiển thị toàn màn hình 2026-06-17 21:41:43 +07:00
3dtours 55de887f87 feat: nhấn nút toggle status để chuyển đến bản đồ di chuyển 2026-06-17 21:30:41 +07:00
3dtours ffe3cb6ecd fix: tạm dừng thuật toán tìm đường đi 2026-06-17 21:16:31 +07:00
3dtours 69c67a1636 fix: sửa lại các tuyến đường đề xuất trên bản đồ 2026-06-17 20:49:30 +07:00
3dtours f181009fa5 fix: sửa lại các nút hiển thị trên màn hình gom lại thành 1 nút 2026-06-17 20:08:28 +07:00
3dtours b6551da147 fix: sửa lại cho phép chỉnh sửa thời gian bắt đầu và kết thúc của điểm xuất phát và điểm kết thúc 2026-06-17 19:46:55 +07:00
3dtours 2afb2971da fix: sửa lại hiển thị của các nút trên bản đồ 2026-06-17 19:37:29 +07:00
3dtours 14ffbb8657 fix: xóa bảng hiển thị mẹo trên bản đồ 2026-06-17 19:34:13 +07:00
3dtours fbe5ef873b feat: thêm tính năng hiển thị tooltip mẹo nhấn chuột phải 2026-06-17 19:30:58 +07:00
3dtours d6ee1dde74 feat: thêm tính năng nút la bàn để xoay map theo hướng Bắc hoặc theo hướng người dùng di chuyển 2026-06-17 19:19:29 +07:00
3dtours 288b40ad12 feat: thêm tính năng tìm tôi để tự động di chuyển bản đồ về vị trí của người dùng 2026-06-17 19:14:18 +07:00
3dtours 3a3296c340 feat: thêm tính năng hiển thị vị trí người dùng trên bản đồ 2026-06-17 18:27:52 +07:00
3dtours e664a3797e feat: thêm tính năng đường dành cho xe máy và đi bộ 2026-06-17 18:00:45 +07:00
3dtours 36418673db feat: thêm tính năng vẽ đường di chuyển trên bản đồ 2026-06-17 17:44:51 +07:00
3dtours 98e4a4a340 fix: lỗi không xuất hiện nút sửa cho điểm bắt đầu và điểm kết thúc 2026-06-17 17:39:53 +07:00
3dtours bfbbb66747 fix: lỗi bong bóng Tour vẫn xuất hiện và điểm kết thúc 2026-06-17 17:33:47 +07:00
3dtours 1933b58f84 fix: lỗi bong bóng Tour vẫn xuất hiện và điểm bắt đầu là điểm xuất phát 2026-06-17 17:29:40 +07:00
3dtours 05dc80bb6d fix: lỗi bong bóng Tour vẫn hiển thị sau khi xóa 2026-06-17 17:15:11 +07:00
3dtours 4d63bff214 feat: thêm nút xóa tour trong phần cài đặt của Tour 2026-06-17 15:48:00 +07:00
3dtours c5474f1ff6 fix: sửa tính năng hiển thị quãng đường tại điểm đến kế tiếp 2026-06-17 15:37:18 +07:00
3dtours 7ad785fed9 feat: thêm tính năng ghi note cho Tour 2026-06-17 12:28:24 +07:00
3dtours e66f242c2c fix: click vào nút bình luận không load dược page 2026-06-16 21:44:49 +07:00
3dtours 6026c2227a fix: UI của trang MyPhotoPage lỗi 2026-06-16 21:25:08 +07:00
3dtours 1eed9b00de feat: xem ảnh theo cấu trúc của lightroom 2026-06-16 20:38:40 +07:00
3dtours 1709638bfd optimize: xác nhận redis đã nén dữ liệu 2026-06-16 20:22:53 +07:00
3dtours 37b5d14d7e optimize: sử dụng redis để cache cho các response từ server 2026-06-16 20:17:17 +07:00
3dtours 2fabdb79df fix: lỗi các thành viên không thể upload ảnh và không có nút xóa ảnh của user 2026-06-16 18:54:55 +07:00
3dtours 00554224a1 fix: lỗi các thành viên không thể upload ảnh và không có nút xóa ảnh của user 2026-06-16 18:54:24 +07:00
3dtours 88b2182789 feat: thêm chức năng upload ảnh 2026-06-16 17:14:22 +07:00
3dtours 989d60643b feat: thêm nút tìm kiếm địa điểm cho phần tạo tour, phần thêm địa điểm 2026-06-16 15:45:26 +07:00
3dtours 1772ced959 feat: nút lọc filter tags dropdown 2026-06-16 15:35:45 +07:00
3dtours c005009da2 feat: Chuyển khám phá địa điểm thành box tìm kiếm địa điểm trên bản đồ 2026-06-16 15:28:10 +07:00
3dtours bfd18e05dd feat: tạo hook useNotification.tsx để dùng chung toàn hệ thống 2026-06-16 12:41:00 +07:00
3dtours 29d39ae7b0 feat: tạo hook useConfirm.tsx để dùng chung toàn hệ thống 2026-06-16 12:29:09 +07:00
3dtours 047170d4be feat: cho phép người dùng và người quản lí xóa bình luận 2026-06-16 12:21:44 +07:00
3dtours fc96ee9eb8 fix: tìm kiếm địa điểm với cách gõ tên trên bản đồ 2026-06-16 12:15:01 +07:00
3dtours 0a584a13c7 feat: tìm kiếm địa điểm trên bản đồ 2026-06-16 11:37:21 +07:00
3dtours f78c72ad60 fix: hiển thị mô tả và tên của hành trình có thêm tags 2026-06-16 11:21:43 +07:00
3dtours 8cca4a83ca fix: tags không có trong csdl gây nên lỗi crash backend 2026-06-16 11:15:39 +07:00
3dtours bc49e081c6 feat: cho phép lấy thời gian và địa điểm hiện tại để gán cho điểm 2026-06-16 10:55:18 +07:00
3dtours 9e26aabce6 Sửa lỗi hiển thị chi phí ở điểm của chặng 2026-06-16 10:36:38 +07:00
3dtours 3e1a5db1a6 Sửa lỗi hiển thị bảng liệt kê chi phí 2026-06-16 10:28:33 +07:00
3dtours baa83aad8a Sửa lỗi người nhận được line xem được nội dung Tour nhưng không xem được bình luận 2026-06-16 09:56:59 +07:00
3dtours 302a82e887 Sửa lỗi người nhận được liên kết chia sẻ không xem được nội dung Tour 2026-06-16 09:54:32 +07:00
3dtours 63da413b3c Sửa lỗi OWNER, MANAGER không thể chia sẻ link 2026-06-16 09:33:15 +07:00
3dtours 02c493f7e0 Thêm tính năng click chuột phải vào bong bóng để lấy link chia sẻ 2026-06-16 08:46:45 +07:00
3dtours b939fc959d Thêm tính năng hiển thị tooltip trên bong bóng Tour 2026-06-16 08:35:48 +07:00
3dtours 5464d90948 Thêm tính năng bình luận ở mỗi điểm của chặng 2026-06-16 08:27:54 +07:00
3dtours c5530f36df feat: cập nhật giao diện tour hiển thị mô tả trên banner 2026-06-15 21:28:41 +07:00
3dtours dcaf71032c Cài đặt tính năng thay đổi tên và mô tả tour 2026-06-15 21:22:05 +07:00
3dtours aec9112064 Cài đặt tính năng người quản lí cập nhật số người tham gia 2026-06-15 20:55:36 +07:00
3dtours 8dac79fc4b Cài đặt tính năng người quản lí có thể khai báo số lượng người tham gia 2026-06-15 20:52:43 +07:00
3dtours 75cf96898c Cài đặt tính năng in bảng thống kê ra pdf 2026-06-15 20:43:32 +07:00
3dtours f52580717f Cài đặt tính năng hiển thị chi phí 2026-06-15 20:20:34 +07:00
3dtours 879f20985c Chỉnh sửa ngày của hành trình là ngày của chặng đầu tiên và chặng hoàn thành 2026-06-15 19:57:54 +07:00
3dtours 4edfd0eb59 Sửa lỗi chỉnh sửa tiêu đề chặng và ngày giờ của chặng 2026-06-15 19:53:58 +07:00
3dtours 55f0a8e775 Sửa lỗi không thể khai báo số lượng chặng 2026-06-15 19:42:45 +07:00
3dtours 7812d1395b Sửa lỗi thành viên có thể gửi lời mời các users khác 2026-06-15 19:38:07 +07:00
3dtours c9ef98b2ff Sửa lỗi không truy cập IP từ ngoài 2026-06-15 19:31:03 +07:00
3dtours 71f4aa9ecd Sửa lỗi frontend không load dược danh sách thêm thành viên 2026-06-15 18:27:54 +07:00
3dtours 34e2af8825 Sửa lỗi frontend không kết nối với backend 2026-06-15 18:25:37 +07:00
3dtours 4716b841dc Sửa lỗi tái cấu trúc thư mục và khai báo import 2026-06-15 16:31:28 +07:00
3dtours 967f6b4f6a Phục hồi lại và chuẩn bị tái cấu trúc thư mục 2026-06-15 15:00:37 +07:00
3dtours c6341aa12f Thêm tính năng owner có thể thêm thành viên, member thêm thành viên sẽ pending 2026-06-14 22:14:40 +07:00
3dtours 7cc724a133 Thêm tính năng sửa tính năng pending bị lỗi 2026-06-14 21:07:21 +07:00
3dtours 8ff09eeeaf Thêm tính năng pending khi một thành viên thêm một người khác vào 2026-06-14 20:22:37 +07:00
3dtours 914a9cf243 Thêm tính năng thêm hoặc xóa thành viên ngay tại trang tạo tour 2026-06-14 18:39:37 +07:00
3dtours d084e806a1 Thêm thành viên bằng địa chỉ email 2026-06-14 18:27:12 +07:00
3dtours bb15b2bf15 Thêm tính năng xóa thành viên ra khỏi tour 2026-06-14 17:43:28 +07:00
3dtours 4bec095a40 Sửa lỗi hiện danh sách trong bảng thêm thành viên 2026-06-14 17:32:51 +07:00
3dtours 81e715ddb3 Sửa lỗi nhấn + để thêm thành viên của tour 2026-06-14 17:16:19 +07:00
3dtours c6d0fdd5d1 Bổ sung số đt và địa chỉ vào phần đăng ký tài khoản 2026-06-14 16:10:30 +07:00
3dtours 3a29f98c96 Sửa hiển thị tên/email của người thanh toán 2026-06-14 16:05:14 +07:00
3dtours 71007bd9a1 Bổ sung người thanh toán vào thêm địa điểm 2026-06-14 15:59:27 +07:00
3dtours 65a9b39966 Bổ sung tính năng cập nhật tiền của địa điểm bị lỗi trùng cổng 2026-06-14 12:24:53 +07:00
3dtours 58087a4b37 Bổ sung tính năng cập nhật tiền của địa điểm 2026-06-14 10:46:52 +07:00
3dtours 838aec562f Bổ sung tính năng sửa chữa, cập nhật địa điểm trong chặng 2026-06-14 10:16:09 +07:00
3dtours ec650fb45d Gán điểm kết thúc của chặng trước làm điểm khởi hành của chặng sau 2026-06-14 10:11:33 +07:00
3dtours bf63510980 Vào thằng Tour Dashboard khi click vào Tour callout 2026-06-14 10:01:26 +07:00
3dtours b3519c94cd Sửa lỗi khi click vào nút bản đồ 2026-06-14 09:48:09 +07:00
3dtours 5cfb5b9d15 Sửa khi click vào dấu + thì xuất hiện bản đồ để chọn 2026-06-14 09:43:23 +07:00
3dtours 4545bde8ec Sửa lỗi khi click vào dấu + để thêm chặng 2026-06-14 09:37:40 +07:00
3dtours 14bf43487e Sửa hiển thị điểm đầu và điểm cuối của chặng 2026-06-14 09:28:33 +07:00
3dtours ed437cdb0f Sửa lỗi hiển thị điểm đầu và điểm cuối của lộ trình trên bản đồ 2026-06-14 09:06:51 +07:00
3dtours c7db2c3ed8 Chỉnh sửa giao diện Tour Dashboard 2026-06-13 22:16:27 +07:00
3dtours 5e3f10631f Cho phép xóa tour và xóa chặng trong Tour Dashboard 2026-06-13 21:48:53 +07:00
3dtours 578b03d808 Cho phép chọn trực tiếp điểm trên bản đồ 2026-06-13 21:34:07 +07:00
3dtours 25fcd5d926 Tạo UI cho người dùng tạo Tour 2026-06-13 20:46:31 +07:00
3dtours c51ddc34c7 Thêm tính năng trong mục 4.1 của ARCHITECTURE.md 2026-06-13 20:14:52 +07:00
3dtours b54707823c Sửa đổi dữ liệu của hệ thống theo ARCHITECTURE.md 2026-06-13 19:52:08 +07:00
225 changed files with 41508 additions and 4527 deletions
+13
View File
@@ -0,0 +1,13 @@
node_modules/
server/node_modules/
# Bỏ qua cấu hình hệ thống và Git
.git/
.idea/
.vscode/
# Bỏ qua các file log và build
*.log
dist/
build/
out/
+8
View File
@@ -0,0 +1,8 @@
name: Local Config
version: 1.0.0
schema: v1
models:
- name: Autodetect
provider: lmstudio
model: AUTODETECT
apiBase: http://192.168.1.12:1234/v1/
+2
View File
@@ -1,2 +1,4 @@
.env
node_modules
server/dist
dist
+3
View File
@@ -0,0 +1,3 @@
{
"$schema": "https://app.kilo.ai/config.json"
}
+4
View File
@@ -0,0 +1,4 @@
{
"continue.enableConsole": true,
"remote.autoForwardPortsFallback": 0
}
-120
View File
@@ -1,120 +0,0 @@
# Kiến Trúc Hệ Thống: Tính Năng Lập Kế Hoạch Du Lịch (Travel Planning Feature)
Tài liệu này mô tả kiến trúc tổng thể, mô hình dữ liệu và các giải pháp kỹ thuật cho tính năng lập kế hoạch du lịch đa người dùng thuộc nền tảng **Travel**.
---
## 1. Kiến Trúc Tổng Quan (System Overview)
Hệ thống được thiết kế theo mô hình **Client-Server** kết hợp với kiến trúc **Modular Monolith** hoặc **Microservices** (tùy quy mô triển khai), chia làm 3 tầng chính:
* **Frontend (Presentation Layer):** Đảm nhiệm việc tương tác trực tiếp với người dùng, hiển thị bản đồ trực quan, quản lý luồng tạo tour/chặng và xử lý tải ảnh/hiển thị album.
* **Backend (Business Logic Layer):** Xử lý xác thực bảo mật, tính toán chi phí (quy tắc phân chia tiền), định tuyến đường đi ngắn nhất, lập lịch kiểm tra task (Cron job/Event-driven) và kiểm tra quyền truy cập (RBAC).
* **Database & Storage (Data Layer):** Lưu trữ dữ liệu quan hệ (PostgreSQL với extension PostGIS để xử lý tọa độ địa lý) và lưu trữ tệp tin hình ảnh riêng biệt (Object Storage như AWS S3 hoặc MinIO).
---
## 2. Công Nghệ Đề Xuất (Tech Stack)
| Thành phần | Công nghệ đề xuất | Lý do lựa chọn |
| :--- | :--- | :--- |
| **Frontend** | React / Next.js + TailwindCSS | Tối ưu cho ứng dụng Single Page (SPA), quản lý trạng thái tốt, hỗ trợ SSR cho SEO nếu cần. |
| **State Management** | Zustand / Redux Toolkit | Quản lý state phức tạp của Tour, Chặng (Leg) và Chi phí. |
| **Map Engine** | Mapbox GL JS / Google Maps API / Leaflet | Hỗ trợ hiển thị bản đồ trực quan, marker kéo thả và tính toán định tuyến (Routing). |
| **Backend** | Node.js (NestJS hoặc Express) | Xử lý bất đồng bộ tốt, phù hợp hệ thống nhiều I/O và tích hợp mDNS/IoT nếu mở rộng sau này. |
| **Database** | PostgreSQL + **PostGIS** | Hệ quản trị cơ sở dữ liệu mạnh mẽ, PostGIS giúp tính toán khoảng cách và tọa độ địa lý chính xác. |
| **File Storage** | AWS S3 / MinIO | Lưu trữ ảnh tách biệt hoàn toàn khỏi Database để tối ưu hiệu năng. |
---
## 3. Mô Hình Dữ Liệu (Database Schema)
Dưới đây là các thực thể cốt lõi phục vụ tính năng:
### 3.1. Users & Authentication
* `users`: Lưu thông tin định danh (`id`, `email`, `password_hash`, `name`, `avatar`).
### 3.2. Tour & Members (Quản lý đa người dùng & Phân quyền)
* `tours`: Thông tin tổng quan về chuyến đi.
* `id` (PK), `title`, `start_date`, `end_date`, `creator_id` (FK), `created_at`.
* `tour_members`: Bảng trung gian quản lý thành viên và quyền hạn (Bảo mật thông tin).
* `tour_id` (FK), `user_id` (FK).
* `role`: Định nghĩa các quyền cụ thể:
* `OWNER`: Toàn quyền (Người tạo).
* `EDITOR`: Sửa đổi kế hoạch, chi phí, xem/thêm ảnh.
* `MEMBER_PLAN_ONLY`: Chỉ xem/sửa kế hoạch, không xem được chi phí.
* `MEMBER_PHOTO_ONLY`: Chỉ được xem/đăng ảnh trong album, không thấy kế hoạch và chi phí.
* `VIEWER_EXTERNAL`: Người ngoài được share link, chỉ xem được ảnh công khai (tùy thuộc vào cài đặt privacy).
### 3.3. Itinerary & Map (Chặng & Địa điểm)
* `legs` (Chặng): Một tour có nhiều chặng.
* `id` (PK), `tour_id` (FK), `sequence_number` (Thứ tự chặng: 1, 2, 3...), `notes`.
* `places` (Địa điểm trong chặng):
* `id` (PK), `leg_id` (FK), `name`, `address`, `latitude`, `longitude` (Dữ liệu PostGIS), `sequence_in_leg`.
* `arrival_time` (Dự kiến), `departure_time` (Dự kiến).
### 3.4. Expenses (Chi phí)
* `expenses`: Lưu vết chi phí cho từng địa điểm/chặng.
* `id` (PK), `leg_id` (FK), `place_id` (FK, nullable), `category` (`LODGING`, `DINING`, `TRANSPORT`, `OTHER`), `amount` (Số tiền), `currency`, `description`.
* `tour_cost_summaries`: Cấu hình phân chia tiền ở cuối tour/chặng.
* `tour_id` (FK), `total_cost`, `adult_count`, `child_count`, `child_discount_percent` (Ví dụ: trẻ em giảm 30%).
### 3.5. Tasks & Timeline Tracking (Theo dõi tiến độ)
* `tasks`: Các hoạt động cần tích chọn hoàn thành.
* `id` (PK), `tour_id` (FK), `leg_id` (FK), `title`, `planned_timestamp`.
* `is_completed` (Boolean).
* `completed_at` (Timestamp - Ghi lại lúc hệ thống tự động tích hoặc user chủ động tích).
* `trigger_type` (`AUTO_BY_TIME` hoặc `MANUAL_BY_USER`).
### 3.6. Album Ảnh (Tách biệt logic kế hoạch)
* `photos`: Lưu trữ hình ảnh của tour gắn với địa điểm.
* `id` (PK), `tour_id` (FK), `place_id` (FK, nullable), `uploader_id` (FK), `image_url` (Đường dẫn S3), `captured_at` (Metadata từ ảnh hoặc thời gian tạo), `privacy_level` (`PUBLIC_IN_TOUR`, `PRIVATE_OWNER`).
---
## 4. Giải Pháp Kiến Trúc Cho Các Tính Năng Core
### 4.1. Thuật Toán Tìm Đường Ngắn Nhất (Shortest Path Routing)
* **Cơ chế:** Khi người dùng thêm các địa điểm vào một chặng, Frontend sẽ gửi danh sách tọa độ hình học (Lat, Lng) về Backend.
* **Xử lý:** Backend tích hợp với **Mapbox Direction API** hoặc chạy một instance **OSRM (Open Source Routing Machine)** cục bộ để giải bài toán Người đi giao hàng (TSP - Travelling Salesman Problem). Hệ thống sẽ sắp xếp lại thứ tự `sequence_in_leg` của các `places` để tối ưu tổng quãng đường hoặc thời gian di chuyển, sau đó trả về mảng tọa độ đường đi (Polyline) để Frontend vẽ lên bản đồ.
### 4.2. Engine Tính Toán Chi Phí & Chia Tiền (Expense Splitting Engine)
* **Công thức tự động:** * Tổng chi phí Tour ($T$) = $\sum \text{Expenses}$ của tất cả các chặng.
* Hệ thống cho phép cấu hình số lượng Người lớn ($N_{adult}$) và Trẻ em ($N_{child}$), kèm phần trăm giảm giá của trẻ em ($P_{child\_discount}$).
* Gọi $X$ là chi phí của 1 người lớn:
$$T = N_{adult} \times X + N_{child} \times \left( X \times (1 - P_{child\_discount}) \right)$$
* Từ đó Backend tự động giải phương trình để tìm ra chi phí trung bình của người lớn ($X$) và trẻ em, hiển thị báo cáo chi tiết ở cuối mỗi chặng và kết thúc tour.
### 4.3. Theo Dõi Tiến Độ & Tính Toán Độ Lệch Thời Gian (Time Variance Tracking)
* **Tự động (Auto-trigger):** Một Worker/Cron-job chạy ngầm mỗi phút quét các `tasks` có cài đặt `AUTO_BY_TIME`. Nếu `current_time >= planned_timestamp` và chưa hoàn thành, hệ thống tự động chuyển `is_completed = true` và ghi nhận `completed_at = planned_timestamp`.
* **Thủ công (Manual-trigger):** Khi user bấm tích chọn hoàn thành, hệ thống ghi nhận ngay `completed_at = current_time`.
* **Báo cáo thời gian (Time Summary):** Backend tính toán hiệu số:
$$\Delta t = \text{completed\_at} - \text{planned\_timestamp}$$
Đồng thời group dữ liệu theo loại hoạt động để xuất ra tổng thời gian: *Di chuyển (dựa trên khoảng cách bản đồ), Tham quan (thời gian ở các địa điểm giải trí), Nghỉ ngơi (thời gian ở chỗ nghỉ).*
### 4.4. Bảo Mật & Tách Biệt Album Ảnh (Photo Album Isolation)
* **Tách biệt logic:** Cơ sở dữ liệu ảnh liên kết qua mã `tour_id``place_id` nhưng API endpoint kiểm tra quyền (`/api/v1/tours/:id/photos`) hoạt động độc lập với endpoint kế hoạch (`/api/v1/tours/:id/plans`).
* **Bảo mật phân quyền (Privacy Middleware):** Trước khi trả về dữ liệu hình ảnh hoặc kế hoạch, hệ thống sẽ chạy qua một tầng Middleware để check quyền trong bảng `tour_members`:
```javascript
// Giả lập Logic kiểm tra quyền truy cập thông tin tài chính/kế hoạch
if (member.role === 'MEMBER_PHOTO_ONLY' && request.path.includes('/plans')) {
throw new ForbiddenException("Bạn không có quyền xem kế hoạch và chi phí của tour này.");
}
```
---
## 5. Thiết Kế Luồng Đi (Workflow Diagrams)
### Luồng tạo chặng và tính toán đường đi (Frontend <=> Backend)
1. **User** tìm kiếm/chọn địa điểm trên bản đồ -> **Frontend** cập nhật state cục bộ.
2. **User** nhấn "Tối ưu đường đi" -> **Frontend** gửi danh sách `coords[]` lên `/api/v1/routing/optimize`.
3. **Backend** gọi Mapbox/OSRM API -> Sắp xếp lại thứ tự tối ưu -> Lưu vào DB -> Trả về JSON chứa `optimized_places` và `polyline_geometry`.
4. **Frontend** render lại danh sách địa điểm theo thứ tự mới và vẽ nét đứt/đường đi trên Map.
### Luồng chia sẻ hình ảnh và kiểm tra bảo mật
1. **User** tải ảnh lên tại một địa điểm trong Tour.
2. **Frontend** gửi file đến dịch vụ lưu trữ (S3) nhận về `image_url`, sau đó lưu thông tin vào DB kèm metadata (ngày giờ, địa điểm).
3. Khi một **User khác** truy cập link Tour:
* Hệ thống check `tour_members`.
* Nếu thuộc diện *External Viewer* hoặc *Photo Only*: Ẩn hoàn toàn tab "Kế hoạch chặng hành trình" và "Tổng số tiền", chỉ hiển thị giao diện Grid hình ảnh (`album`).
-85
View File
@@ -1,85 +0,0 @@
import React, { useState, useEffect } from 'react';
import { LandingPage } from './LandingPage.js';
import { TourDetailPage } from './TourDetailPage.js';
import { ExploreMap } from './ExploreMap.js';
import { SignupPage } from './SignupPage.js';
const App = () => {
type View = 'landing' | 'explore' | 'detail' | 'signup';
const [view, setView] = useState<View>('landing');
const [isInitialSetup, setIsInitialSetup] = useState(false);
const [user, setUser] = useState<any>(null);
const [isUserLoaded, setIsUserLoaded] = useState(false); // Trạng thái để biết user đã được load từ localStorage chưa
useEffect(() => {
// Tự động xác định địa chỉ IP của Backend dựa trên hostname hiện tại
const API_BASE = `http://${window.location.hostname}:3001`;
// Khôi phục phiên đăng nhập từ localStorage
const savedUser = localStorage.getItem('user');
if (savedUser) {
const parsedUser = JSON.parse(savedUser);
setUser(parsedUser);
}
setIsUserLoaded(true); // Đánh dấu user đã được load
// Kiểm tra xem hệ thống đã được cài đặt chưa
fetch(`${API_BASE}/api/v1/auth/status`)
.then(res => res.ok ? res.json() : Promise.reject())
.then(data => setIsInitialSetup(!!data.isInitialSetup))
.catch(() => setIsInitialSetup(false));
}, []); // Chạy một lần khi component mount
// Effect để xử lý chuyển hướng nếu user đã đăng nhập và đang ở trang landing
useEffect(() => {
if (isUserLoaded && user && view === 'landing') {
setView('explore');
}
}, [isUserLoaded, user, view]);
const handleLoginSuccess = (userData: any) => {
setUser(userData);
};
const handleLogout = () => {
localStorage.removeItem('user');
localStorage.removeItem('token');
setUser(null);
setView('landing');
};
return (
<div className="app-container">
{view === 'landing' && (
<LandingPage
isInitialSetup={isInitialSetup}
onContinue={() => setView('explore')}
onGoToSignup={() => setView('signup')}
onGoToMap={() => setView('explore')}
onLoginSuccess={handleLoginSuccess}
/>
)}
{view === 'signup' && (
<SignupPage
onBack={() => setView('landing')}
onSuccess={() => setView('landing')}
/>
)}
{view === 'explore' && (
<ExploreMap
onBack={() => setView('landing')}
onLogout={user ? handleLogout : undefined}
user={user}
/>
)}
{view === 'detail' && (
<TourDetailPage />
)}
</div>
);
};
export default App;
+126
View File
@@ -0,0 +1,126 @@
# To AI Agent: Implement Smart Date-Based Accordion Auto-Expansion and Smooth Viewport Auto-Scroll
## 1. Context & UI/UX Requirements
We are enhancing the routing UX between `MemberDashboard.tsx` and `ItineraryTimeline.tsx` based on `image_cc31a4.png`.
### Functional Specifications:
1. **Date-Matching Evaluation:** When a user clicks "Chi tiết hành trình" on a tour card, calculate if the current user system local date falls inside any Stage/Leg timeline block.
2. **Auto-Expansion State:** On timeline page load, the matched Leg accordion must be expanded by default (`expandedStageId === leg.id`).
3. **Smart Auto-Scroll Layout:** The viewport must smoothly auto-scroll so that the expanded Leg's title bar lands **exactly below the sticky navigation tab bar** ("Lộ trình", "Chi phí", etc.), making it fully visible at the top of the viewport without layout clipping.
---
## 2. Technical Architecture & Layout Constraints
- **The Sticky Header Challenge:** Since the top navigation tab bar uses sticky/fixed positioning, a naive `scrollIntoView({ block: 'start' })` will cause the Leg's title to crawl *underneath* the tabs, hiding it.
- **The Solution:** We will inject a dynamic CSS scroll-margin-top parameter (`scroll-mt-[70px]` or matching header height) on each Leg container, and trigger a minor delayed layout effect pool using a React `setTimeout` to wait for the DOM accordion expansion reflow before pulling the scroll trigger.
---
## 3. Code Refactoring Blueprint
### Step 1: Update Navigation Payload Handler in Dashboard Component
Ensure the `MemberDashboard.tsx` accurately packages the matched target identifier into the client route state bucket:
```typescript
import { useNavigate } from 'react-router-dom';
const navigate = useNavigate();
const handleNavigateToItinerary = (tour: any) => {
let targetLegId = null;
if (tour?.legs && tour.legs.length > 0) {
const today = new Date();
today.setHours(0, 0, 0, 0);
for (const leg of tour.legs) {
const rawStart = leg.startDate || leg.plannedStart || leg.date;
const rawEnd = leg.endDate || leg.plannedEnd || leg.date;
if (rawStart) {
const startBound = new Date(rawStart);
startBound.setHours(0, 0, 0, 0);
const endBound = rawEnd ? new Date(rawEnd) : new Date(rawStart);
endBound.setHours(23, 59, 59, 999);
if (today >= startBound && today <= endBound) {
targetLegId = leg.id;
break;
}
}
}
if (!targetLegId) {
targetLegId = tour.legs[0].id; // Fallback to Chặng 1 if out of tour bounds
}
}
navigate(`/tour/${tour.id}`, {
state: { defaultExpandedLegId: targetLegId, shouldScrollToTarget: true }
});
};
### Step 2: Inject Safe Identifiers and Scroll Margin inside ItineraryTimeline
Locate the top-level outer container div of each Leg item inside the timeline loop (currentTour.legs.map). Assign a unique id and a scroll margin class utility:
{currentTour.legs.map((leg: any, legIdx: number) => (
<div
key={leg.id}
id={`leg-anchor-node-${leg.id}`}
/* CRITICAL: scroll-mt-[70px] leaves a 70px buffer space at the top.
Adjust '70px' to match the exact height of your white Tour Navigation Tabs bar!
*/
className="scroll-mt-[70px] transition-all w-full mb-4"
>
{/* Accordion Header Title ("Chặng 2: Dạo chơi ở xứ hoa vàng...") */}
<div className="flex items-center justify-between ...">
...
</div>
</div>
))}
### Step 3: Implement Lifecycle Interaction Trigger Engine
Inside ItineraryTimeline.tsx, listen to the passed history route parameters. Set the state, then handle the async smooth scrolling action:
import { useLocation } from 'react-router-dom';
const location = useLocation();
const [expandedStageId, setExpandedStageId] = useState<string | null>(null);
useEffect(() => {
if (location.state?.defaultExpandedLegId) {
const targetId = location.state.defaultExpandedLegId;
// 1. Instantly trigger the accordion state layout expansion
setExpandedStageId(targetId);
// 2. Schedule a deferred macro-task callback to allow DOM re-renders to finish
if (location.state?.shouldScrollToTarget) {
const scrollTimer = setTimeout(() => {
const targetElement = document.getElementById(`leg-anchor-node-${targetId}`);
if (targetElement) {
targetElement.scrollIntoView({
behavior: 'smooth',
block: 'start'
});
}
// Clear history router state token flags to prevent repeating scroll on subsequent reload shifts
window.history.replaceState({}, document.title);
}, 150); // 150ms ensures smooth accordion height deployment transition finishes
return () => clearTimeout(scrollTimer);
}
} else if (currentTour?.legs && currentTour.legs.length > 0 && !expandedStageId) {
setExpandedStageId(currentTour.legs[0].id);
}
}, [location.state, currentTour]);
## 4. Quality Control & Acceptance Verification
[ ] Date Matching Accuracy: Set today's date context matching a target Leg configuration profile. Tap the transition interface button from dashboard. The page must route directly and expand the target container block node.
[ ] Flush Viewport Ceiling Test: The animated target header segment node must slide upwards smoothly. It must lock positions cleanly right below the lowest baseline layer shadow boundary of the Tab Controller without passing behind it.
[ ] State Cleanliness Check: Refreshing or navigating back and forth within the active Timeline panel after the initial landing should not lock or force layout views to keep jumping scroll heights automatically.
+156
View File
@@ -0,0 +1,156 @@
# Enhancement Summary: Tag Selection & Photo Filtering
## Overview
Successfully enhanced the travel planning application with improved photo tagging UI and photo filtering capabilities on the explore map.
## Changes Implemented
### 1. Enhanced TagSelectModal Component ✅
**File**: `frontend/src/components/TagSelectModal.tsx`
**Features Added**:
- **Image Preview**: Shows the uploaded photo at the top of the modal (max-height: 192px, with rounded corners)
- **Custom Tag Input**: Text input with "Nhập thẻ mới..." placeholder
- **Add Custom Tags**: Button and Enter key support to add user-defined tags
- **Remove Custom Tags**: Trash icon on hover to delete custom tags
- **Visual Feedback**: Selected tags highlighted in different colors (blue for predefined, emerald for custom)
- **Summary Section**: Displays count and list of all selected tags before confirmation
**UI Improvements**:
- Sticky header and footer for easy access
- Separate sections for predefined tags and custom input
- Max-height with scrolling for long tag lists
- Smooth animations and transitions
### 2. LandingPage Integration ✅
**File**: `frontend/src/pages/LandingPage.tsx`
**Updates**:
- Added `photoPreviewUrl` state for temporary preview image
- Created object URL using `URL.createObjectURL()` when file is selected
- Pass preview URL to TagSelectModal component
- Proper cleanup with `URL.revokeObjectURL()` on modal close or after upload
- Included custom tags in upload formData as JSON
**State Management**:
```typescript
const [photoPreviewUrl, setPhotoPreviewUrl] = useState<string>('');
```
### 3. ExploreMap Photo Tag Filtering ✅
**File**: `frontend/src/pages/ExploreMap.tsx`
**Features Added**:
- **Photo Tag Filter State**: `selectedPhotoFilterTags` for tracking active filters
- **Available Photo Tags**: `availablePhotoTags` computed from all public photos' metadata
- **Enhanced Filter Dropdown**: Two-section filter UI:
- **Tour Section (🧳 Chuyến đi)**: Existing tour tags (single selection)
- **Photo Section (📸 Ảnh công khai)**: Photo tags from uploads (multiple selection)
- **Filtering Logic**: `groupedPhotos` useMemo filters photos based on selected tags
- **Dynamic Tag Population**: Photo tags automatically extracted from `photo.metadata.tags`
**Filtering Behavior**:
- Multiple photo tags can be selected simultaneously
- Photos matching ANY selected tag are displayed (OR logic)
- "Tất cả" button clears photo filters
- Filter is independent from tour tag filtering
## Technical Details
### Tag Storage
- Backend stores tags in `photo.metadata.tags` as JSON array
- No database schema changes required
- Flexible for custom tags without pre-definition
### Frontend State Management
```typescript
// State
const [selectedPhotoFilterTags, setSelectedPhotoFilterTags] = useState<string[]>([]);
// Computed
const availablePhotoTags = React.useMemo(() => {
const tagsSet = new Set<string>();
publicPhotos.forEach(photo => {
const tags = photo.metadata?.tags as string[] | undefined;
if (Array.isArray(tags)) {
tags.forEach(tag => tagsSet.add(tag));
}
});
return Array.from(tagsSet).sort();
}, [publicPhotos]);
// Filtered Results
const groupedPhotos = React.useMemo(() => {
let filteredPhotos = publicPhotos;
if (selectedPhotoFilterTags.length > 0) {
filteredPhotos = publicPhotos.filter((photo) => {
const photoTags = photo.metadata?.tags as string[] | undefined;
if (!Array.isArray(photoTags)) return false;
return selectedPhotoFilterTags.some(tag => photoTags.includes(tag));
});
}
// ... grouping and sorting logic
}, [publicPhotos, selectedPhotoFilterTags]);
```
## Testing Results
### Verified Functionality
✅ Image preview displays in TagSelectModal
✅ Custom tag input accepts user text
✅ Custom tags can be added with button or Enter key
✅ Custom tags can be removed with trash icon
✅ All selected tags display in summary
✅ Photo tag filtering works on ExploreMap
✅ Multiple photo tags can be selected
✅ "Tất cả" button clears filters
✅ Docker build successful (0 errors)
✅ All services running healthy
### Build Status
- Frontend build: ✅ Success
- Backend build: ✅ Success
- Container deployment: ✅ All 4 services running
- Browser testing: ✅ Filter dropdown functional
## User Workflow
### Photo Upload with Tags
1. User clicks "Chụp ảnh" button
2. Selects image from device
3. Image is displayed in TagSelectModal preview
4. User selects predefined tags from 11 categories
5. User can add custom tags in textbox
6. Confirms and photo is uploaded with all tags
7. Tags stored in database for filtering
### Photo Discovery with Filtering
1. User navigates to "Khám phá" (Explore)
2. Clicks filter button to open dropdown
3. Sees available photo tags from community uploads
4. Selects one or more tags to filter
5. Map refreshes showing only photos with selected tags
6. Clear selection with "Tất cả" button to see all photos again
## Files Modified
-`frontend/src/components/TagSelectModal.tsx` - Enhanced with preview and custom tags
-`frontend/src/pages/LandingPage.tsx` - Integration with preview URL
-`frontend/src/pages/ExploreMap.tsx` - Photo tag filtering implementation
## Browser Compatibility
- Modern browsers with ES6+ support
- Tested on latest Chrome/Firefox/Safari
- Mobile responsive design with touch support
## Performance Considerations
- Photo tag extraction done in useMemo (cached)
- Filter operations are optimized with Set for uniqueness
- Lazy filtering applied only to grouped photos
- No additional API calls needed (uses existing photo data)
## Future Enhancements
- Tag search/autocomplete in filter
- Tag popularity sorting
- Tag suggestions based on similar photos
- User tag preferences/favorites
- Tag analytics dashboard
-77
View File
@@ -1,77 +0,0 @@
import React, { useState, useMemo } from 'react';
import { Wallet, Users, Info } from 'lucide-react';
import { useTourStore } from './useTourStore.js';
export const ExpenseManager = () => {
const { legs } = useTourStore();
const [adults, setAdults] = useState(2);
const [children, setChildren] = useState(1);
const [discount, setDiscount] = useState(30);
const totals = useMemo(() => {
const totalAmount = legs.reduce((acc, leg) =>
acc + leg.expenses.reduce((lAcc: number, exp: any) => lAcc + Number(exp.amount), 0), 0
);
const childRateFactor = 1 - (discount / 100);
const weightedCount = adults + (children * childRateFactor);
const adultPrice = totalAmount / weightedCount;
const childPrice = adultPrice * childRateFactor;
return {
total: totalAmount,
adultPrice: Math.round(adultPrice),
childPrice: Math.round(childPrice)
};
}, [legs, adults, children, discount]);
return (
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-4">
<div className="bg-white p-6 rounded-2xl border border-gray-100 shadow-sm">
<div className="flex items-center gap-2 mb-6 text-blue-600">
<Users className="w-5 h-5" />
<h3 className="font-bold">Cấu hình thành viên</h3>
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="text-xs text-gray-400 block mb-1">Người lớn</label>
<input type="number" value={adults} onChange={e => setAdults(Number(e.target.value))} className="w-full p-2 bg-gray-50 rounded-lg border-none focus:ring-2 focus:ring-blue-500" />
</div>
<div>
<label className="text-xs text-gray-400 block mb-1">Trẻ em</label>
<input type="number" value={children} onChange={e => setChildren(Number(e.target.value))} className="w-full p-2 bg-gray-50 rounded-lg border-none focus:ring-2 focus:ring-blue-500" />
</div>
<div>
<label className="text-xs text-gray-400 block mb-1">Giảm trẻ em (%)</label>
<input type="number" value={discount} onChange={e => setDiscount(Number(e.target.value))} className="w-full p-2 bg-gray-50 rounded-lg border-none focus:ring-2 focus:ring-blue-500" />
</div>
</div>
</div>
<div className="bg-blue-600 rounded-2xl p-6 text-white shadow-lg shadow-blue-200">
<div className="flex justify-between items-start mb-8">
<div>
<p className="text-blue-100 text-sm">Tổng chi phí chuyến đi</p>
<h2 className="text-3xl font-bold mt-1">{totals.total.toLocaleString()} VND</h2>
</div>
<Wallet className="w-8 h-8 opacity-20" />
</div>
<div className="grid grid-cols-2 gap-4 border-t border-blue-500 pt-6">
<div>
<p className="text-blue-100 text-xs uppercase tracking-wider font-semibold">Mỗi người lớn</p>
<p className="text-xl font-bold">{totals.adultPrice.toLocaleString()}đ</p>
</div>
<div>
<p className="text-blue-100 text-xs uppercase tracking-wider font-semibold">Mỗi trẻ em (-{discount}%)</p>
<p className="text-xl font-bold">{totals.childPrice.toLocaleString()}đ</p>
</div>
</div>
</div>
<div className="flex items-center gap-2 text-gray-400 text-xs px-2">
<Info className="w-4 h-4" />
<p>Chi phí đưc tự đng tính toán dựa trên hóa đơn của các chặng.</p>
</div>
</div>
);
};
-132
View File
@@ -1,132 +0,0 @@
import React, { useEffect, useState } from 'react';
import { MapContainer, TileLayer, Marker, Popup, useMap } from 'react-leaflet';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import { useTourStore } from './useTourStore.js';
import { X, Navigation, Image as ImageIcon, LogOut, Settings } from 'lucide-react';
import { UserManagementModal } from './UserManagementModal.js';
// Fix lỗi icon mặc định của Leaflet
const DefaultIcon = L.icon({
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
iconSize: [25, 41],
iconAnchor: [12, 41],
});
L.Marker.prototype.options.icon = DefaultIcon;
// Component Helper để cập nhật tâm bản đồ khi vị trí người dùng thay đổi
function RecenterMap({ position }: { position: [number, number] }) {
const map = useMap();
useEffect(() => {
map.setView(position, map.getZoom());
}, [position, map]);
return null;
}
export const ExploreMap = ({ onBack, onLogout, user }: { onBack: () => void, onLogout?: () => void, user?: any }) => {
const { publicTours, fetchPublicTours } = useTourStore();
const [userPos, setUserPos] = useState<[number, number]>([10.7769, 106.7009]); // Mặc định TP.HCM
const [isAdminModalOpen, setIsAdminModalOpen] = useState(false);
useEffect(() => {
fetchPublicTours();
navigator.geolocation.getCurrentPosition(
(pos) => setUserPos([pos.coords.latitude, pos.coords.longitude]),
() => console.log("Không thể lấy vị trí người dùng")
);
}, []);
return (
<div className="h-screen w-full relative">
{/* Nút quay lại */}
<button
onClick={onBack}
className="absolute top-6 left-6 z-[1000] bg-white p-3 rounded-full shadow-xl hover:bg-gray-50 transition-all"
>
<X className="w-6 h-6 text-gray-800" />
</button>
{/* Nút đăng xuất - Chỉ hiển thị khi có user login */}
{onLogout && (
<button
onClick={onLogout}
className="absolute top-6 right-6 z-[1000] bg-white px-4 py-3 rounded-2xl shadow-xl hover:bg-red-50 hover:text-red-600 transition-all flex items-center gap-2 font-bold text-gray-700"
>
<LogOut className="w-5 h-5" />
<span className="hidden sm:inline">Đăng xuất</span>
</button>
)}
{/* Nút quản lý người dùng cho Admin */}
{user?.isAdmin && (
<button
onClick={() => setIsAdminModalOpen(true)}
className="absolute top-6 right-40 z-[1000] bg-blue-600 px-4 py-3 rounded-2xl shadow-xl hover:bg-blue-700 text-white transition-all flex items-center gap-2 font-bold"
>
<Settings className="w-5 h-5" />
<span className="hidden sm:inline">Quản hệ thống</span>
</button>
)}
{/* Header Overlay */}
<div className="absolute top-6 left-20 z-[1000] bg-white/90 backdrop-blur-md px-6 py-3 rounded-2xl shadow-xl border border-white/20 hidden sm:block">
<div className="flex items-center gap-2">
<Navigation className="w-4 h-4 text-blue-600" />
<span className="font-bold text-gray-800">Đang khám phá khu vực của bạn</span>
</div>
</div>
<MapContainer center={userPos} zoom={13} className="h-full w-full">
<TileLayer
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
attribution='&copy; OpenStreetMap contributors'
/>
{/* Tự động di chuyển bản đồ đến vị trí người dùng khi tìm thấy tọa độ */}
<RecenterMap position={userPos} />
{publicTours.map((tour) => {
const place = tour.legs?.[0]?.places?.[0];
if (!place) return null;
const tourImage = tour.photos?.[0]?.imageUrl || `https://picsum.photos/seed/${tour.id}/200/200`;
return (
<Marker
key={tour.id}
position={[place.latitude, place.longitude]}
icon={L.divIcon({
className: 'custom-bubble',
html: `
<div class="relative group">
<div class="w-12 h-12 rounded-full border-4 border-white shadow-lg overflow-hidden transition-transform group-hover:scale-110">
<img src="${tourImage}" class="w-full h-full object-cover" />
</div>
<div class="absolute -bottom-1 -right-1 bg-blue-600 rounded-full p-1 border-2 border-white">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="3"><path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/><circle cx="12" cy="13" r="4"/></svg>
</div>
</div>
`,
iconSize: [48, 48],
})}
>
<Popup className="custom-popup">
<div className="p-1 max-w-[200px]">
<img src={tourImage} className="w-full h-32 object-cover rounded-lg mb-2" />
<h4 className="font-bold text-gray-900 leading-tight mb-1">{tour.title}</h4>
<button className="text-xs font-bold text-blue-600 flex items-center gap-1">
Xem chi tiết hình nh <ImageIcon className="w-3 h-3" />
</button>
</div>
</Popup>
</Marker>
);
})}
</MapContainer>
{/* Admin Modal */}
<UserManagementModal isOpen={isAdminModalOpen} onClose={() => setIsAdminModalOpen(false)} />
</div>
);
};
+104
View File
@@ -0,0 +1,104 @@
# To AI Agent: Fix Infinite Re-render Loop and Flickering "ĐANG TÌM ĐƯỜNG TỐI ƯU..." Label
## 1. Context & Layout Bug Analysis
We are resolving a critical performance and UI bug inside the Map routing engine interface (`image.png`):
- **The Issue:** The floating loading indicator badge reading `"ĐANG TÌM ĐƯỜNG TỐI ƯU..."` keeps flashing, flickering, or appearing and disappearing in an infinite execution loop.
- **Root Cause:** This is caused by a broken React lifecycle loop. Every time the map triggers a directions route query, it toggles a loading state variable (`isSearching: true`). Once the route loads, the state updates (`isSearching: false`), forcing a component re-render. If the coordinates object (`origin`, `destination`) or the map instance ref inside the `useEffect` dependency array changes its reference pointer on every render, the hook fires *again*, creating an endless loop of API fetching and component flashing.
---
## 2. Technical Execution Strategy
To terminate this flickering cycle, we must enforce a strict guard rail on the network/calculation trigger pipeline:
1. **Coordinate Reference Stability:** Deconstruct the input latitude/longitude objects into raw primitive string values (e.g., `origin.lat`, `origin.lng`) inside the hook dependency array to avoid object reference mutations triggering re-renders.
2. **Locking Ref Mechanism:** Implement a mutable React tracking reference (`const queryInProgress = useRef(false)`) to lock the execution window. If a fetch operation is active, block subsequent duplicate queries from firing.
3. **Clean Loading State Termination:** Turn off the search state flag explicitly only *after* the route geometry polyline has completely finished rendering onto the map viewport layout.
---
## 3. Code Refactoring Blueprint
Locate your map navigation layer or modal component (e.g., `LocationNavigationModal.tsx`) and refactor the execution lifecycle loop as defined below:
```typescript
import React, { useEffect, useState, useRef } from 'react';
// Inside your Map Navigation Modal / Layer component wrapper:
export const LocationNavigationModal = ({ isOpen, routeData }) => {
const [isSearchingRoute, setIsSearchingRoute] = useState(false);
const mapInstanceRef = useRef<any>(null);
// CRITICAL FIX 2: Guard mechanism to block concurrent duplicated queries
const fetchLockRef = useRef(false);
// Deconstruct coordinate primitives to secure a stable dependency array
const originLat = routeData?.origin?.lat;
const originLng = routeData?.origin?.lng;
const destLat = routeData?.destination?.lat;
const destLng = routeData?.destination?.lng;
useEffect(() => {
if (!isOpen || !originLat || !originLng || !destLat || !destLng) return;
// If an operation is already locked and active, bail out immediately to prevent loops
if (fetchLockRef.current) return;
const calculateOptimalRoute = async () => {
try {
// 1. Activate loading feedback banner
setIsSearchingRoute(true);
fetchLockRef.current = true; // Engage execution lock
console.log("Fetching route coordinates exactly once...");
// --- YOUR MAP COMPONENT ROUTING LOGIC START ---
// Example: const response = await directionsService.route({...});
// await mapInstanceRef.current.drawPolyline(response);
// --- YOUR MAP COMPONENT ROUTING LOGIC END ---
} catch (error) {
console.error("Failed to compile route optimization maps:", error);
} finally {
// 2. Safe, definitive termination of the tracking states
setIsSearchingRoute(false);
fetchLockRef.current = false; // Disengage execution lock
}
};
calculateOptimalRoute();
// Cleanup phase: Reset execution parameters when inputs dismantle or modal closes
return () => {
fetchLockRef.current = false;
setIsSearchingRoute(false);
};
/* CRITICAL FIX 1: Explicitly tracking primitives only.
Do NOT pass full 'routeData', 'mapInstanceRef' or object literals here!
*/
}, [isOpen, originLat, originLng, destLat, destLng]);
return (
<div className="relative w-full h-full">
{/* Map Content Target Canvas */}
<div id="navigation-viewport-map-canvas" className="w-full h-full" />
{/* RENDER CONTROLLER: Only mount the label if routing calculations are actively processing */}
{isSearchingRoute && (
<div className="absolute top-16 left-4 z-30 bg-white/95 dark:bg-slate-900/95 border border-slate-200 dark:border-slate-800 px-3 py-1.5 rounded-full shadow-lg flex items-center gap-2 animate-pulse">
{/* Circular Loading Spinner Element */}
<div className="w-3.5 h-3.5 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
<span className="text-[11px] font-bold text-slate-700 dark:text-slate-200 uppercase tracking-wider">
ĐANG TÌM ĐƯỜNG TỐI ƯU...
</span>
</div>
)}
</div>
);
};
## 4. Verification & Quality Acceptance Criteria
[ ] Single Instance Trigger: Check console telemetry outputs. When the map modal mounts, the route compilation routine must print its trace log exactly once.
[ ] Flicker Nullification: The "ĐANG TÌM ĐƯỜNG TỐI ƯU..." badge must display smoothly with an animation pulse. It must not shake, blink, flash, or rapid-cycle on and off.
[ ] Deterministic Hiding: As soon as the blue route line draws completely across the map terrain grid layout, the loading badge must cleanly unmount and disappear from view without reappearing unless a new destination node button is clicked.
-110
View File
@@ -1,110 +0,0 @@
import React from 'react';
import { format, differenceInMinutes, parseISO } from 'date-fns';
import { CheckCircle2, Circle, Clock, MapPin, AlertCircle } from 'lucide-react';
import { useTourStore } from './useTourStore.js';
const TimeVariance = ({ planned, actual }: { planned: string, actual: string | null }) => {
if (!actual) return null;
const diff = differenceInMinutes(parseISO(actual), parseISO(planned));
const isLate = diff > 0;
return (
<div className={`flex items-center text-xs font-medium mt-1 ${isLate ? 'text-red-500' : 'text-green-600'}`}>
{isLate ? <AlertCircle className="w-3 h-3 mr-1" /> : <CheckCircle2 className="w-3 h-3 mr-1" />}
<span>
{isLate ? `Trễ ${diff} phút` : diff === 0 ? 'Đúng giờ' : `Sớm ${Math.abs(diff)} phút`}
</span>
</div>
);
};
export const ItineraryTimeline = () => {
const { legs } = useTourStore();
const toggleComplete = async (placeId: number) => {
// Gọi API PATCH /api/v1/places/:id để cập nhật trạng thái
console.log("Toggle status for place:", placeId);
};
return (
<div className="max-w-2xl mx-auto p-4 sm:p-6 bg-gray-50 min-h-screen">
<h2 className="text-2xl font-bold text-gray-800 mb-8 px-2">Lộ trình chuyến đi</h2>
<div className="space-y-8">
{legs.map((leg, legIdx) => (
<div key={leg.id} className="relative">
{/* Leg Header */}
<div className="flex items-center mb-4 px-2">
<div className="bg-blue-600 text-white text-sm font-bold px-3 py-1 rounded-full shadow-sm">
Chặng {leg.sequenceNumber}
</div>
<div className="ml-4 h-[1px] flex-1 bg-gray-200" />
</div>
{/* Vertical Line for the whole leg */}
<div className="absolute left-6 top-10 bottom-0 w-0.5 bg-gray-200 -z-0" />
<div className="space-y-6 ml-2">
{leg.places.map((place) => (
<div key={place.id} className="relative flex group">
{/* Timeline Node */}
<div className="z-10 mt-1.5 mr-4">
<button
onClick={() => toggleComplete(place.id)}
className={`transition-colors duration-200 ${place.isCompleted ? 'text-green-500' : 'text-gray-300 hover:text-blue-500'}`}
>
{place.isCompleted ? (
<CheckCircle2 className="w-8 h-8 bg-white rounded-full" />
) : (
<Circle className="w-8 h-8 bg-white rounded-full fill-white" />
)}
</button>
</div>
{/* Card Content */}
<div className={`flex-1 bg-white p-4 rounded-xl border transition-all duration-200 ${
place.isCompleted ? 'border-green-100 bg-green-50/30' : 'border-gray-100 shadow-sm hover:shadow-md'
}`}>
<div className="flex justify-between items-start">
<div>
<h3 className={`font-semibold text-lg ${place.isCompleted ? 'text-gray-500 line-through' : 'text-gray-800'}`}>
{place.name}
</h3>
<div className="flex items-center text-sm text-gray-500 mt-1">
<MapPin className="w-3 h-3 mr-1" />
<span className="truncate max-w-[200px] sm:max-w-md">{place.address}</span>
</div>
</div>
<div className="text-right flex flex-col items-end">
<div className="flex items-center text-sm font-medium text-blue-600">
<Clock className="w-3 h-3 mr-1" />
{format(parseISO(place.arrivalTime), 'HH:mm')}
</div>
{place.isCompleted && place.completedAt && (
<div className="text-[10px] text-gray-400 mt-1 italic">
Thực tế: {format(parseISO(place.completedAt), 'HH:mm')}
</div>
)}
</div>
</div>
{/* Logic tính toán độ lệch thời gian */}
<TimeVariance planned={place.arrivalTime} actual={place.completedAt} />
</div>
</div>
))}
</div>
{leg.notes && (
<p className="ml-14 mt-4 text-sm text-gray-400 italic">
* {leg.notes}
</p>
)}
</div>
))}
</div>
</div>
);
};
+132
View File
@@ -0,0 +1,132 @@
# To AI Agent: Implement Clickable Google Maps Hyperlinks in PDF Export Table
## 1. Context & Feature Objective
We are upgrading the PDF export functionality within the Yotrip Travel Planner application. Currently, the PDF generates a static table containing location names, addresses, and coordinates.
**Objective:** Automatically turn every location row inside the PDF table into an interactive, clickable hyperlink. When a user clicks on the location cell in the generated PDF document, it must immediately open a browser tab navigating directly to that exact location on Google Maps.
---
## 2. Technical Architecture & Data Strategy
Because `jspdf-autotable` draws text onto a canvas layout, raw HTML tags like `<a>` will fail. We must implement a two-step rendering lifecycle:
1. **Extraction & Styling State:** During the data loop, verify coordinates or address data. Generate a standard universal Google Maps search URL, save it into a coordinate-tracking index object, and transform the raw text cell into a styled cell object (Yale Blue text `#28536b` to mimic an online link).
2. **Link Injection Layer:** Utilize the `didDrawCell` hook callback inside the `doc.autoTable` configuration to position a native invisible link window (`doc.link()`) precisely over the drawn dimensions of that specific location cell.
---
## 3. Code Refactoring Specification
### Step 1: Update Data Preparation Loop
Locate the loop processing `currentTour.legs` inside your PDF generation code block. Initialize a temporary lookup map array named `pdfMapLinks` and refactor the item distribution logic as follows:
```typescript
// Initialize an index mapping registry for hyperlinks before the loop execution
const pdfMapLinks: { [key: number]: string } = {};
if (currentTour?.legs && currentTour.legs.length > 0) {
currentTour.legs.forEach((leg: any, legIdx: number) => {
const legName = leg.note || `Chặng ${legIdx + 1}`;
const legLocations = (leg.locations || []).filter((loc: any) =>
loc && (loc.plannedStart || loc.plannedEnd || loc.name)
);
const startRow = globalRowIndex;
if (legLocations.length > 0) {
legRowRanges[leg.id] = { start: startRow, count: legLocations.length };
legLocations.forEach((loc: any, locIdx: number) => {
const isStartPoint = loc.plannedStart && new Date(loc.plannedStart).getTime() === 0;
const timeSource = isStartPoint ? loc.plannedEnd : loc.plannedStart;
const timeStr = timeSource ? formatDateTime(timeSource) : '';
const locName = loc.name || '';
const addressStr = loc.address || '';
const coordStr = loc.latitude && loc.longitude
? `\n${loc.latitude}, ${loc.longitude}`
: '';
const locationText = [locName, addressStr, coordStr].filter(Boolean).join('\n');
const noteText = loc.note || '';
// 1. Generate standard universal Google Maps URL query pattern
let mapUrl = '';
if (loc.latitude && loc.longitude) {
// Absolute Precision using GPS coordinates
mapUrl = `https://www.google.com/maps/search/?api=1&query=${loc.latitude},${loc.longitude}`;
} else if (addressStr || locName) {
// Text-search query fallback if GPS coordinates are missing
mapUrl = `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(addressStr || locName)}`;
}
// 2. Register current row array position to map lookup index
const currentRowPosition = tableRows.length;
if (mapUrl) {
pdfMapLinks[currentRowPosition] = mapUrl;
}
// 3. Convert raw string into custom styled autoTable Cell configuration object
const locationCellObj = {
content: locationText,
// Apply custom link colors matching theme style #28536b (Yale Blue)
styles: mapUrl ? { textColor: [40, 83, 107], fontStyle: 'bold' as const } : {}
};
tableRows.push([
stt++,
timeStr,
locIdx === 0 ? legName : '',
locationCellObj, // Injected as cell structure object
noteText
]);
globalRowIndex++;
});
} else {
legRowRanges[leg.id] = { start: startRow, count: 1 };
tableRows.push([
stt++,
'',
legName,
'Chưa có địa điểm trong chặng này',
''
]);
globalRowIndex++;
}
});
}
### Step 2: Inject Coordinate-Based Overlay inside doc.autoTable
Locate the core configuration module block where doc.autoTable({}) is invoked. Inject the didDrawCell event engine to deploy the link bounds:
doc.autoTable({
head: [['STT', 'Thời gian', 'Chặng', 'Địa điểm', 'Ghi chú']],
body: tableRows,
theme: 'grid',
// HOOK HANDLER: Overlays active coordinate zones on top of native cells after writing
didDrawCell: (data: any) => {
// Target explicitly: body section only + column index 3 (Location Column)
if (data.section === 'body' && data.column.index === 3) {
const activeRowIndex = data.row.index;
const targetMapUrl = pdfMapLinks[activeRowIndex];
if (targetMapUrl) {
// Build active link container utilizing jsPDF coordinates framework
data.doc.link(
data.cell.x,
data.cell.y,
data.cell.width,
data.cell.height,
{ url: targetMapUrl }
);
}
}
},
styles: { font: 'Roboto' } // Retain existing styling structure
});
## 4. Quality Control & Acceptance Criteria
[ ] Visual Differentiation: Location text cells containing map URLs must render cleanly in bold dark-blue ([40, 83, 107]) while empty state texts stay muted.
[ ] Coordinate Precision: Clicking a location card possessing explicit coordinates (latitude, longitude) must directly map to those absolute markers instead of doing an inaccurate keyword address query.
[ ] Boundary Accuracy: The click targets must fit perfectly inside the grid cell borders. Clicking near the edges of column 3 must register properly, while clicking column 2 (Leg name) or column 4 (Notes) must remain non-reactive.
-162
View File
@@ -1,162 +0,0 @@
import React, { useState, useMemo } from 'react';
import { LogIn, Compass, ArrowRight, Map as MapIcon, UserPlus, ShieldCheck } from 'lucide-react';
import { LoginModal } from './LoginModal.js';
const TRAVEL_IMAGES = [
"https://images.unsplash.com/photo-1476514525535-07fb3b4ae5f1?auto=format&fit=crop&q=80",
"https://images.unsplash.com/photo-1503220317375-aaad61436b1b?auto=format&fit=crop&q=80",
"https://images.unsplash.com/photo-1513581166391-887a96df91e7?auto=format&fit=crop&q=80",
"https://images.unsplash.com/photo-1507525428034-b723cf961d3e?auto=format&fit=crop&q=80",
"https://images.unsplash.com/photo-1469854523086-cc02fe5d8800?auto=format&fit=crop&q=80",
"https://images.unsplash.com/photo-1530789253388-582c481c54b0?auto=format&fit=crop&q=80"
];
interface LandingPageProps {
onContinue?: () => void;
onGoToSignup?: () => void;
onGoToMap?: () => void;
onLoginSuccess?: (user: any) => void;
isInitialSetup?: boolean;
}
export const LandingPage: React.FC<LandingPageProps> = ({ onContinue, onGoToSignup, onGoToMap, onLoginSuccess, isInitialSetup }) => {
const [isLoginModalOpen, setIsLoginModalOpen] = useState(false);
// Chọn ngẫu nhiên một hình ảnh khi người dùng truy cập trang
const backgroundImage = useMemo(() => {
return TRAVEL_IMAGES[Math.floor(Math.random() * TRAVEL_IMAGES.length)];
}, []);
return (
<div className="flex h-screen w-full flex-col md:flex-row overflow-hidden font-sans bg-gray-50 relative">
{/* Logo/Brand Header ở góc trên bên trái */}
<div className="absolute top-8 left-8 z-20 flex items-center gap-3 text-white md:text-white drop-shadow-lg">
<Compass className="w-10 h-10" />
<span className="text-2xl font-black tracking-tighter uppercase">Travel Planner</span>
</div>
{/* Nửa bên trái: Hiển thị một hình ảnh duy nhất */}
<div className="hidden md:block md:w-1/2 relative h-full bg-blue-900">
<img
src={backgroundImage}
className="w-full h-full object-cover opacity-70"
alt="Travel Background"
/>
<div className="absolute inset-0 bg-gradient-to-br from-black/40 to-transparent pointer-events-none" />
{/* Thumbnail Grid ở góc dưới bên trái */}
<div className="absolute bottom-12 left-8 z-20 flex flex-col gap-2 items-start drop-shadow-2xl">
{/* Hàng trên cùng: 1 thumbnail */}
<div className="flex gap-2">
<img src={TRAVEL_IMAGES[0]} className="w-14 h-14 object-cover rounded-xl border-2 border-white/50 transition-transform duration-300 hover:scale-110 cursor-pointer" alt="thumb 1" />
</div>
{/* Hàng thứ 2: 2 thumbnail */}
<div className="flex gap-2">
<img src={TRAVEL_IMAGES[1]} className="w-14 h-14 object-cover rounded-xl border-2 border-white/50 transition-transform duration-300 hover:scale-110 cursor-pointer" alt="thumb 2" />
<img src={TRAVEL_IMAGES[2]} className="w-14 h-14 object-cover rounded-xl border-2 border-white/50 transition-transform duration-300 hover:scale-110 cursor-pointer" alt="thumb 3" />
</div>
{/* Hàng dưới cùng: 3 thumbnail */}
<div className="flex gap-2">
<img src={TRAVEL_IMAGES[3]} className="w-14 h-14 object-cover rounded-xl border-2 border-white/50 transition-transform duration-300 hover:scale-110 cursor-pointer" alt="thumb 4" />
<img src={TRAVEL_IMAGES[4]} className="w-14 h-14 object-cover rounded-xl border-2 border-white/50 transition-transform duration-300 hover:scale-110 cursor-pointer" alt="thumb 5" />
<img src={TRAVEL_IMAGES[5]} className="w-14 h-14 object-cover rounded-xl border-2 border-white/50 transition-transform duration-300 hover:scale-110 cursor-pointer" alt="thumb 6" />
</div>
</div>
</div>
{/* Nửa bên phải: Nội dung chào mừng và Hành động */}
<div className="flex-1 md:w-1/2 flex flex-col justify-center items-center bg-white p-8 lg:p-16 h-full overflow-y-auto">
<div className="max-w-md w-full space-y-12">
<div className="space-y-4">
{isInitialSetup ? (
<>
<div className="inline-flex items-center px-3 py-1 rounded-full bg-amber-50 text-amber-700 text-xs font-bold border border-amber-100 mb-2">
<ShieldCheck className="w-3 h-3 mr-1" /> CẤU HÌNH HỆ THỐNG LẦN ĐU
</div>
<h1 className="text-4xl font-extrabold text-gray-900 leading-tight">
Thiết lập Quản trị viên
</h1>
<p className="text-lg text-gray-500 leading-relaxed">
Chào mừng! Hệ thống vừa đưc cài đt. Vui lòng tạo tài khoản đu tiên đ quản vận hành trang web.
</p>
</>
) : (
<>
<h1 className="text-4xl font-extrabold text-gray-900 leading-tight">
Lên kế hoạch cho hành trình tiếp theo
</h1>
<p className="text-lg text-gray-500 leading-relaxed">
Khám phá các đa điểm nổi bật qua bản đ cộng đng hoặc đăng nhập đ bắt đu tự tạo chuyến đi cho riêng mình.
</p>
</>
)}
</div>
<div className="grid grid-cols-1 gap-4">
{isInitialSetup && (
<button
onClick={onGoToSignup}
className="flex items-center justify-center gap-3 bg-blue-600 hover:bg-blue-700 text-white font-bold py-5 px-8 rounded-2xl transition-all shadow-xl shadow-blue-100 animate-bounce-subtle mb-2"
>
<UserPlus className="w-5 h-5" />
Bắt đu thiết lập ngay
</button>
)}
<button
onClick={onGoToMap}
className="flex items-center justify-center gap-3 bg-indigo-600 hover:bg-indigo-700 text-white font-bold py-4 px-8 rounded-2xl transition-all shadow-lg shadow-indigo-100"
>
<MapIcon className="w-5 h-5" />
Khám phá các hành trình du lịch
</button>
<div className="grid grid-cols-2 gap-4">
<button
onClick={() => setIsLoginModalOpen(true)}
className="flex items-center justify-center gap-2 bg-blue-50 hover:bg-blue-100 text-blue-700 font-bold py-4 rounded-2xl transition-all"
>
<LogIn className="w-5 h-5" />
Đăng nhập
</button>
<button
onClick={onGoToSignup}
className="flex items-center justify-center gap-2 bg-gray-50 hover:bg-gray-100 text-gray-700 font-bold py-4 rounded-2xl border border-gray-100 transition-all"
>
<UserPlus className="w-5 h-5" />
Đăng
</button>
</div>
<button
onClick={onContinue}
className="flex items-center justify-center gap-3 text-gray-400 hover:text-gray-600 py-2 transition-all text-sm font-medium"
>
Tiếp tục tham quan nh
<ArrowRight className="w-5 h-5" />
</button>
</div>
<div className="pt-12 flex items-center gap-4 text-sm text-gray-400 border-t border-gray-100">
<div className="flex -space-x-3">
{[1, 2, 3, 4].map(i => (
<img key={i} className="w-10 h-10 rounded-full border-4 border-white" src={`https://i.pravatar.cc/100?img=${i+20}`} alt="user" />
))}
</div>
<p className="font-medium">Tham gia cùng +2,400 người du lịch khác</p>
</div>
</div>
</div>
{/* Login Modal Component */}
<LoginModal
isOpen={isLoginModalOpen}
onClose={() => setIsLoginModalOpen(false)}
onSwitchToSignup={onGoToSignup}
onLoginSuccess={onLoginSuccess}
/>
</div>
);
};
-141
View File
@@ -1,141 +0,0 @@
import React, { useState } from 'react';
import { X, Mail, Lock, ArrowRight, Loader2 } from 'lucide-react';
interface LoginModalProps {
isOpen: boolean;
onClose: () => void;
onSwitchToSignup?: () => void;
onLoginSuccess?: (user: any) => void;
}
export const LoginModal: React.FC<LoginModalProps> = ({ isOpen, onClose, onSwitchToSignup, onLoginSuccess }) => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
if (!isOpen) return null;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setIsLoading(true);
try {
const API_BASE = `http://${window.location.hostname}:3001`;
const response = await fetch(`${API_BASE}/api/v1/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || 'Đăng nhập thất bại');
}
// Lưu phiên đăng nhập
localStorage.setItem('token', data.access_token);
localStorage.setItem('user', JSON.stringify(data.user));
if (onLoginSuccess) {
onLoginSuccess(data.user);
}
onClose();
} catch (err: any) {
setError(err.message);
} finally {
setIsLoading(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6">
{/* Backdrop */}
<div
className="absolute inset-0 bg-gray-900/60 backdrop-blur-sm animate-in fade-in duration-300"
onClick={onClose}
/>
{/* Modal Content */}
<div className="relative w-full max-w-md bg-white rounded-3xl shadow-2xl overflow-hidden animate-in zoom-in-95 duration-300">
<div className="p-8 sm:p-10">
<div className="flex justify-between items-start mb-8">
<div>
<h2 className="text-3xl font-bold text-gray-900">Đăng nhập</h2>
<p className="text-gray-500 mt-2">Chào mừng bạn quay trở lại!</p>
</div>
<button
onClick={onClose}
className="p-2 hover:bg-gray-100 rounded-full transition-colors text-gray-400 hover:text-gray-600"
>
<X className="w-6 h-6" />
</button>
</div>
{error && (
<div className="mb-6 p-4 bg-red-50 text-red-600 rounded-2xl text-sm font-bold animate-in fade-in slide-in-from-top-1">
{error}
</div>
)}
<form className="space-y-6" onSubmit={handleSubmit}>
<div className="space-y-2">
<label className="text-sm font-semibold text-gray-700 ml-1">Email</label>
<div className="relative group">
<Mail className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 group-focus-within:text-blue-500 transition-colors" />
<input
type="email"
placeholder="name@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="w-full pl-12 pr-4 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all"
/>
</div>
</div>
<div className="space-y-2">
<div className="flex justify-between items-center px-1">
<label className="text-sm font-semibold text-gray-700">Mật khẩu</label>
<button className="text-xs font-bold text-blue-600 hover:text-blue-700">Quên mật khẩu?</button>
</div>
<div className="relative group">
<Lock className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 group-focus-within:text-blue-500 transition-colors" />
<input
type="password"
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="w-full pl-12 pr-4 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all"
/>
</div>
</div>
<button
type="submit"
disabled={isLoading}
className="w-full flex items-center justify-center gap-2 bg-blue-600 hover:bg-blue-700 text-white font-bold py-4 rounded-2xl shadow-lg shadow-blue-200 transition-all active:scale-[0.98] disabled:opacity-50"
>
{isLoading ? 'Đang xử lý...' : 'Đăng nhập'}
{isLoading ? <Loader2 className="w-5 h-5 animate-spin" /> : <ArrowRight className="w-5 h-5" />}
</button>
</form>
<div className="mt-10 pt-8 border-t border-gray-100 text-center">
<p className="text-gray-500">
Chưa tài khoản?{' '}
<button
onClick={() => { onClose(); onSwitchToSignup?.(); }}
className="font-bold text-blue-600 hover:underline"
>
Tạo tài khoản ngay
</button>
</p>
</div>
</div>
</div>
</div>
);
};
+133
View File
@@ -0,0 +1,133 @@
# Travel Planning - Multi-User Travel Itinerary Management System
A comprehensive travel planning application that enables collaborative itinerary planning, expense tracking, and photo sharing for travel groups.
## Features
- **Tour Management**: Create and manage travel tours with multiple legs (stages) and locations
- **Multi-user Collaboration**: Invite friends to join tours with role-based access control
- **Interactive Maps**: Visual tour planning with Leaflet.js integration
- **Expense Tracking**: Automatic cost splitting with configurable adult/child discounts
- **Photo Sharing**: Secure photo album with privacy controls (PUBLIC, TOUR_ONLY, PRIVATE)
- **Real-time Navigation**: Live GPS tracking and route optimization using OSRM API
## Tech Stack
| Layer | Technology | Purpose |
|-------|-----------|---------|
| **Frontend** | React + Vite | Single Page Application with fast HMR |
| | TailwindCSS | Utility-first CSS framework |
| | Zustand | Lightweight state management |
| | Leaflet.js | Interactive map rendering |
| **Backend** | NestJS | Scalable Node.js framework |
| | JWT | Authentication & authorization |
| | Prisma ORM | Type-safe database access |
| | PostgreSQL + PostGIS | Spatial database for geographic data |
## Project Structure
```
travelplanning/
├── backend/ # Backend API server
│ ├── src/
│ │ ├── auth/ # Authentication modules
│ │ ├── main.ts # NestJS entry point
│ │ └── v1/ # API v1 endpoints
│ └── prisma/
│ └── schema.prisma # Database schema
├── frontend/ # React frontend
│ └── src/
│ ├── pages/ # Main pages
│ ├── components/ # Reusable UI components
│ ├── hooks/ # Custom React hooks
│ └── store/ # Zustand stores
├── docs/ # Documentation
│ ├── ARCHITECTURE.md # System architecture
│ └── UITourDesign.md # UI design specifications
└── .env # Environment variables
```
## Database Schema
The application uses PostgreSQL with Prisma ORM. Key models include:
- **User**: Registered users with admin capability
- **Tour**: Travel itineraries with date ranges and participant management
- **Leg**: Stages within a tour (ordered sequence)
- **Location**: Geographic points with timing and status tracking
- **Expense**: Cost tracking linked to legs/locations
- **Photo**: Media storage with privacy controls
- **TourParticipant**: Many-to-many relationship with role-based permissions
### User Roles
| Role | Permissions |
|------|-------------|
| OWNER | Full access to all features |
| MANAGER | Can edit tour content and manage members |
| MEMBER | View tour and participate, access financial data |
| MEMBER_NO_FINANCE | View tour only, no financial access |
| VIEWER_ONLY | Read-only access to itinerary and photos |
## API Endpoints
### Tours
- `GET /api/v1/tours` - Get all public tours
- `POST /api/v1/tours` - Create new tour
- `GET /api/v1/tours/:id` - Get tour details
- `PUT /api/v1/tours/:id` - Update tour
### Authentication
- `POST /api/v1/auth/login` - User login
- `POST /api/v1/auth/register` - User registration
- `POST /api/v1/auth/promote-admin` - Admin role promotion (with secret key)
### Photos
- `GET /api/v1/public-photos` - Get public photos
- `POST /api/v1/tours/:id/photos` - Upload tour photos
## Quick Start
### Prerequisites
- Node.js 18+
- PostgreSQL with PostGIS extension
### Installation
```bash
# Install dependencies
npm install
cd frontend && npm install
cd ../backend && npm install
# Set up database
npx prisma migrate dev
npx prisma generate
# Start development servers
npm run dev # Frontend (Vite)
npm run start:backend # Backend (NestJS)
```
### Environment Variables
Create `.env` in the root directory:
```env
DATABASE_URL="postgresql://user:password@localhost:5432/traveldb"
JWT_SECRET="your-secret-key"
```
## Mobile Optimization
The application is built mobile-first with support for:
- Safe area insets for notch displays (iOS/Android)
- Touch gestures for map interactions
- Responsive layouts for all screen sizes
- Device orientation and compass integration
## Documentation
See the `docs/` directory for detailed documentation:
- [ARCHITECTURE.md](docs/ARCHITECTURE.md) - System architecture and data models
- [UITourDesign.md](docs/UITourDesign.md) - UI design specifications
-163
View File
@@ -1,163 +0,0 @@
import React, { useState } from 'react';
import { User, Mail, Lock, ArrowRight, ChevronLeft, Compass } from 'lucide-react';
interface SignupPageProps {
onBack: () => void;
onSuccess: () => void;
}
export const SignupPage: React.FC<SignupPageProps> = ({ onBack, onSuccess }) => {
const [formData, setFormData] = useState({
name: '',
email: '',
password: '',
confirmPassword: ''
});
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (formData.password !== formData.confirmPassword) {
setError('Mật khẩu xác nhận không khớp');
return;
}
setIsLoading(true);
try {
const API_BASE = `http://${window.location.hostname}:3001`;
const response = await fetch(`${API_BASE}/api/v1/auth/signup`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: formData.email,
password: formData.password,
name: formData.name
}),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || 'Đăng ký thất bại');
}
onSuccess();
} catch (err: any) {
setError(err.message);
} finally {
setIsLoading(false);
}
};
return (
<div className="flex h-screen w-full overflow-hidden font-sans bg-white">
{/* Nửa bên trái: Hình ảnh decor (Đồng bộ với LandingPage) */}
<div className="hidden lg:block lg:w-1/2 relative bg-blue-900">
<img
src="https://images.unsplash.com/photo-1476514525535-07fb3b4ae5f1?q=80&w=2070&auto=format&fit=crop"
alt="Travel background"
className="absolute inset-0 h-full w-full object-cover opacity-50"
/>
<div className="absolute inset-0 bg-gradient-to-br from-blue-600/40 to-transparent" />
<div className="absolute top-12 left-12 flex items-center gap-2 text-white z-10">
<Compass className="w-8 h-8" />
<span className="text-2xl font-black uppercase tracking-tighter">Travel Planner</span>
</div>
<div className="absolute bottom-20 left-12 text-white z-10 max-w-md">
<h2 className="text-4xl font-bold mb-4">Bắt đu hành trình của riêng bạn.</h2>
<p className="text-blue-100 opacity-80">Tạo tài khoản đ lưu lại những kế hoạch du lịch tuyệt vời nhất cùng bạn .</p>
</div>
</div>
{/* Nửa bên phải: Form đăng ký */}
<div className="flex-1 flex flex-col justify-center px-8 sm:px-16 lg:px-24 py-12 overflow-y-auto">
<div className="max-w-md w-full mx-auto">
<button onClick={onBack} className="flex items-center text-gray-400 hover:text-blue-600 mb-8 transition-colors font-medium">
<ChevronLeft className="w-5 h-5" /> Quay lại
</button>
<div className="mb-10">
<h1 className="text-3xl font-extrabold text-gray-900">Tạo tài khoản mới</h1>
<p className="text-gray-500 mt-2 font-medium">Khám phá các tính năng lập kế hoạch chuyên nghiệp.</p>
</div>
{error && (
<div className="mb-6 p-4 bg-red-50 text-red-600 rounded-2xl text-sm font-bold animate-in fade-in slide-in-from-top-1">
{error}
</div>
)}
<form className="space-y-5" onSubmit={handleSubmit}>
<div className="space-y-1.5">
<label className="text-sm font-bold text-gray-700 ml-1">Họ tên</label>
<div className="relative group">
<User className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 group-focus-within:text-blue-500 transition-colors" />
<input
required
type="text"
placeholder="Nguyễn Văn A"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="w-full pl-12 pr-4 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all"
/>
</div>
</div>
<div className="space-y-1.5">
<label className="text-sm font-bold text-gray-700 ml-1">Email</label>
<div className="relative group">
<Mail className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 group-focus-within:text-blue-500 transition-colors" />
<input
required
type="email"
placeholder="email@example.com"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
className="w-full pl-12 pr-4 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all"
/>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-1.5">
<label className="text-sm font-bold text-gray-700 ml-1">Mật khẩu</label>
<input
required
type="password"
placeholder="••••••••"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
className="w-full px-4 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all"
/>
</div>
<div className="space-y-1.5">
<label className="text-sm font-bold text-gray-700 ml-1">Xác nhận</label>
<input
required
type="password"
placeholder="••••••••"
value={formData.confirmPassword}
onChange={(e) => setFormData({ ...formData, confirmPassword: e.target.value })}
className="w-full px-4 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all"
/>
</div>
</div>
<button
disabled={isLoading}
type="submit"
className="w-full flex items-center justify-center gap-2 bg-blue-600 hover:bg-blue-700 text-white font-bold py-5 rounded-2xl shadow-xl shadow-blue-100 transition-all active:scale-[0.98] disabled:opacity-50 mt-4"
>
{isLoading ? 'Đang xử lý...' : 'Tạo tài khoản'}
{!isLoading && <ArrowRight className="w-5 h-5" />}
</button>
</form>
</div>
</div>
</div>
);
};
-137
View File
@@ -1,137 +0,0 @@
import React, { useState, useEffect } from 'react';
import { ItineraryTimeline } from './ItineraryTimeline.js';
import { ExpenseManager } from './ExpenseManager.js';
import { useTourStore } from './useTourStore.js';
import {
Map as MapIcon,
Wallet,
Image as ImageIcon,
Calendar,
Users,
ChevronLeft
} from 'lucide-react';
export const TourDetailPage = () => {
const [activeTab, setActiveTab] = useState<'plan' | 'expense' | 'photo'>('plan');
const { currentTour, fetchTour, userRole } = useTourStore();
useEffect(() => {
// Gọi ID 1 (Dữ liệu từ file seed) để kiểm tra
fetchTour(1);
}, [fetchTour]);
// Định nghĩa các tab dựa trên quyền hạn (RBAC) từ store
const tabs = [
{ id: 'plan', label: 'Lộ trình', icon: MapIcon, visible: ['OWNER', 'EDITOR', 'MEMBER_PLAN_ONLY'].includes(userRole || '') },
{ id: 'expense', label: 'Chi phí', icon: Wallet, visible: ['OWNER', 'EDITOR'].includes(userRole || '') },
{ id: 'photo', label: 'Ảnh', icon: ImageIcon, visible: true },
].filter(t => t.visible);
// Tự động chuyển tab nếu người dùng không có quyền xem 'plan'
useEffect(() => {
if (userRole === 'MEMBER_PHOTO_ONLY') {
setActiveTab('photo');
}
}, [userRole]);
// Mock dữ liệu header (Trong thực tế sẽ lấy từ currentTour)
const tourInfo = {
title: currentTour?.title || "Hành trình khám phá TP.HCM",
date: "20 - 21 Tháng 11, 2023",
members: 5,
budget: "2.500.000 VND"
};
return (
<div className="min-h-screen bg-white">
{/* Top Navigation Bar */}
<div className="sticky top-0 z-30 bg-white border-b border-gray-100 px-4 py-3 flex items-center justify-between">
<button className="p-2 hover:bg-gray-100 rounded-full transition-colors">
<ChevronLeft className="w-6 h-6 text-gray-600" />
</button>
<h1 className="text-lg font-bold text-gray-800 truncate px-4">
{tourInfo.title}
</h1>
<div className="w-10" /> {/* Spacer */}
</div>
{/* Tour Header Info */}
<div className="bg-blue-600 text-white p-6 pb-20">
<div className="max-w-2xl mx-auto space-y-4">
<div className="flex flex-wrap gap-4 text-sm opacity-90">
<div className="flex items-center">
<Calendar className="w-4 h-4 mr-1.5" />
{tourInfo.date}
</div>
<div className="flex items-center">
<Users className="w-4 h-4 mr-1.5" />
{tourInfo.members} thành viên
</div>
</div>
<div className="flex items-baseline gap-2">
<span className="text-3xl font-bold">{tourInfo.budget}</span>
<span className="text-blue-100 text-sm">dự kiến</span>
</div>
</div>
</div>
{/* Main Content Area */}
<div className="max-w-2xl mx-auto -mt-12 px-4 pb-24">
{/* Tab Switcher */}
<div className="bg-white rounded-2xl shadow-xl border border-gray-100 p-1 flex mb-6">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id as any)}
className={`flex-1 flex items-center justify-center py-3 rounded-xl text-sm font-semibold transition-all ${
activeTab === tab.id
? 'bg-blue-50 text-blue-600 shadow-sm'
: 'text-gray-400 hover:text-gray-600'
}`}
>
<tab.icon className={`w-4 h-4 mr-2 ${activeTab === tab.id ? 'animate-pulse' : ''}`} />
{tab.label}
</button>
))}
</div>
{/* Tab Panels */}
<div className="transition-opacity duration-300">
{activeTab === 'plan' && (
<div className="animate-in fade-in slide-in-from-bottom-4">
<ItineraryTimeline />
</div>
)}
{activeTab === 'expense' && (
<div className="animate-in fade-in slide-in-from-bottom-4">
<ExpenseManager />
</div>
)}
{activeTab === 'photo' && (
<div className="grid grid-cols-3 gap-2 animate-in fade-in">
{[1, 2, 3, 4, 5, 6].map((i) => (
<div key={i} className="aspect-square bg-gray-100 rounded-lg overflow-hidden relative group">
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors" />
<img
src={`https://picsum.photos/seed/${i + 10}/400/400`}
alt="Tour photo"
className="w-full h-full object-cover"
/>
</div>
))}
</div>
)}
</div>
</div>
{/* Floating Action Button (Mobile) */}
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-40">
<button className="bg-blue-600 text-white px-6 py-3 rounded-full shadow-lg hover:bg-blue-700 transition-all flex items-center font-bold">
{activeTab === 'plan' ? 'Thêm địa điểm' : activeTab === 'expense' ? 'Thêm chi phí' : 'Đăng ảnh'}
</button>
</div>
</div>
);
};
+134
View File
@@ -0,0 +1,134 @@
# To AI Agent: Audit Location Lock Bug and Refactor GPS Tracking into a Toggle Stateful Button
## 1. Context & Problem Statement
Currently, in our travel planner application map engine (on pages like `TourNavigationPage.tsx`, `LocationNavigationModal.tsx`, or map utilities), the viewport is continuously forced to lock onto the user's live GPS position. This architecture severely damages mobile UX because:
1. It prevents users from manually dragging, panning, or scouting other areas of the map terrain.
2. There is no control interface to temporarily mute or disable live GPS tracking.
**Objective:** - Run a global audit across the entire codebase to locate functions driving this forced-center trap (e.g., custom hooks, `requestGpsPosition`, native geolocation callbacks, or reactive map state updates).
- Refactor the logic so that live tracking is bound strictly to an independent toggle state button. The map must **ONLY** lock/re-center on the user's coordinates when this tracking toggle button is actively switched **ON**.
---
## 2. Phase 1: Codebase Audit Plan (Where to Search)
Scan the entire project repository (specifically `/frontend/src`) using code search patterns to intercept the lock mechanism. Target the following files and keywords:
### Key Target Files to Inspect:
- `frontend/src/pages/TourNavigationPage.tsx`
- `frontend/src/components/LocationNavigationModal.tsx`
- Any custom hooks or contexts handling geography, such as `useGeolocation.ts`, `useMap.ts`, or generic map setup wrappers.
### Regex & Keyword Global Search Queries:
- Search for native background watchers: `navigator.geolocation.watchPosition` or `navigator.geolocation.getCurrentPosition`
- Search for custom map-centering loops: `requestGpsPosition`, `followUser`, `centerToUser`
- Search for viewport mutation commands specific to our active map engine stack:
- **Leaflet:** `.setView(`, `.panTo(`, `center={`
- **Mapbox GL JS:** `.flyTo(`, `.easeTo(`, `.jumpTo(`
---
## 3. Phase 2: Technical Refactoring Blueprint
Once the tracking logic code blocks are isolated from Phase 1, implement the structural state safety rails below:
### Step 1: Initialize the Tracking State Guard
Introduce a state hook controller (`isTrackingLocation`) to manage whether the view should actively mirror device coordinates:
```typescript
// Add inside the map controller/page container component
const [isTrackingLocation, setIsTrackingLocation] = useState(false);
const watchIdRef = useRef<number | null>(null);
### Step 2: Encapsulate the Geolocation Watcher Handler
Wrap your positioning tracking engine loop inside a conditional check governed directly by the state guard. Ensure that if the tracking state is disabled, the background watcher cleanly unmounts:
useEffect(() => {
if (isTrackingLocation) {
if (navigator.geolocation) {
console.log("GPS Location Tracking engaged. Syncing viewport to center...");
watchIdRef.current = navigator.geolocation.watchPosition(
(position) => {
const { latitude, longitude, heading } = position.coords;
if (mapRef.current) {
// ✅ CORRECTION: Viewport ONLY repositions center when tracking button is active
mapRef.current.easeTo({
center: [longitude, latitude],
zoom: 16, // Lock to comfortable navigation zoom level
duration: 600
});
}
},
(error) => console.error("GPS stream tracking lost:", error),
{ enableHighAccuracy: true }
);
}
} else {
// Clean up tracking process instantly when toggled OFF
if (watchIdRef.current !== null) {
navigator.geolocation.clearWatch(watchIdRef.current);
watchIdRef.current = null;
console.log("GPS Location Tracking disabled. Map control released to user.");
}
}
return () => {
if (watchIdRef.current !== null) navigator.geolocation.clearWatch(watchIdRef.current);
};
}, [isTrackingLocation]);
### Step 3: Implement Gesture Detection (UX Safety Rail)
If the user manually drags the screen while tracking is active, the tracking state must automatically toggle OFF so the viewport doesn't fight against the user's finger movements:
useEffect(() => {
if (!mapRef.current) return;
const map = mapRef.current;
const breakTrackingOnGesture = () => {
if (isTrackingLocation) {
console.log("User touch map interaction detected. Disengaging auto-center lock.");
setIsTrackingLocation(false); // Automatically drop tracking flag on map pan/zoom
}
};
map.on('dragstart', breakTrackingOnGesture);
map.on('zoomstart', breakTrackingOnGesture);
map.on('movestart', breakTrackingOnGesture);
return () => {
map.off('dragstart', breakTrackingOnGesture);
map.off('zoomstart', breakTrackingOnGesture);
map.off('movestart', breakTrackingOnGesture);
};
}, [isTrackingLocation]);
### Step 4: Render the UI Toggle Button UI Component
Deploy a new independent floating button on top of the map canvas workspace (placed at bottom-24 right-6, just right above your custom Compass button layout):
{/* FLOATING LIVE GPS TRACKING CENTER LOCK BUTTON */}
<button
onClick={() => setIsTrackingLocation(!isTrackingLocation)}
className={`absolute bottom-24 right-6 z-40 w-14 h-14 rounded-full border-2 flex items-center justify-center shadow-2xl transition-all active:scale-95 ${
isTrackingLocation
? 'bg-green-600 border-green-400 text-white animate-pulse'
: 'bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-700 text-slate-600 dark:text-green-500'
}`}
title={isTrackingLocation ? "Tắt tự động định tâm vị trí" : "Bật tự động định tâm theo vị trí của bạn"}
>
{/* Replace Crosshair icon element with your active layout icon package asset */}
<svg xmlns="[http://www.w3.org/2000/svg](http://www.w3.org/2000/svg)" className="w-7 h-7" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</button>
## 4. Verification & Quality Acceptance Criteria
[ ] Code Erasure Verification: Confirm that old continuous loops or uncontrolled recursive .setView/.easeTo methods triggered instantly on map load are fully removed or properly contained inside the state block.
[ ] Default State Freedom: Upon opening the map page path, tracking must default to OFF. Users must be able to drag the map anywhere in the world without the screen snapped or yanked back to their physical house position.
[ ] Toggle Activation Centering: Pressing the new GPS tracking button must instantly engage the animation, center the map view directly on top of the user blue dot icon, and follow them smoothly if they move.
[ ] Manual Override Interception: Turn tracking ON. Drag the map manually with a finger gesture. Verify that the tracking button instantly changes style states back to deactivated and tracking shuts down cleanly.
-154
View File
@@ -1,154 +0,0 @@
import React, { useState, useEffect } from 'react';
import { X, User, Trash2, Shield, ShieldAlert, Lock, Unlock, Loader2, UserPlus } from 'lucide-react';
interface UserManagementModalProps {
isOpen: boolean;
onClose: () => void;
}
export const UserManagementModal: React.FC<UserManagementModalProps> = ({ isOpen, onClose }) => {
const [users, setUsers] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const fetchUsers = async () => {
setLoading(true);
try {
const API_BASE = `http://${window.location.hostname}:3001`;
const response = await fetch(`${API_BASE}/api/v1/users`, {
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
});
if (!response.ok) throw new Error('Không thể tải danh sách người dùng');
const data = await response.json();
setUsers(data);
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
useEffect(() => {
if (isOpen) fetchUsers();
}, [isOpen]);
const handleToggleBlock = async (id: number) => {
try {
const API_BASE = `http://${window.location.hostname}:3001`;
await fetch(`${API_BASE}/api/v1/users/block/${id}`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
});
fetchUsers();
} catch (err) {
alert('Lỗi khi thay đổi trạng thái block');
}
};
const handleDelete = async (id: number) => {
if (!confirm('Bạn có chắc chắn muốn xóa người dùng này?')) return;
try {
const API_BASE = `http://${window.location.hostname}:3001`;
const res = await fetch(`${API_BASE}/api/v1/users/${id}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.message);
}
fetchUsers();
} catch (err: any) {
alert(err.message);
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-[2000] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-gray-900/60 backdrop-blur-sm" onClick={onClose} />
<div className="relative w-full max-w-4xl bg-white rounded-3xl shadow-2xl overflow-hidden flex flex-col max-h-[90vh]">
<div className="p-6 border-b border-gray-100 flex justify-between items-center bg-gray-50/50">
<div>
<h2 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
<Shield className="w-6 h-6 text-blue-600" /> Quản người dùng
</h2>
<p className="text-sm text-gray-500">Quản trị viên quyền thêm, sửa, xóa hoặc khóa tài khoản.</p>
</div>
<button onClick={onClose} className="p-2 hover:bg-gray-200 rounded-full transition-colors">
<X className="w-6 h-6 text-gray-400" />
</button>
</div>
<div className="flex-1 overflow-y-auto p-6">
{loading ? (
<div className="flex justify-center py-20"><Loader2 className="w-10 h-10 animate-spin text-blue-600" /></div>
) : error ? (
<div className="p-4 bg-red-50 text-red-600 rounded-xl font-bold">{error}</div>
) : (
<table className="w-full text-left border-collapse">
<thead>
<tr className="text-gray-400 text-xs uppercase tracking-wider border-b border-gray-100">
<th className="pb-4 font-bold px-2">Người dùng</th>
<th className="pb-4 font-bold">Vai trò</th>
<th className="pb-4 font-bold">Trạng thái</th>
<th className="pb-4 font-bold text-right">Thao tác</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-50">
{users.map(u => (
<tr key={u.id} className="group hover:bg-gray-50/50 transition-colors">
<td className="py-4 px-2">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-blue-100 flex items-center justify-center text-blue-600 font-bold">
{u.name?.charAt(0) || <User className="w-5 h-5" />}
</div>
<div>
<div className="font-bold text-gray-900">{u.name || 'N/A'}</div>
<div className="text-xs text-gray-400">{u.email}</div>
</div>
</div>
</td>
<td className="py-4">
{u.isAdmin ? (
<span className="px-2 py-1 bg-purple-50 text-purple-600 text-[10px] font-black rounded-md border border-purple-100">ADMIN</span>
) : (
<span className="px-2 py-1 bg-gray-50 text-gray-500 text-[10px] font-black rounded-md border border-gray-100">USER</span>
)}
</td>
<td className="py-4">
{u.isBlocked ? (
<span className="flex items-center gap-1 text-red-500 text-xs font-bold"><ShieldAlert className="w-3 h-3" /> Đã khóa</span>
) : (
<span className="text-green-500 text-xs font-bold">Đang hoạt đng</span>
)}
</td>
<td className="py-4 text-right">
<div className="flex justify-end gap-2">
<button
onClick={() => handleToggleBlock(u.id)}
className={`p-2 rounded-xl transition-all ${u.isBlocked ? 'bg-green-50 text-green-600 hover:bg-green-100' : 'bg-amber-50 text-amber-600 hover:bg-amber-100'}`}
title={u.isBlocked ? 'Mở khóa' : 'Khóa tài khoản'}
>
{u.isBlocked ? <Unlock className="w-4 h-4" /> : <Lock className="w-4 h-4" />}
</button>
<button
onClick={() => handleDelete(u.id)}
className="p-2 bg-red-50 text-red-600 rounded-xl hover:bg-red-100 transition-all"
title="Xóa người dùng"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
</div>
);
};
+6
View File
@@ -0,0 +1,6 @@
node_modules
dist
npm-debug.log
.env
.git
.gitignore
+38
View File
@@ -0,0 +1,38 @@
FROM node:20-alpine AS base
# Install openssl for Prisma
RUN apk add --no-cache openssl
WORKDIR /usr/src/app
COPY package*.json ./
COPY prisma ./prisma/
# Development stage
FROM base AS development
RUN npm install
COPY . .
RUN npx prisma generate
EXPOSE 3001
CMD ["npm", "run", "start:dev"]
# Build stage for production
FROM base AS build
RUN npm install
COPY . .
RUN npx prisma generate
RUN npm run build
RUN npm prune --production
# Production stage
FROM node:20-alpine AS production
RUN apk add --no-cache openssl
WORKDIR /usr/src/app
COPY package*.json ./
COPY --from=build /usr/src/app/node_modules ./node_modules
COPY --from=build /usr/src/app/dist ./dist
COPY --from=build /usr/src/app/prisma ./prisma
EXPOSE 3001
CMD ["node", "dist/src/main.js"]
+39
View File
@@ -0,0 +1,39 @@
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
async function main() {
const tourId = '84d84c21-c12b-4a18-9655-043c85f5c0c5';
const ownerUserId = '5b2053bb-f523-4a11-817c-f47fef7322bb'; // owner@travel.com
// Check if owner@travel.com is already a participant
const existing = await prisma.tourParticipant.findFirst({
where: { tourId, userId: ownerUserId }
});
if (existing) {
await prisma.tourParticipant.update({
where: { id: existing.id },
data: { role: 'OWNER' }
});
console.log('Updated existing participant to OWNER');
} else {
// Demote current owner to MEMBER or just keep them
const result = await prisma.tourParticipant.create({
data: {
tourId,
userId: ownerUserId,
role: 'OWNER'
}
});
console.log('Created new OWNER participant:', result);
}
// Update tour createdById to ownerUserId
await prisma.tour.update({
where: { id: tourId },
data: { createdById: ownerUserId }
});
console.log('Updated tour creator to owner@travel.com');
}
main().catch(console.error).finally(() => prisma.$disconnect());
+7
View File
@@ -0,0 +1,7 @@
import { OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
export declare class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
constructor();
onModuleInit(): Promise<void>;
onModuleDestroy(): Promise<void>;
}
+34
View File
@@ -0,0 +1,34 @@
"use strict";
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
var __metadata = (this && this.__metadata) || function (k, v) {
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.PrismaService = void 0;
const common_1 = require("@nestjs/common");
const client_1 = require("@prisma/client");
const adapter_pg_1 = require("@prisma/adapter-pg");
const pg_1 = require("pg");
let PrismaService = class PrismaService extends client_1.PrismaClient {
constructor() {
console.log('--- [PRISMA CHECK] ---');
console.log('DATABASE_URL nhận được:', process.env.DATABASE_URL ? 'ĐÃ ĐỌC THÀNH CÔNG ✔️' : 'VẪN BỊ UNDEFINED ❌');
console.log('----------------------');
const pool = new pg_1.Pool({ connectionString: process.env.DATABASE_URL });
const adapter = new adapter_pg_1.PrismaPg(pool);
super({ adapter });
}
async onModuleInit() { await this.$connect(); }
async onModuleDestroy() { await this.$disconnect(); }
};
exports.PrismaService = PrismaService;
exports.PrismaService = PrismaService = __decorate([
(0, common_1.Injectable)(),
__metadata("design:paramtypes", [])
], PrismaService);
//# sourceMappingURL=prisma.service.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"prisma.service.js","sourceRoot":"","sources":["../../prisma/prisma.service.ts"],"names":[],"mappings":";;;;;;;;;;;;AAAA,2CAA2E;AAC3E,2CAA8C;AAC9C,mDAA8C;AAC9C,2BAA0B;AAGnB,IAAM,aAAa,GAAnB,MAAM,aAAc,SAAQ,qBAAY;IAC7C;QACE,OAAO,CAAC,GAAG,CAAC,wBAAwB,CAAC,CAAC;QACtC,OAAO,CAAC,GAAG,CAAC,yBAAyB,EAAE,OAAO,CAAC,GAAG,CAAC,YAAY,CAAC,CAAC,CAAC,sBAAsB,CAAC,CAAC,CAAC,oBAAoB,CAAC,CAAC;QACjH,OAAO,CAAC,GAAG,CAAC,wBAAwB,CAAC,CAAC;QACtC,MAAM,IAAI,GAAG,IAAI,SAAI,CAAC,EAAE,gBAAgB,EAAE,OAAO,CAAC,GAAG,CAAC,YAAY,EAAE,CAAC,CAAC;QACtE,MAAM,OAAO,GAAG,IAAI,qBAAQ,CAAC,IAAI,CAAC,CAAC;QACnC,KAAK,CAAC,EAAE,OAAO,EAAE,CAAC,CAAC;IACrB,CAAC;IAED,KAAK,CAAC,YAAY,KAAK,MAAM,IAAI,CAAC,QAAQ,EAAE,CAAC,CAAC,CAAC;IAC/C,KAAK,CAAC,eAAe,KAAK,MAAM,IAAI,CAAC,WAAW,EAAE,CAAC,CAAC,CAAC;CACtD,CAAA;AAZY,sCAAa;wBAAb,aAAa;IADzB,IAAA,mBAAU,GAAE;;GACA,aAAa,CAYzB"}
+7
View File
@@ -0,0 +1,7 @@
import { CanActivate, ExecutionContext } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
export declare class AdminGuard implements CanActivate {
private prisma;
constructor(prisma: PrismaService);
canActivate(context: ExecutionContext): Promise<boolean>;
}
+40
View File
@@ -0,0 +1,40 @@
"use strict";
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
var __metadata = (this && this.__metadata) || function (k, v) {
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.AdminGuard = void 0;
const common_1 = require("@nestjs/common");
const prisma_service_1 = require("../../prisma/prisma.service");
let AdminGuard = class AdminGuard {
constructor(prisma) {
this.prisma = prisma;
}
async canActivate(context) {
const request = context.switchToHttp().getRequest();
const user = request.user;
if (!user || !user.id) {
throw new common_1.ForbiddenException('Yêu cầu xác thực không hợp lệ. Vui lòng đăng nhập.');
}
const dbUser = await this.prisma.user.findUnique({
where: { id: user.id },
select: { isAdmin: true, isBlocked: true },
});
if (!dbUser || !dbUser.isAdmin || dbUser.isBlocked) {
throw new common_1.ForbiddenException('Truy cập bị từ chối. Bạn không có quyền quản trị viên hệ thống.');
}
return true;
}
};
exports.AdminGuard = AdminGuard;
exports.AdminGuard = AdminGuard = __decorate([
(0, common_1.Injectable)(),
__metadata("design:paramtypes", [prisma_service_1.PrismaService])
], AdminGuard);
//# sourceMappingURL=admin.guard.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"admin.guard.js","sourceRoot":"","sources":["../../../src/auth/admin.guard.ts"],"names":[],"mappings":";;;;;;;;;;;;AAAA,2CAA+F;AAC/F,gEAA4D;AAGrD,IAAM,UAAU,GAAhB,MAAM,UAAU;IACrB,YAAoB,MAAqB;QAArB,WAAM,GAAN,MAAM,CAAe;IAAG,CAAC;IAE7C,KAAK,CAAC,WAAW,CAAC,OAAyB;QACzC,MAAM,OAAO,GAAG,OAAO,CAAC,YAAY,EAAE,CAAC,UAAU,EAAE,CAAC;QAGpD,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC;QAE1B,IAAI,CAAC,IAAI,IAAI,CAAC,IAAI,CAAC,EAAE,EAAE,CAAC;YACtB,MAAM,IAAI,2BAAkB,CAAC,oDAAoD,CAAC,CAAC;QACrF,CAAC;QAGD,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,UAAU,CAAC;YAC/C,KAAK,EAAE,EAAE,EAAE,EAAE,IAAI,CAAC,EAAE,EAAE;YACtB,MAAM,EAAE,EAAE,OAAO,EAAE,IAAI,EAAE,SAAS,EAAE,IAAI,EAAE;SAC3C,CAAC,CAAC;QAEH,IAAI,CAAC,MAAM,IAAI,CAAC,MAAM,CAAC,OAAO,IAAI,MAAM,CAAC,SAAS,EAAE,CAAC;YACnD,MAAM,IAAI,2BAAkB,CAAC,iEAAiE,CAAC,CAAC;QAClG,CAAC;QAED,OAAO,IAAI,CAAC;IACd,CAAC;CACF,CAAA;AAzBY,gCAAU;qBAAV,UAAU;IADtB,IAAA,mBAAU,GAAE;qCAEiB,8BAAa;GAD9B,UAAU,CAyBtB"}
+8
View File
@@ -0,0 +1,8 @@
declare const JwtAuthGuard_base: import("@nestjs/passport").Type<import("@nestjs/passport").IAuthGuard>;
export declare class JwtAuthGuard extends JwtAuthGuard_base {
}
declare const JwtAuthGuardNoAnonymous_base: import("@nestjs/passport").Type<import("@nestjs/passport").IAuthGuard>;
export declare class JwtAuthGuardNoAnonymous extends JwtAuthGuardNoAnonymous_base {
handleRequest(err: any, user: any, info: any): any;
}
export {};
+34
View File
@@ -0,0 +1,34 @@
"use strict";
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.JwtAuthGuardNoAnonymous = exports.JwtAuthGuard = void 0;
const common_1 = require("@nestjs/common");
const passport_1 = require("@nestjs/passport");
const common_2 = require("@nestjs/common");
let JwtAuthGuard = class JwtAuthGuard extends (0, passport_1.AuthGuard)('jwt') {
};
exports.JwtAuthGuard = JwtAuthGuard;
exports.JwtAuthGuard = JwtAuthGuard = __decorate([
(0, common_1.Injectable)()
], JwtAuthGuard);
let JwtAuthGuardNoAnonymous = class JwtAuthGuardNoAnonymous extends (0, passport_1.AuthGuard)('jwt') {
handleRequest(err, user, info) {
if (err || !user) {
throw err || new common_2.UnauthorizedException('Phiên làm việc không hợp lệ hoặc đã hết hạn');
}
if (user.isAnonymous) {
throw new common_2.UnauthorizedException('Tài khoản khách không có quyền truy cập tính năng này. Vui lòng đăng ký tài khoản để tiếp tục.');
}
return user;
}
};
exports.JwtAuthGuardNoAnonymous = JwtAuthGuardNoAnonymous;
exports.JwtAuthGuardNoAnonymous = JwtAuthGuardNoAnonymous = __decorate([
(0, common_1.Injectable)()
], JwtAuthGuardNoAnonymous);
//# sourceMappingURL=jwt-auth.guard.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"jwt-auth.guard.js","sourceRoot":"","sources":["../../../src/auth/jwt-auth.guard.ts"],"names":[],"mappings":";;;;;;;;;AAAA,2CAA4C;AAC5C,+CAA6C;AAC7C,2CAAuD;AAGhD,IAAM,YAAY,GAAlB,MAAM,YAAa,SAAQ,IAAA,oBAAS,EAAC,KAAK,CAAC;CAAG,CAAA;AAAxC,oCAAY;uBAAZ,YAAY;IADxB,IAAA,mBAAU,GAAE;GACA,YAAY,CAA4B;AAI9C,IAAM,uBAAuB,GAA7B,MAAM,uBAAwB,SAAQ,IAAA,oBAAS,EAAC,KAAK,CAAC;IAC3D,aAAa,CAAC,GAAQ,EAAE,IAAS,EAAE,IAAS;QAC1C,IAAI,GAAG,IAAI,CAAC,IAAI,EAAE,CAAC;YACjB,MAAM,GAAG,IAAI,IAAI,8BAAqB,CAAC,6CAA6C,CAAC,CAAC;QACxF,CAAC;QAGD,IAAI,IAAI,CAAC,WAAW,EAAE,CAAC;YACrB,MAAM,IAAI,8BAAqB,CAAC,gGAAgG,CAAC,CAAC;QACpI,CAAC;QAED,OAAO,IAAI,CAAC;IACd,CAAC;CACF,CAAA;AAbY,0DAAuB;kCAAvB,uBAAuB;IADnC,IAAA,mBAAU,GAAE;GACA,uBAAuB,CAanC"}
+20
View File
@@ -0,0 +1,20 @@
import { PrismaService } from '../../prisma/prisma.service';
declare const JwtStrategy_base: new (...args: any) => any;
export declare class JwtStrategy extends JwtStrategy_base {
private prisma;
constructor(prisma: PrismaService);
validate(payload: any): Promise<{
id: string;
email: string | null;
passwordHash: string | null;
name: string | null;
phone: string | null;
address: string | null;
avatar: string | null;
createdAt: Date;
isAdmin: boolean;
isBlocked: boolean;
isAnonymous: boolean;
}>;
}
export {};
+41
View File
@@ -0,0 +1,41 @@
"use strict";
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
var __metadata = (this && this.__metadata) || function (k, v) {
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.JwtStrategy = void 0;
const common_1 = require("@nestjs/common");
const passport_1 = require("@nestjs/passport");
const passport_jwt_1 = require("passport-jwt");
const prisma_service_1 = require("../../prisma/prisma.service");
let JwtStrategy = class JwtStrategy extends (0, passport_1.PassportStrategy)(passport_jwt_1.Strategy) {
constructor(prisma) {
super({
jwtFromRequest: passport_jwt_1.ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: process.env.JWT_SECRET || 'super-secret',
});
this.prisma = prisma;
}
async validate(payload) {
const user = await this.prisma.user.findUnique({
where: { id: payload.sub },
});
if (!user) {
throw new common_1.UnauthorizedException('Người dùng không tồn tại hoặc phiên làm việc hết hạn');
}
return user;
}
};
exports.JwtStrategy = JwtStrategy;
exports.JwtStrategy = JwtStrategy = __decorate([
(0, common_1.Injectable)(),
__metadata("design:paramtypes", [prisma_service_1.PrismaService])
], JwtStrategy);
//# sourceMappingURL=jwt.strategy.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"jwt.strategy.js","sourceRoot":"","sources":["../../../src/auth/jwt.strategy.ts"],"names":[],"mappings":";;;;;;;;;;;;AAAA,2CAAmE;AACnE,+CAAoD;AACpD,+CAAoD;AACpD,gEAA4D;AAGrD,IAAM,WAAW,GAAjB,MAAM,WAAY,SAAQ,IAAA,2BAAgB,EAAC,uBAAQ,CAAC;IACzD,YAAoB,MAAqB;QACvC,KAAK,CAAC;YACJ,cAAc,EAAE,yBAAU,CAAC,2BAA2B,EAAE;YACxD,gBAAgB,EAAE,KAAK;YACvB,WAAW,EAAE,OAAO,CAAC,GAAG,CAAC,UAAU,IAAI,cAAc;SACtD,CAAC,CAAC;QALe,WAAM,GAAN,MAAM,CAAe;IAMzC,CAAC;IAED,KAAK,CAAC,QAAQ,CAAC,OAAY;QACzB,MAAM,IAAI,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,UAAU,CAAC;YAC7C,KAAK,EAAE,EAAE,EAAE,EAAE,OAAO,CAAC,GAAG,EAAE;SAC3B,CAAC,CAAC;QAEH,IAAI,CAAC,IAAI,EAAE,CAAC;YACV,MAAM,IAAI,8BAAqB,CAAC,sDAAsD,CAAC,CAAC;QAC1F,CAAC;QAID,OAAO,IAAI,CAAC;IACd,CAAC;CACF,CAAA;AAtBY,kCAAW;sBAAX,WAAW;IADvB,IAAA,mBAAU,GAAE;qCAEiB,8BAAa;GAD9B,WAAW,CAsBvB"}
+10
View File
@@ -0,0 +1,10 @@
import { CallHandler, ExecutionContext, NestInterceptor } from '@nestjs/common';
import { Observable } from 'rxjs';
import { Cache } from 'cache-manager';
import { HttpAdapterHost } from '@nestjs/core';
export declare class CompressCacheInterceptor implements NestInterceptor {
private cacheManager;
private readonly httpAdapterHost;
constructor(cacheManager: Cache, httpAdapterHost: HttpAdapterHost);
intercept(context: ExecutionContext, next: CallHandler): Promise<Observable<any>>;
}
+123
View File
@@ -0,0 +1,123 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
var __metadata = (this && this.__metadata) || function (k, v) {
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
};
var __param = (this && this.__param) || function (paramIndex, decorator) {
return function (target, key) { decorator(target, key, paramIndex); }
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.CompressCacheInterceptor = void 0;
const common_1 = require("@nestjs/common");
const rxjs_1 = require("rxjs");
const operators_1 = require("rxjs/operators");
const cache_manager_1 = require("@nestjs/cache-manager");
const zlib = __importStar(require("zlib"));
const util_1 = require("util");
const core_1 = require("@nestjs/core");
const gzip = (0, util_1.promisify)(zlib.gzip);
const gunzip = (0, util_1.promisify)(zlib.gunzip);
const COMPRESSION_THRESHOLD = 100;
let CompressCacheInterceptor = class CompressCacheInterceptor {
constructor(cacheManager, httpAdapterHost) {
this.cacheManager = cacheManager;
this.httpAdapterHost = httpAdapterHost;
}
async intercept(context, next) {
const httpAdapter = this.httpAdapterHost.httpAdapter;
const request = context.getArgByIndex(0);
const response = context.getArgByIndex(1);
if (httpAdapter.getRequestMethod(request) !== 'GET') {
return next.handle();
}
const cacheKey = httpAdapter.getRequestUrl(request);
let cachedData = await this.cacheManager.get(cacheKey);
if (cachedData) {
try {
if (Buffer.isBuffer(cachedData) && cachedData.length > 2 && cachedData[0] === 0x1f && cachedData[1] === 0x8b) {
const decompressed = await gunzip(cachedData);
const jsonString = decompressed.toString('utf8');
httpAdapter.setHeader(response, 'Content-Type', 'application/json');
httpAdapter.setHeader(response, 'X-Cache', 'HIT (Compressed)');
return (0, rxjs_1.of)(JSON.parse(jsonString));
}
else {
const jsonString = cachedData.toString();
httpAdapter.setHeader(response, 'Content-Type', 'application/json');
httpAdapter.setHeader(response, 'X-Cache', 'HIT (Uncompressed)');
return (0, rxjs_1.of)(JSON.parse(jsonString));
}
}
catch (e) {
console.error(`[Cache] Lỗi giải nén dữ liệu cache cho key ${cacheKey}:`, e);
await this.cacheManager.del(cacheKey);
}
}
httpAdapter.setHeader(response, 'X-Cache', 'MISS');
return next.handle().pipe((0, operators_1.tap)(async (data) => {
if (!data)
return;
const jsonString = JSON.stringify(data);
const ttl = 60000;
if (jsonString.length > COMPRESSION_THRESHOLD) {
try {
const compressed = await gzip(Buffer.from(jsonString, 'utf8'));
await this.cacheManager.set(cacheKey, compressed, ttl);
console.log(`[Cache] 📦 Đã nén dữ liệu cho: ${cacheKey} (${jsonString.length} -> ${compressed.length} bytes)`);
}
catch (e) {
console.error(`[Cache] Lỗi nén dữ liệu cho key ${cacheKey}:`, e);
await this.cacheManager.set(cacheKey, jsonString, ttl);
}
}
else {
await this.cacheManager.set(cacheKey, jsonString, ttl);
}
}));
}
};
exports.CompressCacheInterceptor = CompressCacheInterceptor;
exports.CompressCacheInterceptor = CompressCacheInterceptor = __decorate([
(0, common_1.Injectable)(),
__param(0, (0, common_1.Inject)(cache_manager_1.CACHE_MANAGER)),
__metadata("design:paramtypes", [Object, core_1.HttpAdapterHost])
], CompressCacheInterceptor);
//# sourceMappingURL=compress-cache.interceptor.js.map
@@ -0,0 +1 @@
{"version":3,"file":"compress-cache.interceptor.js","sourceRoot":"","sources":["../../../src/common/compress-cache.interceptor.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,2CAAoG;AACpG,+BAAsC;AACtC,8CAAqC;AACrC,yDAAsD;AAEtD,2CAA6B;AAC7B,+BAAiC;AACjC,uCAA+C;AAG/C,MAAM,IAAI,GAAG,IAAA,gBAAS,EAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AAClC,MAAM,MAAM,GAAG,IAAA,gBAAS,EAAC,IAAI,CAAC,MAAM,CAAC,CAAC;AAItC,MAAM,qBAAqB,GAAG,GAAG,CAAC;AAG3B,IAAM,wBAAwB,GAA9B,MAAM,wBAAwB;IACnC,YACiC,YAAmB,EACjC,eAAgC;QADlB,iBAAY,GAAZ,YAAY,CAAO;QACjC,oBAAe,GAAf,eAAe,CAAiB;IAChD,CAAC;IAEJ,KAAK,CAAC,SAAS,CAAC,OAAyB,EAAE,IAAiB;QAC1D,MAAM,WAAW,GAAG,IAAI,CAAC,eAAe,CAAC,WAAW,CAAC;QACrD,MAAM,OAAO,GAAG,OAAO,CAAC,aAAa,CAAC,CAAC,CAAC,CAAC;QACzC,MAAM,QAAQ,GAAG,OAAO,CAAC,aAAa,CAAC,CAAC,CAAC,CAAC;QAG1C,IAAI,WAAW,CAAC,gBAAgB,CAAC,OAAO,CAAC,KAAK,KAAK,EAAE,CAAC;YACpD,OAAO,IAAI,CAAC,MAAM,EAAE,CAAC;QACvB,CAAC;QAED,MAAM,QAAQ,GAAG,WAAW,CAAC,aAAa,CAAC,OAAO,CAAC,CAAC;QACpD,IAAI,UAAU,GAAG,MAAM,IAAI,CAAC,YAAY,CAAC,GAAG,CAAS,QAAQ,CAAC,CAAC;QAE/D,IAAI,UAAU,EAAE,CAAC;YACf,IAAI,CAAC;gBAEH,IAAI,MAAM,CAAC,QAAQ,CAAC,UAAU,CAAC,IAAI,UAAU,CAAC,MAAM,GAAG,CAAC,IAAI,UAAU,CAAC,CAAC,CAAC,KAAK,IAAI,IAAI,UAAU,CAAC,CAAC,CAAC,KAAK,IAAI,EAAE,CAAC;oBAC7G,MAAM,YAAY,GAAG,MAAM,MAAM,CAAC,UAAU,CAAC,CAAC;oBAC9C,MAAM,UAAU,GAAG,YAAY,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC;oBAEjD,WAAW,CAAC,SAAS,CAAC,QAAQ,EAAE,cAAc,EAAE,kBAAkB,CAAC,CAAC;oBACpE,WAAW,CAAC,SAAS,CAAC,QAAQ,EAAE,SAAS,EAAE,kBAAkB,CAAC,CAAC;oBAC/D,OAAO,IAAA,SAAE,EAAC,IAAI,CAAC,KAAK,CAAC,UAAU,CAAC,CAAC,CAAC;gBACpC,CAAC;qBAAM,CAAC;oBAEN,MAAM,UAAU,GAAG,UAAU,CAAC,QAAQ,EAAE,CAAC;oBACzC,WAAW,CAAC,SAAS,CAAC,QAAQ,EAAE,cAAc,EAAE,kBAAkB,CAAC,CAAC;oBACpE,WAAW,CAAC,SAAS,CAAC,QAAQ,EAAE,SAAS,EAAE,oBAAoB,CAAC,CAAC;oBACjE,OAAO,IAAA,SAAE,EAAC,IAAI,CAAC,KAAK,CAAC,UAAU,CAAC,CAAC,CAAC;gBACpC,CAAC;YACH,CAAC;YAAC,OAAO,CAAC,EAAE,CAAC;gBACX,OAAO,CAAC,KAAK,CAAC,8CAA8C,QAAQ,GAAG,EAAE,CAAC,CAAC,CAAC;gBAE5E,MAAM,IAAI,CAAC,YAAY,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC;YACxC,CAAC;QACH,CAAC;QAID,WAAW,CAAC,SAAS,CAAC,QAAQ,EAAE,SAAS,EAAE,MAAM,CAAC,CAAC;QAEnD,OAAO,IAAI,CAAC,MAAM,EAAE,CAAC,IAAI,CACvB,IAAA,eAAG,EAAC,KAAK,EAAE,IAAI,EAAE,EAAE;YACjB,IAAI,CAAC,IAAI;gBAAE,OAAO;YAElB,MAAM,UAAU,GAAG,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,CAAC;YACxC,MAAM,GAAG,GAAG,KAAK,CAAC;YAElB,IAAI,UAAU,CAAC,MAAM,GAAG,qBAAqB,EAAE,CAAC;gBAC9C,IAAI,CAAC;oBACH,MAAM,UAAU,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,UAAU,EAAE,MAAM,CAAC,CAAC,CAAC;oBAC/D,MAAM,IAAI,CAAC,YAAY,CAAC,GAAG,CAAC,QAAQ,EAAE,UAAU,EAAE,GAAG,CAAC,CAAC;oBACvD,OAAO,CAAC,GAAG,CAAC,kCAAkC,QAAQ,KAAK,UAAU,CAAC,MAAM,OAAO,UAAU,CAAC,MAAM,SAAS,CAAC,CAAC;gBAEjH,CAAC;gBAAC,OAAO,CAAC,EAAE,CAAC;oBACX,OAAO,CAAC,KAAK,CAAC,mCAAmC,QAAQ,GAAG,EAAE,CAAC,CAAC,CAAC;oBAEjE,MAAM,IAAI,CAAC,YAAY,CAAC,GAAG,CAAC,QAAQ,EAAE,UAAU,EAAE,GAAG,CAAC,CAAC;gBACzD,CAAC;YACH,CAAC;iBAAM,CAAC;gBAEN,MAAM,IAAI,CAAC,YAAY,CAAC,GAAG,CAAC,QAAQ,EAAE,UAAU,EAAE,GAAG,CAAC,CAAC;YACzD,CAAC;QACH,CAAC,CAAC,CACH,CAAC;IACJ,CAAC;CACF,CAAA;AAxEY,4DAAwB;mCAAxB,wBAAwB;IADpC,IAAA,mBAAU,GAAE;IAGR,WAAA,IAAA,eAAM,EAAC,6BAAa,CAAC,CAAA;6CACY,sBAAe;GAHxC,wBAAwB,CAwEpC"}
+7
View File
@@ -0,0 +1,7 @@
import { CanActivate, ExecutionContext } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
export declare class TourRoleGuard implements CanActivate {
private prisma;
constructor(prisma: PrismaService);
canActivate(context: ExecutionContext): Promise<boolean>;
}
+58
View File
@@ -0,0 +1,58 @@
"use strict";
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
var __metadata = (this && this.__metadata) || function (k, v) {
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.TourRoleGuard = void 0;
const common_1 = require("@nestjs/common");
const prisma_service_1 = require("../../prisma/prisma.service");
let TourRoleGuard = class TourRoleGuard {
constructor(prisma) {
this.prisma = prisma;
}
async canActivate(context) {
const request = context.switchToHttp().getRequest();
const user = request.user;
const tourId = request.params.id || request.params.tourId;
const path = request.url;
if (!user || !tourId) {
throw new common_1.ForbiddenException("Thông tin xác thực hoặc mã Tour không hợp lệ.");
}
if (path.includes('/join-requests') && request.method === 'POST' && (!request.params.requestId || /\/join-requests\/[^/]+\/(accept|reject)$/.test(path))) {
request.tourParticipation = null;
return true;
}
const participation = await this.prisma.tourParticipant.findUnique({
where: {
tourId_userId: {
tourId: tourId,
userId: user.id,
},
},
});
if (!participation) {
throw new common_1.ForbiddenException("Bạn không phải là thành viên của tour này.");
}
request.tourParticipation = participation;
const role = participation.role;
const isPlanPath = path.includes('/plans');
const isExpensePath = path.includes('/expenses');
if ((role === 'MEMBER_NO_FINANCE' || role === 'VIEWER_ONLY') &&
(isPlanPath || isExpensePath)) {
throw new common_1.ForbiddenException("Bạn không có quyền xem kế hoạch và chi phí của tour này.");
}
return true;
}
};
exports.TourRoleGuard = TourRoleGuard;
exports.TourRoleGuard = TourRoleGuard = __decorate([
(0, common_1.Injectable)(),
__metadata("design:paramtypes", [prisma_service_1.PrismaService])
], TourRoleGuard);
//# sourceMappingURL=rbac.middleware.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"rbac.middleware.js","sourceRoot":"","sources":["../../../src/common/rbac.middleware.ts"],"names":[],"mappings":";;;;;;;;;;;;AAAA,2CAA+F;AAC/F,gEAA4D;AAGrD,IAAM,aAAa,GAAnB,MAAM,aAAa;IACxB,YAAoB,MAAqB;QAArB,WAAM,GAAN,MAAM,CAAe;IAAG,CAAC;IAE7C,KAAK,CAAC,WAAW,CAAC,OAAyB;QACzC,MAAM,OAAO,GAAG,OAAO,CAAC,YAAY,EAAE,CAAC,UAAU,EAAE,CAAC;QACpD,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC;QAE1B,MAAM,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,EAAE,IAAI,OAAO,CAAC,MAAM,CAAC,MAAM,CAAC;QAC1D,MAAM,IAAI,GAAG,OAAO,CAAC,GAAG,CAAC;QAEzB,IAAI,CAAC,IAAI,IAAI,CAAC,MAAM,EAAE,CAAC;YACrB,MAAM,IAAI,2BAAkB,CAAC,+CAA+C,CAAC,CAAC;QAChF,CAAC;QAED,IAAI,IAAI,CAAC,QAAQ,CAAC,gBAAgB,CAAC,IAAI,OAAO,CAAC,MAAM,KAAK,MAAM,IAAI,CAAC,CAAC,OAAO,CAAC,MAAM,CAAC,SAAS,IAAI,0CAA0C,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,EAAE,CAAC;YACzJ,OAAO,CAAC,iBAAiB,GAAG,IAAI,CAAC;YACjC,OAAO,IAAI,CAAC;QACd,CAAC;QAED,MAAM,aAAa,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,eAAe,CAAC,UAAU,CAAC;YACjE,KAAK,EAAE;gBACL,aAAa,EAAE;oBACb,MAAM,EAAE,MAAM;oBACd,MAAM,EAAE,IAAI,CAAC,EAAE;iBAChB;aACF;SACF,CAAC,CAAC;QAEH,IAAI,CAAC,aAAa,EAAE,CAAC;YACnB,MAAM,IAAI,2BAAkB,CAAC,4CAA4C,CAAC,CAAC;QAC7E,CAAC;QAED,OAAO,CAAC,iBAAiB,GAAG,aAAa,CAAC;QAE1C,MAAM,IAAI,GAAG,aAAa,CAAC,IAAI,CAAC;QAChC,MAAM,UAAU,GAAG,IAAI,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC;QAC3C,MAAM,aAAa,GAAG,IAAI,CAAC,QAAQ,CAAC,WAAW,CAAC,CAAC;QAEjD,IACE,CAAC,IAAI,KAAK,mBAAmB,IAAI,IAAI,KAAK,aAAa,CAAC;YACxD,CAAC,UAAU,IAAI,aAAa,CAAC,EAC7B,CAAC;YACD,MAAM,IAAI,2BAAkB,CAAC,0DAA0D,CAAC,CAAC;QAC3F,CAAC;QAED,OAAO,IAAI,CAAC;IACd,CAAC;CACF,CAAA;AA/CY,sCAAa;wBAAb,aAAa;IADzB,IAAA,mBAAU,GAAE;qCAEiB,8BAAa;GAD9B,aAAa,CA+CzB"}
+35
View File
@@ -0,0 +1,35 @@
import 'reflect-metadata';
import { OnGatewayConnection } from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
import { PrismaService } from '../prisma/prisma.service';
import { ParticipantRole } from '@prisma/client';
import { Reflector } from '@nestjs/core';
import { CanActivate, ExecutionContext } from '@nestjs/common';
import { Cache } from 'cache-manager';
export declare class CommentGateway implements OnGatewayConnection {
server: Server;
handleConnection(client: Socket): void;
handleJoinTour(client: Socket, tourId: string): void;
handleJoinPhoto(client: Socket, photoId: string): void;
notifyNewComment(tourId: string, data: any): void;
notifyNewPhotoComment(photoId: string, data: any): void;
handleJoinUser(client: Socket, userId: string): void;
notifyNewMessage(receiverId: string, data: any): void;
notifyConnectionAccepted(requesterId: string, data: any): void;
notifyJoinRequestAccepted(userId: string, data: any): void;
}
export declare const ROLES_KEY = "roles";
export declare const Roles: (...roles: ParticipantRole[]) => import("@nestjs/common").CustomDecorator<string>;
export declare class TourRoleGuard implements CanActivate {
private reflector;
private prisma;
private cacheManager;
constructor(reflector: Reflector, prisma: PrismaService, cacheManager: Cache);
canActivate(context: ExecutionContext): Promise<boolean>;
}
export declare class EmailService {
private transporter;
constructor();
sendOTP(email: string, otp: string): Promise<any>;
sendTourInvitation(email: string, tourTitle: string, inviteLink: string): Promise<any>;
}
+4963
View File
File diff suppressed because it is too large Load Diff
+1
View File
File diff suppressed because one or more lines are too long
+4 -2
View File
@@ -1,8 +1,10 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": ".",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
"deleteOutDir": true,
"assets": ["**/*.prisma", "schema.sql"],
"watchAssets": true
}
}
+80
View File
@@ -0,0 +1,80 @@
> backend@0.0.1 start:dev
> nest start --watch
[10:46:05 PM] Starting compilation in watch mode...
[10:46:08 PM] Found 0 errors. Watching for file changes.
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [NestFactory] Starting Nest application...
--- [PRISMA CHECK] ---
DATABASE_URL nhận được: ĐÃ ĐỌC THÀNH CÔNG ✔️
----------------------
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [InstanceLoader] ConfigHostModule dependencies initialized +40ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [InstanceLoader] JwtModule dependencies initialized +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [InstanceLoader] ConfigModule dependencies initialized +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [InstanceLoader] CacheModule dependencies initialized +15ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [InstanceLoader] AppModule dependencies initialized +2ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [WebSocketsController] CommentGateway subscribed to the "joinTour" message +11ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [WebSocketsController] CommentGateway subscribed to the "joinPhoto" message +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RoutesResolver] AppController {/api/v1}: +1ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1, GET} route +2ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RoutesResolver] AuthController {/api/v1/auth}: +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/auth/convert-guest, POST} route +1ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/auth/create-guest, POST} route +1ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/auth/status, GET} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/auth/login, POST} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/auth/signup/request, POST} route +1ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/auth/signup/verify, POST} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RoutesResolver] PublicTourController {/api/v1/tours}: +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/tours/:id/public, GET} route +1ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RoutesResolver] TourController {/api/v1/tours}: +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/tours, POST} route +1ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/tours/:tourId/locations, POST} route +1ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/tours/:tourId/start-point, POST} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/tours/:tourId/end-point, POST} route +1ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/tours/:tourId/legs/batch, POST} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/tours/:tourId/legs, POST} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/tours/:id, PATCH} route +1ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/tours/:id, DELETE} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/tours/explore, GET} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/tours/:id, GET} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/tours/:tourId/members, POST} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/tours/:tourId/join-requests, GET} route +1ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/tours/:tourId/join-requests, POST} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/tours/:tourId/join-requests/:requestId/accept, POST} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/tours/:tourId/join-requests/:requestId/reject, POST} route +1ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/tours/:tourId/members/:userId, DELETE} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/tours/:tourId/photos, POST} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RoutesResolver] UserController {/api/v1/users}: +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/users, GET} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/users/me/photos, GET} route +1ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/users/:id, PATCH} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/users/:id, DELETE} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/users/block/:id, POST} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RoutesResolver] RoutingController {/api/v1/routing}: +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/routing/optimize/:legId, POST} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RoutesResolver] LegController {/api/v1/legs}: +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/legs/:id, PATCH} route +1ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/legs/:id, DELETE} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RoutesResolver] LocationController {/api/v1/locations}: +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/locations/:id, PATCH} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/locations/:id, DELETE} route +1ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RoutesResolver] CommentController {/api/v1/locations}: +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/locations/:locationId/comments, GET} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/locations/:locationId/comments, POST} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RoutesResolver] PhotoController {/api/v1/photos}: +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/photos/upload-anonymous, POST} route +1ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/photos/:id, DELETE} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RoutesResolver] PublicPhotoController {/api/v1/public-photos}: +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/public-photos, GET} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/public-photos/:photoId/comments, GET} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/public-photos/:photoId/comments, POST} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RoutesResolver] AdminOtpController {/api/v1/admin/otp}: +1ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/admin/otp/send, POST} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/admin/otp/verify, POST} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [NestApplication] Nest application successfully started +16ms
🚀 Server is running on: http://localhost:3001
[EmailService] ✅ Kết nối SMTP thành công. Sẵn sàng gửi mail.
[WS] Client connected: sIV1IoQvXb-xaa9iAAAC
[WS] Client sIV1IoQvXb-xaa9iAAAC joined room: photo_557913e6-fcb4-4dae-8cff-a6ff52da3386
+50
View File
@@ -0,0 +1,50 @@
{
"name": "backend",
"version": "0.0.1",
"scripts": {
"build": "nest build",
"start:dev": "nest start --watch",
"db:generate": "dotenv -e ../.env -- prisma generate --schema=prisma/schema.prisma",
"db:migrate": "dotenv -e ../.env -- prisma migrate dev --schema=prisma/schema.prisma",
"db:seed": "dotenv -e ../.env -- tsx seed.ts"
},
"devDependencies": {
"@nestjs/cli": "^11.0.23",
"@types/bcrypt": "^5.0.2",
"@types/node": "^20.14.10",
"@types/pg": "^8.11.6",
"dotenv-cli": "^7.4.2",
"prisma": "^5.16.2",
"ts-node": "^10.9.2",
"tsx": "^4.19.2",
"typescript": "^5.5.3"
},
"dependencies": {
"@nestjs/cache-manager": "^3.1.3",
"@nestjs/common": "^11.1.27",
"@nestjs/config": "^4.0.4",
"@nestjs/core": "^11.1.27",
"@nestjs/jwt": "^11.0.2",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.1.27",
"@nestjs/platform-socket.io": "^11.1.27",
"@nestjs/websockets": "^11.1.27",
"@prisma/adapter-pg": "^5.16.2",
"@prisma/client": "^5.16.2",
"bcrypt": "^6.0.0",
"cache-manager": "^7.2.8",
"cache-manager-redis-yet": "^5.1.5",
"dotenv": "^17.4.2",
"exifr": "^7.1.3",
"heic-convert": "^2.1.0",
"nodemailer": "^9.0.1",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"pg": "^8.12.0",
"redis": "^6.0.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2",
"sharp": "^0.35.1",
"socket.io": "^4.8.3"
}
}
@@ -0,0 +1,174 @@
-- CreateEnum
CREATE TYPE "ParticipantRole" AS ENUM ('OWNER', 'MANAGER', 'MEMBER', 'MEMBER_NO_FINANCE', 'VIEWER_ONLY');
-- CreateEnum
CREATE TYPE "JoinRequestStatus" AS ENUM ('PENDING', 'ACCEPTED', 'REJECTED');
-- CreateEnum
CREATE TYPE "ExpenseCategory" AS ENUM ('ACCOMMODATION', 'FOOD', 'TRANSPORT', 'TICKET', 'OTHER');
-- CreateEnum
CREATE TYPE "LocationStatus" AS ENUM ('PENDING', 'COMPLETED');
-- CreateEnum
CREATE TYPE "LocationType" AS ENUM ('MOVE', 'VISIT', 'REST', 'EAT');
-- CreateEnum
CREATE TYPE "PrivacyLevel" AS ENUM ('PUBLIC', 'TOUR_ONLY', 'PRIVATE');
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"passwordHash" TEXT NOT NULL,
"name" TEXT,
"phone" TEXT,
"address" TEXT,
"avatar" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"isAdmin" BOOLEAN NOT NULL DEFAULT false,
"isBlocked" BOOLEAN NOT NULL DEFAULT false,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Tour" (
"id" TEXT NOT NULL,
"title" TEXT NOT NULL,
"startDate" TIMESTAMP(3),
"endDate" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"totalCost" DECIMAL(15,2) NOT NULL DEFAULT 0,
"createdById" TEXT NOT NULL,
CONSTRAINT "Tour_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "JoinRequest" (
"id" TEXT NOT NULL,
"tourId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"requestedById" TEXT NOT NULL,
"status" "JoinRequestStatus" NOT NULL DEFAULT 'PENDING',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "JoinRequest_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "TourParticipant" (
"tourId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"role" "ParticipantRole" NOT NULL DEFAULT 'MEMBER',
CONSTRAINT "TourParticipant_pkey" PRIMARY KEY ("tourId","userId")
);
-- CreateTable
CREATE TABLE "Leg" (
"id" TEXT NOT NULL,
"tourId" TEXT NOT NULL,
"sequence" INTEGER NOT NULL,
"note" TEXT,
CONSTRAINT "Leg_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Location" (
"id" TEXT NOT NULL,
"legId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"address" TEXT,
"latitude" DOUBLE PRECISION NOT NULL,
"longitude" DOUBLE PRECISION NOT NULL,
"plannedStart" TIMESTAMP(3),
"plannedEnd" TIMESTAMP(3),
"actualStart" TIMESTAMP(3),
"actualEnd" TIMESTAMP(3),
"status" "LocationStatus" NOT NULL DEFAULT 'PENDING',
"type" "LocationType" NOT NULL DEFAULT 'VISIT',
CONSTRAINT "Location_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Expense" (
"id" TEXT NOT NULL,
"leg_id" TEXT NOT NULL,
"location_id" TEXT,
"category" "ExpenseCategory" NOT NULL,
"amount" DECIMAL(15,2) NOT NULL,
"description" TEXT,
"note" TEXT,
"paid_by_id" TEXT,
CONSTRAINT "Expense_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Photo" (
"id" TEXT NOT NULL,
"tourId" TEXT NOT NULL,
"locationId" TEXT,
"uploaderId" TEXT NOT NULL,
"imageUrl" TEXT NOT NULL,
"capturedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"metadata" JSONB,
"privacy" "PrivacyLevel" NOT NULL DEFAULT 'TOUR_ONLY',
CONSTRAINT "Photo_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE INDEX "JoinRequest_tourId_status_idx" ON "JoinRequest"("tourId", "status");
-- CreateIndex
CREATE INDEX "JoinRequest_userId_idx" ON "JoinRequest"("userId");
-- AddForeignKey
ALTER TABLE "Tour" ADD CONSTRAINT "Tour_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "JoinRequest" ADD CONSTRAINT "JoinRequest_tourId_fkey" FOREIGN KEY ("tourId") REFERENCES "Tour"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "JoinRequest" ADD CONSTRAINT "JoinRequest_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "JoinRequest" ADD CONSTRAINT "JoinRequest_requestedById_fkey" FOREIGN KEY ("requestedById") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TourParticipant" ADD CONSTRAINT "TourParticipant_tourId_fkey" FOREIGN KEY ("tourId") REFERENCES "Tour"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TourParticipant" ADD CONSTRAINT "TourParticipant_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Leg" ADD CONSTRAINT "Leg_tourId_fkey" FOREIGN KEY ("tourId") REFERENCES "Tour"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Location" ADD CONSTRAINT "Location_legId_fkey" FOREIGN KEY ("legId") REFERENCES "Leg"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Expense" ADD CONSTRAINT "Expense_leg_id_fkey" FOREIGN KEY ("leg_id") REFERENCES "Leg"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Expense" ADD CONSTRAINT "Expense_location_id_fkey" FOREIGN KEY ("location_id") REFERENCES "Location"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Expense" ADD CONSTRAINT "Expense_paid_by_id_fkey" FOREIGN KEY ("paid_by_id") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Photo" ADD CONSTRAINT "Photo_tourId_fkey" FOREIGN KEY ("tourId") REFERENCES "Tour"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Photo" ADD CONSTRAINT "Photo_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "Location"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Photo" ADD CONSTRAINT "Photo_uploaderId_fkey" FOREIGN KEY ("uploaderId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,4 @@
-- AlterTable
ALTER TABLE "Leg" ADD COLUMN "description" TEXT,
ADD COLUMN "endDate" TIMESTAMP(3),
ADD COLUMN "startDate" TIMESTAMP(3);
@@ -0,0 +1,4 @@
-- AlterTable
ALTER TABLE "Tour" ADD COLUMN "adultCount" INTEGER NOT NULL DEFAULT 1,
ADD COLUMN "childCount" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN "childDiscount" INTEGER NOT NULL DEFAULT 30;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Tour" ADD COLUMN "description" TEXT;
@@ -0,0 +1,16 @@
-- CreateTable
CREATE TABLE "Comment" (
"id" TEXT NOT NULL,
"content" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"locationId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
CONSTRAINT "Comment_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "Comment" ADD CONSTRAINT "Comment_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "Location"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Comment" ADD CONSTRAINT "Comment_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Tour" ADD COLUMN "tags" TEXT[];
@@ -0,0 +1,15 @@
-- DropForeignKey
ALTER TABLE "Photo" DROP CONSTRAINT "Photo_tourId_fkey";
-- AlterTable
ALTER TABLE "Photo" ADD COLUMN "originalUrl" TEXT,
ALTER COLUMN "tourId" DROP NOT NULL,
ALTER COLUMN "imageUrl" DROP NOT NULL;
-- AlterTable
ALTER TABLE "User" ADD COLUMN "isAnonymous" BOOLEAN NOT NULL DEFAULT false,
ALTER COLUMN "email" DROP NOT NULL,
ALTER COLUMN "passwordHash" DROP NOT NULL;
-- AddForeignKey
ALTER TABLE "Photo" ADD CONSTRAINT "Photo_tourId_fkey" FOREIGN KEY ("tourId") REFERENCES "Tour"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,6 @@
-- AlterTable
ALTER TABLE "Comment" ADD COLUMN "photoId" TEXT,
ALTER COLUMN "locationId" DROP NOT NULL;
-- AddForeignKey
ALTER TABLE "Comment" ADD CONSTRAINT "Comment_photoId_fkey" FOREIGN KEY ("photoId") REFERENCES "Photo"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Location" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "TourParticipant" ADD COLUMN "adultCount" INTEGER NOT NULL DEFAULT 1,
ADD COLUMN "childCount" INTEGER NOT NULL DEFAULT 0;
@@ -0,0 +1,22 @@
/*
Warnings:
- The primary key for the `TourParticipant` table will be changed. If it partially fails, the table could be left without primary key constraint.
- A unique constraint covering the columns `[tourId,userId]` on the table `TourParticipant` will be added. If there are existing duplicate values, this will fail.
- The required column `id` was added to the `TourParticipant` table with a prisma-level default value. This is not possible if the table is not empty. Please add this column as optional, then populate it before making it required.
*/
-- AlterTable
ALTER TABLE "TourParticipant" DROP CONSTRAINT "TourParticipant_pkey",
ADD COLUMN "displayName" TEXT,
ADD COLUMN "id" TEXT;
UPDATE "TourParticipant" SET "id" = md5(random()::text);
ALTER TABLE "TourParticipant" ALTER COLUMN "id" SET NOT NULL,
ALTER COLUMN "userId" DROP NOT NULL;
ALTER TABLE "TourParticipant" ADD CONSTRAINT "TourParticipant_pkey" PRIMARY KEY ("id");
-- CreateIndex
CREATE UNIQUE INDEX "TourParticipant_tourId_userId_key" ON "TourParticipant"("tourId", "userId");
@@ -0,0 +1,21 @@
-- CreateTable
CREATE TABLE "TourInvitation" (
"id" TEXT NOT NULL,
"tourId" TEXT NOT NULL,
"email" TEXT NOT NULL,
"role" "ParticipantRole" NOT NULL DEFAULT 'MEMBER',
"token" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expiredAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "TourInvitation_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "TourInvitation_token_key" ON "TourInvitation"("token");
-- CreateIndex
CREATE UNIQUE INDEX "TourInvitation_tourId_email_key" ON "TourInvitation"("tourId", "email");
-- AddForeignKey
ALTER TABLE "TourInvitation" ADD CONSTRAINT "TourInvitation_tourId_fkey" FOREIGN KEY ("tourId") REFERENCES "Tour"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,57 @@
-- CreateEnum
CREATE TYPE "ConnectionStatus" AS ENUM ('PENDING', 'ACCEPTED', 'REJECTED');
-- CreateEnum
CREATE TYPE "ConnectionType" AS ENUM ('FRIEND', 'FAMILY');
-- CreateTable
CREATE TABLE "UserConnection" (
"id" TEXT NOT NULL,
"requesterId" TEXT NOT NULL,
"receiverId" TEXT NOT NULL,
"status" "ConnectionStatus" NOT NULL DEFAULT 'PENDING',
"type" "ConnectionType" NOT NULL DEFAULT 'FRIEND',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "UserConnection_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "DirectMessage" (
"id" TEXT NOT NULL,
"senderId" TEXT NOT NULL,
"receiverId" TEXT NOT NULL,
"content" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "DirectMessage_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "UserConnection_requesterId_idx" ON "UserConnection"("requesterId");
-- CreateIndex
CREATE INDEX "UserConnection_receiverId_idx" ON "UserConnection"("receiverId");
-- CreateIndex
CREATE UNIQUE INDEX "UserConnection_requesterId_receiverId_key" ON "UserConnection"("requesterId", "receiverId");
-- CreateIndex
CREATE INDEX "DirectMessage_senderId_idx" ON "DirectMessage"("senderId");
-- CreateIndex
CREATE INDEX "DirectMessage_receiverId_idx" ON "DirectMessage"("receiverId");
-- AddForeignKey
ALTER TABLE "UserConnection" ADD CONSTRAINT "UserConnection_requesterId_fkey" FOREIGN KEY ("requesterId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "UserConnection" ADD CONSTRAINT "UserConnection_receiverId_fkey" FOREIGN KEY ("receiverId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DirectMessage" ADD CONSTRAINT "DirectMessage_senderId_fkey" FOREIGN KEY ("senderId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DirectMessage" ADD CONSTRAINT "DirectMessage_receiverId_fkey" FOREIGN KEY ("receiverId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,31 @@
-- AlterTable
ALTER TABLE "DirectMessage" ADD COLUMN "attachmentUrl" TEXT,
ADD COLUMN "latitude" DOUBLE PRECISION,
ADD COLUMN "longitude" DOUBLE PRECISION;
-- CreateTable
CREATE TABLE "TourMessage" (
"id" TEXT NOT NULL,
"tourId" TEXT NOT NULL,
"senderId" TEXT NOT NULL,
"content" TEXT NOT NULL,
"attachmentUrl" TEXT,
"latitude" DOUBLE PRECISION,
"longitude" DOUBLE PRECISION,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "TourMessage_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "TourMessage_tourId_idx" ON "TourMessage"("tourId");
-- CreateIndex
CREATE INDEX "TourMessage_senderId_idx" ON "TourMessage"("senderId");
-- AddForeignKey
ALTER TABLE "TourMessage" ADD CONSTRAINT "TourMessage_tourId_fkey" FOREIGN KEY ("tourId") REFERENCES "Tour"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TourMessage" ADD CONSTRAINT "TourMessage_senderId_fkey" FOREIGN KEY ("senderId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,73 @@
-- CreateTable
CREATE TABLE "WordFilter" (
"id" TEXT NOT NULL,
"word" TEXT NOT NULL,
"replacement" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "WordFilter_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ModerationSetting" (
"id" TEXT NOT NULL,
"blockNsfw" BOOLEAN NOT NULL DEFAULT false,
"blurFaces" BOOLEAN NOT NULL DEFAULT false,
CONSTRAINT "ModerationSetting_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "TourRating" (
"id" TEXT NOT NULL,
"tourId" TEXT NOT NULL,
"targetUserId" TEXT NOT NULL,
"raterUserId" TEXT NOT NULL,
"honesty" INTEGER NOT NULL DEFAULT 5,
"transparency" INTEGER NOT NULL DEFAULT 5,
"enthusiasm" INTEGER NOT NULL DEFAULT 5,
"cheerfulness" INTEGER NOT NULL DEFAULT 5,
"seriousness" INTEGER NOT NULL DEFAULT 5,
"planning" INTEGER NOT NULL DEFAULT 5,
"survival" INTEGER NOT NULL DEFAULT 5,
"averageScore" DOUBLE PRECISION NOT NULL DEFAULT 5.0,
"comment" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "TourRating_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "TourShare" (
"id" TEXT NOT NULL,
"tourId" TEXT NOT NULL,
"token" TEXT NOT NULL,
"isEnabled" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "TourShare_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "WordFilter_word_key" ON "WordFilter"("word");
-- CreateIndex
CREATE UNIQUE INDEX "TourRating_tourId_targetUserId_raterUserId_key" ON "TourRating"("tourId", "targetUserId", "raterUserId");
-- CreateIndex
CREATE UNIQUE INDEX "TourShare_tourId_key" ON "TourShare"("tourId");
-- CreateIndex
CREATE UNIQUE INDEX "TourShare_token_key" ON "TourShare"("token");
-- AddForeignKey
ALTER TABLE "TourRating" ADD CONSTRAINT "TourRating_tourId_fkey" FOREIGN KEY ("tourId") REFERENCES "Tour"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TourRating" ADD CONSTRAINT "TourRating_targetUserId_fkey" FOREIGN KEY ("targetUserId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TourRating" ADD CONSTRAINT "TourRating_raterUserId_fkey" FOREIGN KEY ("raterUserId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TourShare" ADD CONSTRAINT "TourShare_tourId_fkey" FOREIGN KEY ("tourId") REFERENCES "Tour"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,16 @@
-- CreateTable
CREATE TABLE "BusinessReport" (
"id" TEXT NOT NULL,
"type" TEXT NOT NULL,
"name" TEXT NOT NULL,
"phone" TEXT,
"email" TEXT,
"address" TEXT,
"latitude" DOUBLE PRECISION,
"longitude" DOUBLE PRECISION,
"reason" TEXT NOT NULL,
"isBlacklisted" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "BusinessReport_pkey" PRIMARY KEY ("id")
);
@@ -0,0 +1,49 @@
-- AlterTable
ALTER TABLE "ModerationSetting" ADD COLUMN "trashRetentionDays" INTEGER NOT NULL DEFAULT 30;
-- AlterTable
ALTER TABLE "Photo" ADD COLUMN "deletedAt" TIMESTAMP(3),
ADD COLUMN "isDeleted" BOOLEAN NOT NULL DEFAULT false;
-- AlterTable
ALTER TABLE "Tour" ADD COLUMN "deletedAt" TIMESTAMP(3),
ADD COLUMN "isDeleted" BOOLEAN NOT NULL DEFAULT false;
-- CreateTable
CREATE TABLE "TourNote" (
"id" TEXT NOT NULL,
"tourId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"title" TEXT NOT NULL,
"content" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"isDeleted" BOOLEAN NOT NULL DEFAULT false,
"deletedAt" TIMESTAMP(3),
CONSTRAINT "TourNote_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "RecommendedLocation" (
"id" TEXT NOT NULL,
"type" TEXT NOT NULL,
"name" TEXT NOT NULL,
"phone" TEXT,
"email" TEXT,
"address" TEXT,
"latitude" DOUBLE PRECISION,
"longitude" DOUBLE PRECISION,
"description" TEXT NOT NULL,
"stars" INTEGER NOT NULL DEFAULT 5,
"isApproved" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "RecommendedLocation_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "TourNote" ADD CONSTRAINT "TourNote_tourId_fkey" FOREIGN KEY ("tourId") REFERENCES "Tour"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TourNote" ADD CONSTRAINT "TourNote_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,4 @@
-- AlterTable
ALTER TABLE "Photo" ADD COLUMN "flaggedAt" TIMESTAMP(3),
ADD COLUMN "flaggedReason" TEXT,
ADD COLUMN "isFlagged" BOOLEAN NOT NULL DEFAULT false;
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"
@@ -2,22 +2,18 @@ import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import { PrismaPg } from '@prisma/adapter-pg';
import { Pool } from 'pg';
import 'dotenv/config';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
private pool: Pool;
constructor() {
console.log('--- [PRISMA CHECK] ---');
console.log('DATABASE_URL nhận được:', process.env.DATABASE_URL ? 'ĐÃ ĐỌC THÀNH CÔNG ✔️' : 'VẪN BỊ UNDEFINED ❌');
console.log('----------------------');
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const adapter = new PrismaPg(pool);
super({ adapter });
this.pool = pool;
}
async onModuleInit() { await this.$connect(); }
async onModuleDestroy() {
await this.$disconnect();
await this.pool.end();
}
}
async onModuleDestroy() { await this.$disconnect(); }
}
+402
View File
@@ -0,0 +1,402 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
previewFeatures = ["driverAdapters"]
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// --- Enums ---
enum ParticipantRole {
OWNER
MANAGER
MEMBER
MEMBER_NO_FINANCE
VIEWER_ONLY
}
enum JoinRequestStatus {
PENDING
ACCEPTED
REJECTED
}
enum ExpenseCategory {
ACCOMMODATION
FOOD
TRANSPORT
TICKET
OTHER
}
enum LocationStatus {
PENDING
COMPLETED
}
enum LocationType {
MOVE
VISIT
REST
EAT
}
enum PrivacyLevel {
PUBLIC
TOUR_ONLY
PRIVATE
}
// --- Models ---
model User {
id String @id @default(uuid())
email String? @unique
passwordHash String?
name String?
phone String?
address String?
avatar String?
createdAt DateTime @default(now())
isAdmin Boolean @default(false)
isBlocked Boolean @default(false)
isAnonymous Boolean @default(false)
createdTours Tour[] @relation("TourCreator")
tourParticipations TourParticipant[]
requestedJoinRequests JoinRequest[] @relation("JoinRequestUser")
receivedJoinRequests JoinRequest[] @relation("JoinRequester")
uploadedPhotos Photo[]
paidExpenses Expense[] @relation("ExpensePaidBy")
comments Comment[]
sentConnections UserConnection[] @relation("ConnectionRequester")
receivedConnections UserConnection[] @relation("ConnectionReceiver")
sentMessages DirectMessage[] @relation("MessageSender")
receivedMessages DirectMessage[] @relation("MessageReceiver")
tourMessages TourMessage[]
receivedRatings TourRating[] @relation("RatedUser")
sentRatings TourRating[] @relation("RatingUser")
tourNotes TourNote[]
}
model Tour {
id String @id @default(uuid())
title String
description String?
startDate DateTime?
endDate DateTime?
createdAt DateTime @default(now())
totalCost Decimal @default(0) @db.Decimal(15, 2)
adultCount Int @default(1)
childCount Int @default(0)
childDiscount Int @default(30)
tags String[]
createdById String
creator User @relation("TourCreator", fields: [createdById], references: [id])
participants TourParticipant[]
joinRequests JoinRequest[]
legs Leg[]
photos Photo[]
invitations TourInvitation[]
tourMessages TourMessage[]
ratings TourRating[]
share TourShare?
notes TourNote[]
isDeleted Boolean @default(false)
deletedAt DateTime?
}
model JoinRequest {
id String @id @default(uuid())
tourId String
userId String
requestedById String
status JoinRequestStatus @default(PENDING)
createdAt DateTime @default(now())
tour Tour @relation(fields: [tourId], references: [id], onDelete: Cascade)
user User @relation("JoinRequestUser", fields: [userId], references: [id], onDelete: Cascade)
requestedBy User @relation("JoinRequester", fields: [requestedById], references: [id], onDelete: Cascade)
@@index([tourId, status])
@@index([userId])
}
model TourParticipant {
id String @id @default(uuid())
tourId String
userId String?
role ParticipantRole @default(MEMBER)
displayName String?
adultCount Int @default(1)
childCount Int @default(0)
tour Tour @relation(fields: [tourId], references: [id], onDelete: Cascade)
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([tourId, userId])
}
model Leg {
id String @id @default(uuid())
tourId String
sequence Int
note String? @db.Text
startDate DateTime?
endDate DateTime?
description String? @db.Text
tour Tour @relation(fields: [tourId], references: [id], onDelete: Cascade)
locations Location[]
expenses Expense[]
}
model Location {
id String @id @default(uuid())
legId String
name String
address String?
latitude Float
longitude Float
plannedStart DateTime?
plannedEnd DateTime?
actualStart DateTime?
actualEnd DateTime?
status LocationStatus @default(PENDING)
type LocationType @default(VISIT)
leg Leg @relation(fields: [legId], references: [id], onDelete: Cascade)
expenses Expense[]
photos Photo[]
comments Comment[]
createdAt DateTime @default(now())
}
model Expense {
id String @id @default(uuid())
legId String @map("leg_id")
locationId String? @map("location_id")
category ExpenseCategory
amount Decimal @db.Decimal(15, 2)
description String? @db.Text
note String? @db.Text
paidById String? @map("paid_by_id")
leg Leg @relation(fields: [legId], references: [id], onDelete: Cascade)
location Location? @relation(fields: [locationId], references: [id], onDelete: Cascade)
paidBy User? @relation("ExpensePaidBy", fields: [paidById], references: [id], onDelete: SetNull)
}
model Photo {
id String @id @default(uuid())
tourId String?
locationId String?
uploaderId String
imageUrl String?
originalUrl String?
capturedAt DateTime @default(now())
metadata Json?
privacy PrivacyLevel @default(TOUR_ONLY)
isFlagged Boolean @default(false)
flaggedReason String?
flaggedAt DateTime?
tour Tour? @relation(fields: [tourId], references: [id], onDelete: SetNull)
location Location? @relation(fields: [locationId], references: [id], onDelete: SetNull)
uploader User @relation(fields: [uploaderId], references: [id])
comments Comment[]
isDeleted Boolean @default(false)
deletedAt DateTime?
}
model Comment {
id String @id @default(uuid())
content String
createdAt DateTime @default(now())
locationId String?
location Location? @relation(fields: [locationId], references: [id], onDelete: Cascade)
photoId String?
photo Photo? @relation(fields: [photoId], references: [id], onDelete: Cascade)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model TourInvitation {
id String @id @default(uuid())
tourId String
email String
role ParticipantRole @default(MEMBER)
token String @unique
createdAt DateTime @default(now())
expiredAt DateTime
tour Tour @relation(fields: [tourId], references: [id], onDelete: Cascade)
@@unique([tourId, email])
}
enum ConnectionStatus {
PENDING
ACCEPTED
REJECTED
}
enum ConnectionType {
FRIEND
FAMILY
}
model UserConnection {
id String @id @default(uuid())
requesterId String
receiverId String
status ConnectionStatus @default(PENDING)
type ConnectionType @default(FRIEND)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
requester User @relation("ConnectionRequester", fields: [requesterId], references: [id], onDelete: Cascade)
receiver User @relation("ConnectionReceiver", fields: [receiverId], references: [id], onDelete: Cascade)
@@unique([requesterId, receiverId])
@@index([requesterId])
@@index([receiverId])
}
model DirectMessage {
id String @id @default(uuid())
senderId String
receiverId String
content String @db.Text
attachmentUrl String?
latitude Float?
longitude Float?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sender User @relation("MessageSender", fields: [senderId], references: [id], onDelete: Cascade)
receiver User @relation("MessageReceiver", fields: [receiverId], references: [id], onDelete: Cascade)
@@index([senderId])
@@index([receiverId])
}
model TourMessage {
id String @id @default(uuid())
tourId String
senderId String
content String @db.Text
attachmentUrl String?
latitude Float?
longitude Float?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tour Tour @relation(fields: [tourId], references: [id], onDelete: Cascade)
sender User @relation(fields: [senderId], references: [id], onDelete: Cascade)
@@index([tourId])
@@index([senderId])
}
model WordFilter {
id String @id @default(uuid())
word String @unique
replacement String
createdAt DateTime @default(now())
}
model ModerationSetting {
id String @id @default(uuid())
blockNsfw Boolean @default(false)
blurFaces Boolean @default(false)
trashRetentionDays Int @default(30)
}
model TourRating {
id String @id @default(uuid())
tourId String
targetUserId String
raterUserId String
honesty Int @default(5)
transparency Int @default(5)
enthusiasm Int @default(5)
cheerfulness Int @default(5)
seriousness Int @default(5)
planning Int @default(5)
survival Int @default(5)
averageScore Float @default(5.0)
comment String? @db.Text
createdAt DateTime @default(now())
tour Tour @relation(fields: [tourId], references: [id], onDelete: Cascade)
targetUser User @relation("RatedUser", fields: [targetUserId], references: [id], onDelete: Cascade)
raterUser User @relation("RatingUser", fields: [raterUserId], references: [id], onDelete: Cascade)
@@unique([tourId, targetUserId, raterUserId])
}
model TourShare {
id String @id @default(uuid())
tourId String @unique
token String @unique @default(uuid())
isEnabled Boolean @default(true)
createdAt DateTime @default(now())
tour Tour @relation(fields: [tourId], references: [id], onDelete: Cascade)
}
model BusinessReport {
id String @id @default(uuid())
type String // e.g. "USER", "RESTAURANT", "HOTEL", "HOMESTAY"
name String
phone String?
email String?
address String?
latitude Float?
longitude Float?
reason String @db.Text
isBlacklisted Boolean @default(false)
createdAt DateTime @default(now())
}
model TourNote {
id String @id @default(uuid())
tourId String
tour Tour @relation(fields: [tourId], references: [id], onDelete: Cascade)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
title String
content String @db.Text
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
isDeleted Boolean @default(false)
deletedAt DateTime?
}
model RecommendedLocation {
id String @id @default(uuid())
type String // e.g. "RESTAURANT", "HOTEL", "HOMESTAY"
name String
phone String?
email String?
address String?
latitude Float?
longitude Float?
description String @db.Text
stars Int @default(5)
isApproved Boolean @default(false)
createdAt DateTime @default(now())
}
+19
View File
@@ -0,0 +1,19 @@
import { PrismaClient } from '@prisma/client';
import { PrismaPg } from '@prisma/adapter-pg';
import { Pool } from 'pg';
import * as dotenv from 'dotenv';
import * as path from 'path';
const envPath = path.resolve(process.cwd(), '..', '.env');
dotenv.config({ path: envPath });
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const adapter = new PrismaPg(pool);
const prisma = new PrismaClient({ adapter });
async function main() {
const users = await prisma.user.findMany();
console.log('USERS:', users);
}
main().finally(() => pool.end());
+41 -16
View File
@@ -1,7 +1,12 @@
import { PrismaClient } from '@prisma/client';
import { PrismaPg } from '@prisma/adapter-pg';
import { Pool } from 'pg';
import 'dotenv/config';
import * as dotenv from 'dotenv';
import * as path from 'path';
import bcrypt from 'bcrypt';
const envPath = path.resolve(process.cwd(), '..', '.env');
dotenv.config({ path: envPath });
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const adapter = new PrismaPg(pool);
@@ -20,7 +25,9 @@ async function main() {
data: {
email: 'owner@travel.com',
name: 'Lộc Phạm (Chủ Tour)',
passwordHash: 'hashed_password_123',
// Hash mật khẩu '123456' để có thể đăng nhập thực tế
passwordHash: await bcrypt.hash('123456', 10),
isAdmin: true,
},
});
@@ -28,7 +35,7 @@ async function main() {
data: {
email: 'photomember@travel.com',
name: 'Nguyễn Văn Ảnh (Chỉ xem ảnh)',
passwordHash: 'hashed_password_456',
passwordHash: await bcrypt.hash('123456', 10),
},
});
@@ -38,11 +45,11 @@ async function main() {
title: 'Hành trình khám phá TP.HCM',
startDate: new Date('2023-11-20'),
endDate: new Date('2023-11-21'),
creatorId: owner.id,
members: {
createdById: owner.id,
participants: {
create: [
{ userId: owner.id, role: 'OWNER' },
{ userId: photoMember.id, role: 'MEMBER_PHOTO_ONLY' },
{ userId: photoMember.id, role: 'VIEWER_ONLY' },
],
},
},
@@ -52,25 +59,23 @@ async function main() {
const leg1 = await prisma.leg.create({
data: {
tourId: tour.id,
sequenceNumber: 1,
notes: 'Khám phá lịch sử trung tâm',
places: {
sequence: 1,
note: 'Khám phá lịch sử trung tâm',
locations: {
create: [
{
name: 'Dinh Độc Lập',
address: '135 Nam Kỳ Khởi Nghĩa, Quận 1',
latitude: 10.777,
longitude: 106.695,
sequenceInLeg: 1,
arrivalTime: new Date('2023-11-20T08:00:00Z'),
plannedStart: new Date('2023-11-20T08:00:00Z'),
},
{
name: 'Bưu điện Thành phố',
address: '02 Công xã Paris, Quận 1',
latitude: 10.779,
longitude: 106.699,
sequenceInLeg: 2,
arrivalTime: new Date('2023-11-20T10:00:00Z'),
plannedStart: new Date('2023-11-20T10:00:00Z'),
},
],
},
@@ -78,16 +83,36 @@ async function main() {
});
console.log('--- Đang tạo chi phí mẫu... ---');
await prisma.expense.create({
const expense1 = await prisma.expense.create({
data: {
legId: leg1.id,
category: 'DINING',
category: 'FOOD',
amount: 500000,
currency: 'VND',
description: 'Ăn trưa đặc sản Quận 1',
note: 'Đặt trước cho 3 người',
paidById: owner.id,
},
});
console.log('--- Đang tạo bình luận mẫu... ---');
const dinhDocLap = await prisma.location.findFirst({ where: { name: 'Dinh Độc Lập' } });
if (dinhDocLap) {
await prisma.comment.createMany({
data: [
{
content: 'Chỗ này rất đẹp, giàu giá trị lịch sử!',
locationId: dinhDocLap.id,
userId: owner.id,
},
{
content: 'Nên đi vào buổi sáng cho mát mẻ mọi người nhé.',
locationId: dinhDocLap.id,
userId: photoMember.id,
}
],
});
}
console.log('--- Seed dữ liệu hoàn tất! ---');
console.log(`Email đăng nhập Owner: ${owner.email}`);
console.log(`Email đăng nhập Photo Only: ${photoMember.email}`);
@@ -1,5 +1,5 @@
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
import { PrismaService } from './prisma.service.js';
import { PrismaService } from '../../prisma/prisma.service';
@Injectable()
export class AdminGuard implements CanActivate {
+23
View File
@@ -0,0 +1,23 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { UnauthorizedException } from '@nestjs/common';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}
// Guard that rejects anonymous/guest users - used for dashboard and sensitive endpoints
@Injectable()
export class JwtAuthGuardNoAnonymous extends AuthGuard('jwt') {
handleRequest(err: any, user: any, info: any) {
if (err || !user) {
throw err || new UnauthorizedException('Phiên làm việc không hợp lệ hoặc đã hết hạn');
}
// Reject anonymous/temporary users
if (user.isAnonymous) {
throw new UnauthorizedException('Tài khoản khách không có quyền truy cập tính năng này. Vui lòng đăng ký tài khoản để tiếp tục.');
}
return user;
}
}
@@ -1,7 +1,7 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { PrismaService } from './prisma.service.js';
import { PrismaService } from '../../prisma/prisma.service';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
@@ -22,6 +22,8 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
throw new UnauthorizedException('Người dùng không tồn tại hoặc phiên làm việc hết hạn');
}
// Note: We allow anonymous users to pass JWT validation
// Individual endpoints decide whether to accept anonymous users based on their guard
return user;
}
}
@@ -0,0 +1,91 @@
import { CallHandler, ExecutionContext, Injectable, NestInterceptor, Inject } from '@nestjs/common';
import { Observable, of } from 'rxjs';
import { tap } from 'rxjs/operators';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Cache } from 'cache-manager';
import * as zlib from 'zlib';
import { promisify } from 'util';
import { HttpAdapterHost } from '@nestjs/core';
// Promisify các hàm nén/giải nén
const gzip = promisify(zlib.gzip);
const gunzip = promisify(zlib.gunzip);
// Ngưỡng nén: Chỉ nén nếu chuỗi JSON lớn hơn ngưỡng này (bytes)
// Nén dữ liệu quá nhỏ có thể làm tăng kích thước do overhead của header nén
const COMPRESSION_THRESHOLD = 100;
@Injectable()
export class CompressCacheInterceptor implements NestInterceptor {
constructor(
@Inject(CACHE_MANAGER) private cacheManager: Cache,
private readonly httpAdapterHost: HttpAdapterHost, // Để truy cập request/response
) {}
async intercept(context: ExecutionContext, next: CallHandler): Promise<Observable<any>> {
const httpAdapter = this.httpAdapterHost.httpAdapter;
const request = context.getArgByIndex(0);
const response = context.getArgByIndex(1);
// Chỉ áp dụng cho các request GET
if (httpAdapter.getRequestMethod(request) !== 'GET') {
return next.handle();
}
const cacheKey = httpAdapter.getRequestUrl(request);
let cachedData = await this.cacheManager.get<Buffer>(cacheKey);
if (cachedData) {
try {
// Kiểm tra xem dữ liệu có phải là Buffer và có Gzip header (0x1f 0x8b) không
if (Buffer.isBuffer(cachedData) && cachedData.length > 2 && cachedData[0] === 0x1f && cachedData[1] === 0x8b) {
const decompressed = await gunzip(cachedData);
const jsonString = decompressed.toString('utf8');
httpAdapter.setHeader(response, 'Content-Type', 'application/json');
httpAdapter.setHeader(response, 'X-Cache', 'HIT (Compressed)');
return of(JSON.parse(jsonString));
} else {
// Dữ liệu không nén (lưu dưới dạng string hoặc buffer thường)
const jsonString = cachedData.toString();
httpAdapter.setHeader(response, 'Content-Type', 'application/json');
httpAdapter.setHeader(response, 'X-Cache', 'HIT (Uncompressed)');
return of(JSON.parse(jsonString));
}
} catch (e) {
console.error(`[Cache] Lỗi giải nén dữ liệu cache cho key ${cacheKey}:`, e);
// Nếu giải nén lỗi, coi như cache miss và xóa cache bị lỗi
await this.cacheManager.del(cacheKey);
}
}
// Cache miss hoặc giải nén lỗi, tiếp tục xử lý request
// Đặt header MISS ngay lập tức trước khi chạy logic Controller
httpAdapter.setHeader(response, 'X-Cache', 'MISS');
return next.handle().pipe(
tap(async (data) => { // Sử dụng tap để thực hiện side effect (lưu cache) mà không thay đổi dữ liệu gốc
if (!data) return;
const jsonString = JSON.stringify(data);
const ttl = 60000; // TTL mặc định 1 phút (có thể cấu hình từ CACHE_TTL.DEFAULT)
if (jsonString.length > COMPRESSION_THRESHOLD) {
try {
const compressed = await gzip(Buffer.from(jsonString, 'utf8'));
await this.cacheManager.set(cacheKey, compressed, ttl);
console.log(`[Cache] 📦 Đã nén dữ liệu cho: ${cacheKey} (${jsonString.length} -> ${compressed.length} bytes)`);
// Không setHeader ở đây vì response có thể đã gửi xong
} catch (e) {
console.error(`[Cache] Lỗi nén dữ liệu cho key ${cacheKey}:`, e);
// Nếu nén lỗi, lưu dữ liệu không nén làm fallback
await this.cacheManager.set(cacheKey, jsonString, ttl);
}
} else {
// Dữ liệu quá nhỏ, lưu không nén
await this.cacheManager.set(cacheKey, jsonString, ttl);
}
})
);
}
}
@@ -1,5 +1,5 @@
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
import { PrismaService } from './prisma.service.js';
import { PrismaService } from '../../prisma/prisma.service';
@Injectable()
export class TourRoleGuard implements CanActivate {
@@ -7,22 +7,21 @@ export class TourRoleGuard implements CanActivate {
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const user = request.user; // Giả sử đã qua AuthGuard (Passport/JWT)
const user = request.user;
// Lấy tourId từ URL params (:id hoặc :tourId)
const tourId = parseInt(request.params.id || request.params.tourId);
const tourId = request.params.id || request.params.tourId;
const path = request.url;
if (!user || isNaN(tourId)) {
if (!user || !tourId) {
throw new ForbiddenException("Thông tin xác thực hoặc mã Tour không hợp lệ.");
}
/**
* TỐI ƯU: Chỉ truy vấn Database 1 lần duy nhất đ lấy thông tin thành viên.
* Chúng ta lưu kết quả vào request object đ các interceptor hoặc controller
* sau này thể dùng lại không cần query lại.
*/
const membership = await this.prisma.tourMember.findUnique({
if (path.includes('/join-requests') && request.method === 'POST' && (!request.params.requestId || /\/join-requests\/[^/]+\/(accept|reject)$/.test(path))) {
request.tourParticipation = null;
return true;
}
const participation = await this.prisma.tourParticipant.findUnique({
where: {
tourId_userId: {
tourId: tourId,
@@ -31,20 +30,18 @@ export class TourRoleGuard implements CanActivate {
},
});
if (!membership) {
if (!participation) {
throw new ForbiddenException("Bạn không phải là thành viên của tour này.");
}
// Gắn thông tin vào request để sử dụng ở tầng Controller
request.tourMembership = membership;
request.tourParticipation = participation;
const role = membership.role;
const role = participation.role;
const isPlanPath = path.includes('/plans');
const isExpensePath = path.includes('/expenses');
// 1. Chặn MEMBER_PHOTO_ONLY và VIEWER_EXTERNAL truy cập Plans & Expenses
if (
(role === 'MEMBER_PHOTO_ONLY' || role === 'VIEWER_EXTERNAL') &&
(role === 'MEMBER_NO_FINANCE' || role === 'VIEWER_ONLY') &&
(isPlanPath || isExpensePath)
) {
throw new ForbiddenException("Bạn không có quyền xem kế hoạch và chi phí của tour này.");
+4746
View File
File diff suppressed because it is too large Load Diff
+28
View File
@@ -0,0 +1,28 @@
async function test() {
const loginRes = await fetch('http://localhost:3001/api/v1/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: 'owner@travel.com', password: '123456' })
});
const loginData = await loginRes.json();
if (loginRes.ok) {
const token = loginData.access_token;
const tourId = '84d84c21-c12b-4a18-9655-043c85f5c0c5'; // Tour ID from seed
const res = await fetch(`http://localhost:3001/api/v1/tours/${tourId}/members`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ displayName: 'Offline Member X', role: 'MEMBER' })
});
console.log('Add status:', res.status);
const data = await res.json();
console.log('Add response:', data);
} else {
console.error('Login failed:', loginData);
}
}
test().catch(console.error);
+14
View File
@@ -0,0 +1,14 @@
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
async function main() {
const users = await prisma.user.findMany();
console.log('--- Users in DB ---');
console.log(users);
const participants = await prisma.tourParticipant.findMany();
console.log('--- Tour Participants in DB ---');
console.log(participants);
}
main().catch(console.error).finally(() => prisma.$disconnect());
+17
View File
@@ -0,0 +1,17 @@
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
async function main() {
const tours = await prisma.tour.findMany({
include: {
participants: {
include: {
user: { select: { email: true, name: true } }
}
}
}
});
console.log(JSON.stringify(tours, null, 2));
}
main().finally(() => prisma.$disconnect());
+36
View File
@@ -0,0 +1,36 @@
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
async function main() {
const tourId = '84d84c21-c12b-4a18-9655-043c85f5c0c5'; // Use existing tour ID from seed
console.log('Inserting first manual member...');
try {
const p1 = await prisma.tourParticipant.create({
data: {
tourId,
role: 'MEMBER',
displayName: 'Manual Member 1'
}
});
console.log('Inserted:', p1);
} catch (err) {
console.error('Failed to insert first:', err);
}
console.log('Inserting second manual member...');
try {
const p2 = await prisma.tourParticipant.create({
data: {
tourId,
role: 'MEMBER',
displayName: 'Manual Member 2'
}
});
console.log('Inserted:', p2);
} catch (err) {
console.error('Failed to insert second:', err);
}
}
main().catch(console.error).finally(() => prisma.$disconnect());
+22
View File
@@ -0,0 +1,22 @@
async function test() {
const loginRes = await fetch('http://localhost:3001/api/v1/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: 'photomember@travel.com', password: '123456' })
});
const loginData = await loginRes.json();
if (loginRes.ok) {
const token = loginData.access_token;
const usersRes = await fetch('http://localhost:3001/api/v1/users?q=owner@travel.com', {
headers: { 'Authorization': `Bearer ${token}` }
});
console.log('Users status:', usersRes.status);
const usersData = await usersRes.json();
console.log('Users data:', usersData);
} else {
console.error('Login failed:', loginData);
}
}
test().catch(console.error);
+27
View File
@@ -0,0 +1,27 @@
{
"compilerOptions": {
"module": "commonjs",
"moduleResolution": "node", // Đm bo module resolution là 'node'
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2021",
"sourceMap": true,
"outDir": "./dist",
"skipLibCheck": true,
"strictNullChecks": false,
"noImplicitAny": false,
"strictBindCallApply": false,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": false,
"resolveJsonModule": true, // Cho phép import các file .json
"esModuleInterop": true, // Cho phép cú pháp import/export ES Modules vi CommonJS
"baseUrl": "./",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*"]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 868 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 844 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 408 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 422 KiB

+208
View File
@@ -0,0 +1,208 @@
import React, { useState } from 'react';
// Types for notification modal
export interface NotificationModalProps {
isOpen: boolean;
title?: string;
message: string;
onConfirm?: () => void;
onCancel?: () => void;
type?: 'info' | 'success' | 'warning' | 'error';
confirmButtonText?: string;
cancelButtonText?: string;
}
// Icon components for different types
const Icons = {
info: (props: React.SVGProps<SVGSVGElement>) => (
<svg {...props} viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="2" />
<path d="M12 16v-4" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
<path d="M12 8h.01" stroke="currentColor" strokeWidth="3" strokeLinecap="round" />
</svg>
),
success: (props: React.SVGProps<SVGSVGElement>) => (
<svg {...props} viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="2" />
<path d="M8 12l2.5 2.5L15.5 9" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
),
warning: (props: React.SVGProps<SVGSVGElement>) => (
<svg {...props} viewBox="0 0 24 24" fill="none">
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" stroke="currentColor" strokeWidth="2" />
<line x1="12" y1="9" x2="12" y2="13" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
<circle cx="12" cy="17" r="1" fill="currentColor" />
</svg>
),
error: (props: React.SVGProps<SVGSVGElement>) => (
<svg {...props} viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="2" />
<line x1="15" y1="9" x2="9" y2="15" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
<line x1="9" y1="9" x2="15" y2="15" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
),
};
// Default props
const defaultProps: Partial<NotificationModalProps> = {
title: 'Thông báo',
type: 'info',
confirmButtonText: 'OK',
cancelButtonText: 'Hủy',
};
export const NotificationModal: React.FC<NotificationModalProps> = ({
isOpen,
title = defaultProps.title,
message,
onConfirm,
onCancel,
type = defaultProps.type,
confirmButtonText = defaultProps.confirmButtonText,
cancelButtonText = defaultProps.cancelButtonText,
}) => {
const [isAnimating, setIsAnimating] = useState(false);
// Get colors based on type
const getTypeStyles = () => {
switch (type) {
case 'success':
return { bg: '#d4edda', border: '#c3e6cb', text: '#155724' };
case 'warning':
return { bg: '#fff3cd', border: '#ffeeba', text: '#856404' };
case 'error':
return { bg: '#f8d7da', border: '#f5c6cb', text: '#721c24' };
default:
return { bg: '#e2e3e5', border: '#d6d8db', text: '#383d41' };
}
};
const styles = getTypeStyles();
// Animation classes based on state
const getAnimationClass = () => {
if (!isOpen) return 'opacity-0 translate-y-4';
if (isAnimating && onCancel) return 'animate-fade-out';
return 'animate-fade-in';
};
// Handle confirm click
const handleConfirm = () => {
setIsAnimating(true);
onConfirm?.();
setTimeout(() => setIsAnimating(false), 300);
};
// Handle cancel click
const handleCancel = () => {
setIsAnimating(true);
onCancel?.();
setTimeout(() => setIsAnimating(false), 300);
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-[100] p-4">
<div
className={`bg-white rounded-lg shadow-xl max-w-md w-full transform transition-all duration-300 ${getAnimationClass()}`}
role="alertdialog"
aria-modal="true"
aria-labelledby="modal-title"
aria-describedby="modal-message"
>
{/* Header */}
<div className={`p-6 border-b ${styles.border}`}>
<div className="flex items-center gap-3">
{type === 'success' && <Icons.success className="w-5 h-5 text-green-600" />}
{type === 'warning' && <Icons.warning className="w-5 h-5 text-yellow-600" />}
{type === 'error' && <Icons.error className="w-5 h-5 text-red-600" />}
{type === 'info' && <Icons.info className="w-5 h-5 text-blue-600" />}
<h2 id="modal-title" className={`text-xl font-semibold ${styles.text}`}>
{title}
</h2>
</div>
</div>
{/* Body */}
<div className="p-6">
<p id="modal-message" className="text-gray-700 leading-relaxed">{message}</p>
</div>
{/* Footer */}
<div className={`px-6 py-4 flex justify-end gap-3 border-t ${styles.border}`}>
{onCancel && (
<button
onClick={handleCancel}
className="px-4 py-2 text-gray-700 hover:bg-gray-100 rounded-md transition-colors"
>
{cancelButtonText}
</button>
)}
{onConfirm && (
<button
onClick={handleConfirm}
className={`px-4 py-2 text-white rounded-md font-medium transition-colors ${
type === 'error'
? 'bg-red-600 hover:bg-red-700'
: 'bg-blue-600 hover:bg-blue-700'
}`}
>
{confirmButtonText}
</button>
)}
</div>
</div>
</div>
);
};
// Hook for easy usage without props management
export const useNotificationModal = () => {
const [modalState, setModalState] = useState<{
isOpen: boolean;
title?: string;
message: string;
type?: 'info' | 'success' | 'warning' | 'error';
onConfirm?: () => void;
onCancel?: () => void;
} | null>(null);
const openModal = (
title: string,
message: string,
type: 'info' | 'success' | 'warning' | 'error' = 'info',
onConfirm?: () => void,
onCancel?: () => void,
) => {
setModalState({
isOpen: true,
title,
message,
type,
onConfirm,
onCancel,
});
// Auto-close after 5 seconds if no confirm action
const timer = setTimeout(() => {
if (onCancel) {
setModalState((prev) => ({ ...prev, isOpen: false }));
}
}, 5000);
return () => clearTimeout(timer);
};
const closeModal = () => {
setModalState((prev) => prev ? { ...prev, isOpen: false } : null);
};
return {
modalState,
openModal,
closeModal,
};
};
export default NotificationModal;
+8
View File
@@ -0,0 +1,8 @@
import React from 'react';
export declare const AddLocationModal: ({ isOpen, onClose, tourId, initialLegId, editingLocation }: {
isOpen: boolean;
onClose: () => void;
tourId: string;
initialLegId?: string;
editingLocation?: any;
}) => React.JSX.Element;
+146
View File
File diff suppressed because one or more lines are too long
+1
View File
File diff suppressed because one or more lines are too long
+31
View File
@@ -0,0 +1,31 @@
import React from 'react';
interface AddMemberModalProps {
isOpen: boolean;
onClose: () => void;
tourId: string;
participants?: Array<{
userId: string;
role: string;
user?: {
id: string;
name: string;
email: string;
};
}>;
joinRequests?: Array<{
id: string;
userId: string;
user?: {
id: string;
name: string;
email: string;
};
status: string;
requestedById: string;
}>;
onRemoveMember?: (userId: string) => Promise<void>;
onMemberAdded?: () => void;
userRole?: string;
}
export declare const AddMemberModal: React.FC<AddMemberModalProps>;
export {};
+154
View File
@@ -0,0 +1,154 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useState, useEffect, useMemo } from 'react';
import { X, UserPlus, Loader2, Shield, Trash2, Clock } from 'lucide-react';
export const AddMemberModal = ({ isOpen, onClose, tourId, participants = [], joinRequests = [], onRemoveMember, onMemberAdded, userRole }) => {
const [query, setQuery] = useState('');
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(false);
const [selectedUser, setSelectedUser] = useState(null);
const [role, setRole] = useState('MEMBER');
const [submitting, setSubmitting] = useState(false);
const [fetchError, setFetchError] = useState('');
const [submitError, setSubmitError] = useState('');
const [isConfirmOpen, setIsConfirmOpen] = useState(false);
const [confirmTarget, setConfirmTarget] = useState(null);
const [actionLoading, setActionLoading] = useState(null);
const participantIds = useMemo(() => new Set(participants.map((p) => p.userId)), [participants]);
const requestUserIds = useMemo(() => new Set(joinRequests.map((r) => r.userId)), [joinRequests]);
const visibleUsers = useMemo(() => users.filter((u) => !participantIds.has(u.id)), [users, participantIds]);
const canCreateDirectly = !userRole || userRole === 'OWNER' || userRole === 'MANAGER';
const fetchUsers = async () => {
setLoading(true);
setFetchError('');
try {
const API_BASE = `http://${window.location.hostname}:3001`;
const res = await fetch(`${API_BASE}/api/v1/users?q=${encodeURIComponent(query)}`, {
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
});
if (!res.ok)
throw new Error('Không thể tải danh sách người dùng');
const data = await res.json();
setUsers(Array.isArray(data) ? data : []);
}
catch (err) {
setFetchError(err.message || 'Không thể tải danh sách người dùng');
}
finally {
setLoading(false);
}
};
useEffect(() => {
if (!isOpen)
return;
fetchUsers();
}, [isOpen]);
useEffect(() => {
if (!isOpen) {
setQuery('');
setSelectedUser(null);
setRole('MEMBER');
setFetchError('');
setSubmitError('');
}
}, [isOpen]);
const handleRemove = async (userId, memberName) => {
if (!onRemoveMember)
return;
setConfirmTarget({ userId, name: memberName });
setIsConfirmOpen(true);
};
const confirmRemove = async () => {
if (!confirmTarget || !onRemoveMember)
return;
try {
await onRemoveMember(confirmTarget.userId);
}
catch (err) {
setSubmitError(err.message || 'Không thể xóa thành viên');
}
finally {
setIsConfirmOpen(false);
setConfirmTarget(null);
}
};
const handleRequestAction = async (reqId, action, userName) => {
if (!onMemberAdded)
return;
setActionLoading(reqId);
try {
const API_BASE = `http://${window.location.hostname}:3001`;
const endpoint = action === 'accept'
? `${API_BASE}/api/v1/tours/${tourId}/join-requests/${reqId}/accept`
: `${API_BASE}/api/v1/tours/${tourId}/join-requests/${reqId}/reject`;
const res = await fetch(endpoint, {
method: 'POST',
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` },
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.message || data.error || (action === 'accept' ? 'Không thể chấp nhận' : 'Không thể từ chối'));
}
await onMemberAdded();
}
catch (err) {
alert(err.message || 'Thao tác thất bại');
}
finally {
setActionLoading(null);
}
};
const handleAdd = async () => {
if (!selectedUser)
return;
setSubmitting(true);
setSubmitError('');
try {
const API_BASE = `http://${window.location.hostname}:3001`;
const endpoint = canCreateDirectly ? `/api/v1/tours/${tourId}/members` : `/api/v1/tours/${tourId}/join-requests`;
const body = canCreateDirectly
? { userId: selectedUser, role }
: { userId: selectedUser };
const res = await fetch(`${API_BASE}${endpoint}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`,
},
body: JSON.stringify(body),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.message || data.error || 'Thao tác thất bại');
}
await onMemberAdded?.();
onClose();
}
catch (err) {
setSubmitError(err.message || 'Thao tác thất bại');
}
finally {
setSubmitting(false);
}
};
if (!isOpen)
return null;
return (_jsxs("div", { className: "fixed inset-0 z-[2000] flex items-center justify-center p-4", children: [_jsx("div", { className: "absolute inset-0 bg-gray-900/60 backdrop-blur-sm", onClick: onClose }), _jsxs("div", { className: "relative w-full max-w-md bg-white rounded-3xl shadow-2xl overflow-hidden flex flex-col max-h-[90vh]", children: [_jsxs("div", { className: "p-5 border-b border-gray-100 flex justify-between items-center bg-gray-50/50", children: [_jsxs("div", { children: [_jsxs("h2", { className: "text-lg font-bold text-gray-900 flex items-center gap-2", children: [_jsx(UserPlus, { className: "w-5 h-5 text-blue-600" }), " ", canCreateDirectly ? 'Thêm thành viên' : 'Mời tham gia tour'] }), _jsx("p", { className: "text-xs text-gray-500", children: canCreateDirectly ? 'Chọn người dùng và phân quyền cho tour này.' : 'Mời sẽ được gửi và chờ người quản lý phê duyệt.' })] }), _jsx("button", { onClick: onClose, className: "p-2 hover:bg-gray-200 rounded-full transition-colors", children: _jsx(X, { className: "w-5 h-5 text-gray-400" }) })] }), _jsxs("div", { className: "p-5 space-y-4", children: [_jsxs("div", { children: [_jsxs("p", { className: "text-[11px] font-bold text-gray-500 mb-2", children: ["Th\u00E0nh vi\u00EAn c\u1EE7a tour (", participants.length, ")"] }), _jsxs("div", { className: "flex flex-wrap gap-3", children: [participants.map((p) => {
const rawToken = localStorage.getItem('token');
let currentUserId = null;
try {
const payload = JSON.parse(atob((rawToken || '').split('.')[1]));
currentUserId = payload.sub;
}
catch {
currentUserId = null;
}
const isCurrentUser = currentUserId && p.userId === currentUserId;
const isOwner = p.role === 'OWNER';
const canRemove = onRemoveMember && !isCurrentUser && !isOwner;
return (_jsxs("div", { className: "flex flex-col items-center gap-1", children: [_jsxs("div", { className: "relative", children: [_jsx("div", { className: "w-10 h-10 rounded-full bg-blue-100 border border-blue-200 flex items-center justify-center text-blue-700 font-bold text-sm overflow-hidden", children: p.user?.name?.charAt(0) || '?' }), canRemove && (_jsx("button", { onClick: () => handleRemove(p.userId, p.user?.name || p.userId), className: "absolute -top-1 -right-1 w-4 h-4 bg-gray-500 hover:bg-red-600 rounded-full flex items-center justify-center text-white", "aria-label": "Remove item", children: _jsx(Trash2, { size: 10 }) }))] }), _jsx("span", { className: "text-[10px] font-semibold text-gray-700 max-w-[72px] truncate", children: p.user?.name || p.userId })] }, p.userId));
}), participants.length === 0 && (_jsx("span", { className: "text-xs text-gray-400", children: "Ch\u01B0a c\u00F3 th\u00E0nh vi\u00EAn n\u00E0o" }))] })] }), joinRequests.length > 0 && (_jsxs("div", { children: [_jsxs("p", { className: "text-[11px] font-bold text-gray-500 mb-2 flex items-center gap-1", children: [_jsx(Clock, { className: "w-3 h-3 text-amber-500" }), " \u0110ang ch\u1EDD ph\u00EA duy\u1EC7t (", joinRequests.length, ")"] }), _jsx("div", { className: "flex flex-wrap gap-3", children: joinRequests.map((req) => (_jsxs("div", { className: "flex flex-col items-center gap-1 relative", children: [_jsx("div", { className: "w-10 h-10 rounded-full bg-amber-50 border border-amber-200 flex items-center justify-center text-amber-600 font-bold text-sm overflow-hidden", children: req.user?.name?.charAt(0) || '?' }), _jsxs("div", { className: "absolute -top-1 -right-1 flex", children: [_jsx("button", { type: "button", disabled: actionLoading === req.id, onClick: () => handleRequestAction(req.id, 'accept', req.user?.name || req.userId), className: "w-4 h-4 bg-green-500 hover:bg-green-600 rounded-full flex items-center justify-center text-white text-[10px] leading-none disabled:opacity-50", "aria-label": "Accept", children: "+" }), _jsx("button", { type: "button", disabled: actionLoading === req.id, onClick: () => handleRequestAction(req.id, 'reject', req.user?.name || req.userId), className: "w-4 h-4 bg-red-500 hover:bg-red-600 rounded-full flex items-center justify-center text-white text-[10px] leading-none disabled:opacity-50 -ml-1", "aria-label": "Reject", children: "x" })] }), _jsx("span", { className: "text-[10px] font-semibold text-gray-700 max-w-[72px] truncate", children: req.user?.name || req.userId }), _jsx("span", { className: "text-[9px] font-bold text-amber-600 bg-amber-100 px-1.5 py-0.5 rounded-full border border-amber-200", children: "PENDING" })] }, req.id))) })] })), canCreateDirectly && (_jsxs("div", { className: "space-y-1.5", children: [_jsx("label", { className: "text-xs font-bold text-gray-700 ml-1", children: "Ph\u00E2n quy\u1EC1n" }), _jsxs("select", { className: "w-full px-3 py-2 bg-white border border-gray-200 rounded-xl outline-none text-sm", value: role, onChange: (e) => setRole(e.target.value), children: [_jsx("option", { value: "OWNER", children: "OWNER" }), _jsx("option", { value: "MANAGER", children: "MANAGER" }), _jsx("option", { value: "MEMBER", children: "MEMBER" }), _jsx("option", { value: "MEMBER_NO_FINANCE", children: "MEMBER_NO_FINANCE" }), _jsx("option", { value: "VIEWER_ONLY", children: "VIEWER_ONLY" })] })] })), _jsxs("div", { className: "space-y-2", children: [fetchError && (_jsx("div", { className: "p-3 bg-red-50 text-red-600 rounded-xl text-xs font-bold border border-red-100", children: fetchError })), submitError && (_jsx("div", { className: "p-3 bg-red-50 text-red-600 rounded-xl text-xs font-bold border border-red-100", children: submitError })), loading ? (_jsx("div", { className: "flex justify-center py-10", children: _jsx(Loader2, { className: "w-8 h-8 animate-spin text-blue-600" }) })) : (_jsxs("div", { className: "space-y-2 max-h-[40vh] overflow-y-auto pr-1", children: [visibleUsers.map((u) => {
const isSelected = selectedUser === u.id;
return (_jsxs("button", { onClick: () => setSelectedUser(u.id), disabled: requestUserIds.has(u.id), className: `w-full flex items-center gap-3 p-3 rounded-2xl border transition-all ${isSelected ? 'border-blue-500 bg-blue-50/60' : 'border-gray-100 hover:border-blue-200'} ${requestUserIds.has(u.id) ? 'opacity-60' : ''}`, children: [_jsx("div", { className: "w-9 h-9 rounded-full bg-blue-100 flex items-center justify-center text-blue-600 font-bold", children: u.name?.charAt(0) || '?' }), _jsxs("div", { className: "flex-1 text-left", children: [_jsx("div", { className: "text-sm font-bold text-gray-900", children: u.name || 'Chưa đặt tên' }), _jsx("div", { className: "text-[11px] text-gray-500", children: u.email }), _jsxs("div", { className: "text-[11px] text-gray-400", children: [u.phone || '', " ", u.address ? `${u.address}` : ''] })] }), _jsxs("div", { className: "flex items-center gap-1 text-[10px] font-bold text-gray-500", children: [u.isAdmin ? (_jsx("span", { className: "px-2 py-1 bg-purple-50 text-purple-600 rounded-md border border-purple-100", children: "ADMIN" })) : (_jsx("span", { className: "px-2 py-1 bg-gray-50 text-gray-500 rounded-md border border-gray-100", children: "USER" })), isSelected && _jsx(Shield, { className: "w-3 h-3 text-blue-600" })] })] }, u.id));
}), !loading && visibleUsers.length === 0 && (_jsx("div", { className: "text-center py-8 text-sm text-gray-500", children: "Kh\u00F4ng t\u00ECm th\u1EA5y ng\u01B0\u1EDDi d\u00F9ng ph\u00F9 h\u1EE3p" }))] }))] })] }), _jsxs("div", { className: "p-5 border-t border-gray-100 flex justify-end gap-2", children: [_jsx("button", { onClick: onClose, className: "px-4 py-2.5 rounded-xl text-sm font-bold text-gray-600 hover:bg-gray-100 transition-colors", children: "H\u1EE7y" }), _jsx("button", { disabled: !selectedUser || submitting, onClick: handleAdd, className: "px-4 py-2.5 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white rounded-xl text-sm font-bold transition-all", children: submitting ? 'Đang xử lý...' : canCreateDirectly ? 'Thêm vào tour' : 'Gửi lời mời' })] })] }), isConfirmOpen && (_jsxs("div", { className: "fixed inset-0 z-[2100] flex items-center justify-center p-4", children: [_jsx("div", { className: "absolute inset-0 bg-gray-900/70 backdrop-blur-sm", onClick: () => setIsConfirmOpen(false) }), _jsxs("div", { className: "relative w-full max-w-sm bg-white rounded-2xl shadow-2xl p-5", children: [_jsx("h3", { className: "text-base font-bold text-gray-900", children: "X\u00E1c nh\u1EADn x\u00F3a th\u00E0nh vi\u00EAn" }), _jsxs("p", { className: "mt-2 text-sm text-gray-600", children: ["B\u1EA1n c\u00F3 ch\u1EAFc mu\u1ED1n x\u00F3a ", _jsx("span", { className: "font-semibold text-gray-800", children: confirmTarget?.name }), " kh\u1ECFi tour n\u00E0y?"] }), _jsxs("div", { className: "mt-4 flex justify-end gap-2", children: [_jsx("button", { onClick: () => setIsConfirmOpen(false), className: "px-3 py-2 rounded-xl text-sm font-bold text-gray-600 hover:bg-gray-100 transition-colors", children: "H\u1EE7y" }), _jsx("button", { onClick: confirmRemove, className: "px-3 py-2 bg-red-600 hover:bg-red-700 text-white rounded-xl text-sm font-bold transition-colors", children: "X\u00F3a" })] })] })] }))] }));
};
//# sourceMappingURL=AddMemberModal.js.map
+1
View File
File diff suppressed because one or more lines are too long
Vendored
+6 -1
View File
@@ -4,11 +4,13 @@ import { LandingPage } from './LandingPage.js';
import { TourDetailPage } from './TourDetailPage.js';
import { ExploreMap } from './ExploreMap.js';
import { SignupPage } from './SignupPage.js';
import { useTourStore } from './useTourStore.js';
const App = () => {
const [view, setView] = useState('landing');
const [isInitialSetup, setIsInitialSetup] = useState(false);
const [user, setUser] = useState(null);
const [isUserLoaded, setIsUserLoaded] = useState(false);
const fetchTour = useTourStore(state => state.fetchTour);
useEffect(() => {
const API_BASE = `http://${window.location.hostname}:3001`;
const savedUser = localStorage.getItem('user');
@@ -36,7 +38,10 @@ const App = () => {
setUser(null);
setView('landing');
};
return (_jsxs("div", { className: "app-container", children: [view === 'landing' && (_jsx(LandingPage, { isInitialSetup: isInitialSetup, onContinue: () => setView('explore'), onGoToSignup: () => setView('signup'), onGoToMap: () => setView('explore'), onLoginSuccess: handleLoginSuccess })), view === 'signup' && (_jsx(SignupPage, { onBack: () => setView('landing'), onSuccess: () => setView('landing') })), view === 'explore' && (_jsx(ExploreMap, { onBack: () => setView('landing'), onLogout: user ? handleLogout : undefined, user: user })), view === 'detail' && (_jsx(TourDetailPage, {}))] }));
return (_jsxs("div", { className: "app-container", children: [view === 'landing' && (_jsx(LandingPage, { isInitialSetup: isInitialSetup, onContinue: () => setView('explore'), onGoToSignup: () => setView('signup'), onGoToMap: () => setView('explore'), onLoginSuccess: handleLoginSuccess })), view === 'signup' && (_jsx(SignupPage, { onBack: () => setView('landing'), onSuccess: () => setView('landing') })), view === 'explore' && (_jsx(ExploreMap, { onBack: () => setView('landing'), onLogout: user ? handleLogout : undefined, user: user, onViewTour: (id) => {
fetchTour(id);
setView('detail');
} })), view === 'detail' && (_jsx(TourDetailPage, { onBack: () => setView('explore') }))] }));
};
export default App;
//# sourceMappingURL=App.js.map
+1 -1
View File
@@ -1 +1 @@
{"version":3,"file":"App.js","sourceRoot":"","sources":["../App.tsx"],"names":[],"mappings":";AAAA,OAAc,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AACnD,OAAO,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAC/C,OAAO,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AACrD,OAAO,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAC7C,OAAO,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAE7C,MAAM,GAAG,GAAG,GAAG,EAAE;IAEf,MAAM,CAAC,IAAI,EAAE,OAAO,CAAC,GAAG,QAAQ,CAAO,SAAS,CAAC,CAAC;IAClD,MAAM,CAAC,cAAc,EAAE,iBAAiB,CAAC,GAAG,QAAQ,CAAC,KAAK,CAAC,CAAC;IAC5D,MAAM,CAAC,IAAI,EAAE,OAAO,CAAC,GAAG,QAAQ,CAAM,IAAI,CAAC,CAAC;IAC5C,MAAM,CAAC,YAAY,EAAE,eAAe,CAAC,GAAG,QAAQ,CAAC,KAAK,CAAC,CAAC;IAExD,SAAS,CAAC,GAAG,EAAE;QAEb,MAAM,QAAQ,GAAG,UAAU,MAAM,CAAC,QAAQ,CAAC,QAAQ,OAAO,CAAC;QAG3D,MAAM,SAAS,GAAG,YAAY,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC;QAC/C,IAAI,SAAS,EAAE,CAAC;YACd,MAAM,UAAU,GAAG,IAAI,CAAC,KAAK,CAAC,SAAS,CAAC,CAAC;YACzC,OAAO,CAAC,UAAU,CAAC,CAAC;QACtB,CAAC;QACD,eAAe,CAAC,IAAI,CAAC,CAAC;QAGtB,KAAK,CAAC,GAAG,QAAQ,qBAAqB,CAAC;aACpC,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC,CAAC,GAAG,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC,MAAM,EAAE,CAAC;aACnD,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC,iBAAiB,CAAC,CAAC,CAAC,IAAI,CAAC,cAAc,CAAC,CAAC;aACtD,KAAK,CAAC,GAAG,EAAE,CAAC,iBAAiB,CAAC,KAAK,CAAC,CAAC,CAAC;IAC3C,CAAC,EAAE,EAAE,CAAC,CAAC;IAGP,SAAS,CAAC,GAAG,EAAE;QACb,IAAI,YAAY,IAAI,IAAI,IAAI,IAAI,KAAK,SAAS,EAAE,CAAC;YAC/C,OAAO,CAAC,SAAS,CAAC,CAAC;QACrB,CAAC;IACH,CAAC,EAAE,CAAC,YAAY,EAAE,IAAI,EAAE,IAAI,CAAC,CAAC,CAAC;IAE/B,MAAM,kBAAkB,GAAG,CAAC,QAAa,EAAE,EAAE;QAC3C,OAAO,CAAC,QAAQ,CAAC,CAAC;IACpB,CAAC,CAAC;IAEF,MAAM,YAAY,GAAG,GAAG,EAAE;QACxB,YAAY,CAAC,UAAU,CAAC,MAAM,CAAC,CAAC;QAChC,YAAY,CAAC,UAAU,CAAC,OAAO,CAAC,CAAC;QACjC,OAAO,CAAC,IAAI,CAAC,CAAC;QACd,OAAO,CAAC,SAAS,CAAC,CAAC;IACrB,CAAC,CAAC;IAEF,OAAO,CACL,eAAK,SAAS,EAAC,eAAe,aAC3B,IAAI,KAAK,SAAS,IAAI,CACrB,KAAC,WAAW,IACV,cAAc,EAAE,cAAc,EAC9B,UAAU,EAAE,GAAG,EAAE,CAAC,OAAO,CAAC,SAAS,CAAC,EACpC,YAAY,EAAE,GAAG,EAAE,CAAC,OAAO,CAAC,QAAQ,CAAC,EACrC,SAAS,EAAE,GAAG,EAAE,CAAC,OAAO,CAAC,SAAS,CAAC,EACnC,cAAc,EAAE,kBAAkB,GAClC,CACH,EAEA,IAAI,KAAK,QAAQ,IAAI,CACpB,KAAC,UAAU,IACT,MAAM,EAAE,GAAG,EAAE,CAAC,OAAO,CAAC,SAAS,CAAC,EAChC,SAAS,EAAE,GAAG,EAAE,CAAC,OAAO,CAAC,SAAS,CAAC,GACnC,CACH,EAEA,IAAI,KAAK,SAAS,IAAI,CACrB,KAAC,UAAU,IACT,MAAM,EAAE,GAAG,EAAE,CAAC,OAAO,CAAC,SAAS,CAAC,EAChC,QAAQ,EAAE,IAAI,CAAC,CAAC,CAAC,YAAY,CAAC,CAAC,CAAC,SAAS,EACzC,IAAI,EAAE,IAAI,GACV,CACH,EAEA,IAAI,KAAK,QAAQ,IAAI,CACpB,KAAC,cAAc,KAAG,CACnB,IACG,CACP,CAAC;AACJ,CAAC,CAAC;AAEF,eAAe,GAAG,CAAC"}
{"version":3,"file":"App.js","sourceRoot":"","sources":["../App.tsx"],"names":[],"mappings":";AAAA,OAAc,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AACnD,OAAO,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAC/C,OAAO,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AACrD,OAAO,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAC7C,OAAO,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAC7C,OAAO,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC;AAEjD,MAAM,GAAG,GAAG,GAAG,EAAE;IAEf,MAAM,CAAC,IAAI,EAAE,OAAO,CAAC,GAAG,QAAQ,CAAO,SAAS,CAAC,CAAC;IAClD,MAAM,CAAC,cAAc,EAAE,iBAAiB,CAAC,GAAG,QAAQ,CAAC,KAAK,CAAC,CAAC;IAC5D,MAAM,CAAC,IAAI,EAAE,OAAO,CAAC,GAAG,QAAQ,CAAM,IAAI,CAAC,CAAC;IAC5C,MAAM,CAAC,YAAY,EAAE,eAAe,CAAC,GAAG,QAAQ,CAAC,KAAK,CAAC,CAAC;IACxD,MAAM,SAAS,GAAG,YAAY,CAAC,KAAK,CAAC,EAAE,CAAC,KAAK,CAAC,SAAS,CAAC,CAAC;IAEzD,SAAS,CAAC,GAAG,EAAE;QAEb,MAAM,QAAQ,GAAG,UAAU,MAAM,CAAC,QAAQ,CAAC,QAAQ,OAAO,CAAC;QAG3D,MAAM,SAAS,GAAG,YAAY,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC;QAC/C,IAAI,SAAS,EAAE,CAAC;YACd,MAAM,UAAU,GAAG,IAAI,CAAC,KAAK,CAAC,SAAS,CAAC,CAAC;YACzC,OAAO,CAAC,UAAU,CAAC,CAAC;QACtB,CAAC;QACD,eAAe,CAAC,IAAI,CAAC,CAAC;QAGtB,KAAK,CAAC,GAAG,QAAQ,qBAAqB,CAAC;aACpC,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC,CAAC,GAAG,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC,MAAM,EAAE,CAAC;aACnD,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC,iBAAiB,CAAC,CAAC,CAAC,IAAI,CAAC,cAAc,CAAC,CAAC;aACtD,KAAK,CAAC,GAAG,EAAE,CAAC,iBAAiB,CAAC,KAAK,CAAC,CAAC,CAAC;IAC3C,CAAC,EAAE,EAAE,CAAC,CAAC;IAGP,SAAS,CAAC,GAAG,EAAE;QACb,IAAI,YAAY,IAAI,IAAI,IAAI,IAAI,KAAK,SAAS,EAAE,CAAC;YAC/C,OAAO,CAAC,SAAS,CAAC,CAAC;QACrB,CAAC;IACH,CAAC,EAAE,CAAC,YAAY,EAAE,IAAI,EAAE,IAAI,CAAC,CAAC,CAAC;IAE/B,MAAM,kBAAkB,GAAG,CAAC,QAAa,EAAE,EAAE;QAC3C,OAAO,CAAC,QAAQ,CAAC,CAAC;IACpB,CAAC,CAAC;IAEF,MAAM,YAAY,GAAG,GAAG,EAAE;QACxB,YAAY,CAAC,UAAU,CAAC,MAAM,CAAC,CAAC;QAChC,YAAY,CAAC,UAAU,CAAC,OAAO,CAAC,CAAC;QACjC,OAAO,CAAC,IAAI,CAAC,CAAC;QACd,OAAO,CAAC,SAAS,CAAC,CAAC;IACrB,CAAC,CAAC;IAEF,OAAO,CACL,eAAK,SAAS,EAAC,eAAe,aAC3B,IAAI,KAAK,SAAS,IAAI,CACrB,KAAC,WAAW,IACV,cAAc,EAAE,cAAc,EAC9B,UAAU,EAAE,GAAG,EAAE,CAAC,OAAO,CAAC,SAAS,CAAC,EACpC,YAAY,EAAE,GAAG,EAAE,CAAC,OAAO,CAAC,QAAQ,CAAC,EACrC,SAAS,EAAE,GAAG,EAAE,CAAC,OAAO,CAAC,SAAS,CAAC,EACnC,cAAc,EAAE,kBAAkB,GAClC,CACH,EAEA,IAAI,KAAK,QAAQ,IAAI,CACpB,KAAC,UAAU,IACT,MAAM,EAAE,GAAG,EAAE,CAAC,OAAO,CAAC,SAAS,CAAC,EAChC,SAAS,EAAE,GAAG,EAAE,CAAC,OAAO,CAAC,SAAS,CAAC,GACnC,CACH,EAEA,IAAI,KAAK,SAAS,IAAI,CACrB,KAAC,UAAU,IACT,MAAM,EAAE,GAAG,EAAE,CAAC,OAAO,CAAC,SAAS,CAAC,EAChC,QAAQ,EAAE,IAAI,CAAC,CAAC,CAAC,YAAY,CAAC,CAAC,CAAC,SAAS,EACzC,IAAI,EAAE,IAAI,EACV,UAAU,EAAE,CAAC,EAAE,EAAE,EAAE;oBACjB,SAAS,CAAC,EAAE,CAAC,CAAC;oBACd,OAAO,CAAC,QAAQ,CAAC,CAAC;gBACpB,CAAC,GACD,CACH,EAEA,IAAI,KAAK,QAAQ,IAAI,CACpB,KAAC,cAAc,IAAC,MAAM,EAAE,GAAG,EAAE,CAAC,OAAO,CAAC,SAAS,CAAC,GAAI,CACrD,IACG,CACP,CAAC;AACJ,CAAC,CAAC;AAEF,eAAe,GAAG,CAAC"}

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