Compare commits
167 Commits
ea5799ffb5
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 03ee337e28 | |||
| 48ffd7e49b | |||
| 8df94bca26 | |||
| d2aced396f | |||
| 4e93722ba5 | |||
| 33a5996bee | |||
| 90475d4130 | |||
| 8e984e609b | |||
| b48502c34a | |||
| 978992057a | |||
| ed0fb8dd64 | |||
| 828bc89890 | |||
| b8bf4f88cc | |||
| f74587376e | |||
| a135196cb5 | |||
| cfdcb573de | |||
| f17215f236 | |||
| 5e60614588 | |||
| e385049652 | |||
| ef295e0994 | |||
| c3382a422d | |||
| 5d47e6d291 | |||
| 957ba6c72c | |||
| 4b551ccc31 | |||
| 9dc1faee6f | |||
| b998833371 | |||
| 13cd68707e | |||
| f355b9471a | |||
| a64af5f9cf | |||
| 7182391241 | |||
| 28ea9abccd | |||
| 9f41400bd8 | |||
| 78c5754655 | |||
| 5145835c8a | |||
| 6b15e7ff02 | |||
| a65be48d36 | |||
| ac91f6e4c8 | |||
| 09b1ee882d | |||
| acf867a375 | |||
| dd2635a44d | |||
| 29c19c6372 | |||
| 01d6d7439f | |||
| 860395cb14 | |||
| b1a539235b | |||
| 403c169ddd | |||
| 8d79cd76f6 | |||
| 392a4d4766 | |||
| 55fba75fda | |||
| 36157bd53b | |||
| 52706cab7d | |||
| ae97f061e8 | |||
| e34d197dd0 | |||
| fdf41e05fc | |||
| ae74035ad5 | |||
| ac11bb56db | |||
| 5081546cdf | |||
| 9da8a9494d | |||
| 4373ed684a | |||
| def0e0d0f9 | |||
| 36e8658dad | |||
| 6c1b77d7d2 | |||
| 4640526e4b | |||
| c7692ab9b6 | |||
| 36fbbb9386 | |||
| ef11309399 | |||
| 6f2dd0fc01 | |||
| 623f0164c8 | |||
| 88b47099b1 | |||
| 659d2a0840 | |||
| 68950dba10 | |||
| 3e215ae8e8 | |||
| e7b8c672e0 | |||
| 2462850e2e | |||
| abb61f65cd | |||
| da32d59161 | |||
| 40b1c4f2ab | |||
| 55de887f87 | |||
| ffe3cb6ecd | |||
| 69c67a1636 | |||
| f181009fa5 | |||
| b6551da147 | |||
| 2afb2971da | |||
| 14ffbb8657 | |||
| fbe5ef873b | |||
| d6ee1dde74 | |||
| 288b40ad12 | |||
| 3a3296c340 | |||
| e664a3797e | |||
| 36418673db | |||
| 98e4a4a340 | |||
| bfbbb66747 | |||
| 1933b58f84 | |||
| 05dc80bb6d | |||
| 4d63bff214 | |||
| c5474f1ff6 | |||
| 7ad785fed9 | |||
| e66f242c2c | |||
| 6026c2227a | |||
| 1eed9b00de | |||
| 1709638bfd | |||
| 37b5d14d7e | |||
| 2fabdb79df | |||
| 00554224a1 | |||
| 88b2182789 | |||
| 989d60643b | |||
| 1772ced959 | |||
| c005009da2 | |||
| bfd18e05dd | |||
| 29d39ae7b0 | |||
| 047170d4be | |||
| fc96ee9eb8 | |||
| 0a584a13c7 | |||
| f78c72ad60 | |||
| 8cca4a83ca | |||
| bc49e081c6 | |||
| 9e26aabce6 | |||
| 3e1a5db1a6 | |||
| baa83aad8a | |||
| 302a82e887 | |||
| 63da413b3c | |||
| 02c493f7e0 | |||
| b939fc959d | |||
| 5464d90948 | |||
| c5530f36df | |||
| dcaf71032c | |||
| aec9112064 | |||
| 8dac79fc4b | |||
| 75cf96898c | |||
| f52580717f | |||
| 879f20985c | |||
| 4edfd0eb59 | |||
| 55f0a8e775 | |||
| 7812d1395b | |||
| c9ef98b2ff | |||
| 71f4aa9ecd | |||
| 34e2af8825 | |||
| 4716b841dc | |||
| 967f6b4f6a | |||
| c6341aa12f | |||
| 7cc724a133 | |||
| 8ff09eeeaf | |||
| 914a9cf243 | |||
| d084e806a1 | |||
| bb15b2bf15 | |||
| 4bec095a40 | |||
| 81e715ddb3 | |||
| c6d0fdd5d1 | |||
| 3a29f98c96 | |||
| 71007bd9a1 | |||
| 65a9b39966 | |||
| 58087a4b37 | |||
| 838aec562f | |||
| ec650fb45d | |||
| bf63510980 | |||
| b3519c94cd | |||
| 5cfb5b9d15 | |||
| 4545bde8ec | |||
| 14bf43487e | |||
| ed437cdb0f | |||
| c7db2c3ed8 | |||
| 5e3f10631f | |||
| 578b03d808 | |||
| 25fcd5d926 | |||
| c51ddc34c7 | |||
| b54707823c | |||
| 7fc80a49d0 | |||
| 7b42058d77 |
@@ -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/
|
||||
@@ -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/
|
||||
@@ -1,2 +1,4 @@
|
||||
.env
|
||||
node_modules
|
||||
server/dist
|
||||
dist
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"$schema": "https://app.kilo.ai/config.json"
|
||||
}
|
||||
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"continue.enableConsole": true,
|
||||
"remote.autoForwardPortsFallback": 0
|
||||
}
|
||||
-120
@@ -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` và `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`).
|
||||
@@ -1,65 +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);
|
||||
|
||||
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) {
|
||||
setUser(JSON.parse(savedUser));
|
||||
}
|
||||
|
||||
// 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));
|
||||
}, [view]);
|
||||
|
||||
const handleLoginSuccess = (userData: any) => {
|
||||
setUser(userData);
|
||||
setView('explore'); // Chuyển sang bản đồ khám phá sau khi đăng nhập
|
||||
};
|
||||
|
||||
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')} />
|
||||
)}
|
||||
|
||||
{view === 'detail' && (
|
||||
<TourDetailPage />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
-105
@@ -1,105 +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 } from 'lucide-react';
|
||||
|
||||
// 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 }: { onBack: () => void }) => {
|
||||
const { publicTours, fetchPublicTours } = useTourStore();
|
||||
const [userPos, setUserPos] = useState<[number, number]>([10.7769, 106.7009]); // Mặc định TP.HCM
|
||||
|
||||
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>
|
||||
|
||||
{/* 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='© 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>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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
@@ -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 lý và 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 ký
|
||||
</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
@@ -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 có 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>
|
||||
);
|
||||
};
|
||||
@@ -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
@@ -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 bè.</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ọ và 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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.
|
||||
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
dist
|
||||
npm-debug.log
|
||||
.env
|
||||
.git
|
||||
.gitignore
|
||||
@@ -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"]
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>;
|
||||
}
|
||||
Vendored
+40
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"}
|
||||
@@ -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>>;
|
||||
}
|
||||
@@ -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"}
|
||||
@@ -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
@@ -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
|
||||
@@ -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"}
|
||||
Vendored
+35
@@ -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>;
|
||||
}
|
||||
Vendored
+4963
File diff suppressed because it is too large
Load Diff
Vendored
+1
File diff suppressed because one or more lines are too long
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
|
||||
> backend@0.0.1 start:dev
|
||||
> nest start --watch
|
||||
|
||||
[2J[3J[H[[90m10:46:05 PM[0m] Starting compilation in watch mode...
|
||||
|
||||
[[90m10:46:08 PM[0m] Found 0 errors. Watching for file changes.
|
||||
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[NestFactory] [39m[32mStarting Nest application...[39m
|
||||
--- [PRISMA CHECK] ---
|
||||
DATABASE_URL nhận được: ĐÃ ĐỌC THÀNH CÔNG ✔️
|
||||
----------------------
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[InstanceLoader] [39m[32mConfigHostModule dependencies initialized[39m[38;5;3m +40ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[InstanceLoader] [39m[32mJwtModule dependencies initialized[39m[38;5;3m +0ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[InstanceLoader] [39m[32mConfigModule dependencies initialized[39m[38;5;3m +0ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[InstanceLoader] [39m[32mCacheModule dependencies initialized[39m[38;5;3m +15ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[InstanceLoader] [39m[32mAppModule dependencies initialized[39m[38;5;3m +2ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[WebSocketsController] [39m[32mCommentGateway subscribed to the "joinTour" message[39m[38;5;3m +11ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[WebSocketsController] [39m[32mCommentGateway subscribed to the "joinPhoto" message[39m[38;5;3m +0ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RoutesResolver] [39m[32mAppController {/api/v1}:[39m[38;5;3m +1ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1, GET} route[39m[38;5;3m +2ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RoutesResolver] [39m[32mAuthController {/api/v1/auth}:[39m[38;5;3m +0ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/auth/convert-guest, POST} route[39m[38;5;3m +1ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/auth/create-guest, POST} route[39m[38;5;3m +1ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/auth/status, GET} route[39m[38;5;3m +0ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/auth/login, POST} route[39m[38;5;3m +0ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/auth/signup/request, POST} route[39m[38;5;3m +1ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/auth/signup/verify, POST} route[39m[38;5;3m +0ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RoutesResolver] [39m[32mPublicTourController {/api/v1/tours}:[39m[38;5;3m +0ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/tours/:id/public, GET} route[39m[38;5;3m +1ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RoutesResolver] [39m[32mTourController {/api/v1/tours}:[39m[38;5;3m +0ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/tours, POST} route[39m[38;5;3m +1ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/tours/:tourId/locations, POST} route[39m[38;5;3m +1ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/tours/:tourId/start-point, POST} route[39m[38;5;3m +0ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/tours/:tourId/end-point, POST} route[39m[38;5;3m +1ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/tours/:tourId/legs/batch, POST} route[39m[38;5;3m +0ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/tours/:tourId/legs, POST} route[39m[38;5;3m +0ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/tours/:id, PATCH} route[39m[38;5;3m +1ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/tours/:id, DELETE} route[39m[38;5;3m +0ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/tours/explore, GET} route[39m[38;5;3m +0ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/tours/:id, GET} route[39m[38;5;3m +0ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/tours/:tourId/members, POST} route[39m[38;5;3m +0ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/tours/:tourId/join-requests, GET} route[39m[38;5;3m +1ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/tours/:tourId/join-requests, POST} route[39m[38;5;3m +0ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/tours/:tourId/join-requests/:requestId/accept, POST} route[39m[38;5;3m +0ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/tours/:tourId/join-requests/:requestId/reject, POST} route[39m[38;5;3m +1ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/tours/:tourId/members/:userId, DELETE} route[39m[38;5;3m +0ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/tours/:tourId/photos, POST} route[39m[38;5;3m +0ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RoutesResolver] [39m[32mUserController {/api/v1/users}:[39m[38;5;3m +0ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/users, GET} route[39m[38;5;3m +0ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/users/me/photos, GET} route[39m[38;5;3m +1ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/users/:id, PATCH} route[39m[38;5;3m +0ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/users/:id, DELETE} route[39m[38;5;3m +0ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/users/block/:id, POST} route[39m[38;5;3m +0ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RoutesResolver] [39m[32mRoutingController {/api/v1/routing}:[39m[38;5;3m +0ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/routing/optimize/:legId, POST} route[39m[38;5;3m +0ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RoutesResolver] [39m[32mLegController {/api/v1/legs}:[39m[38;5;3m +0ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/legs/:id, PATCH} route[39m[38;5;3m +1ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/legs/:id, DELETE} route[39m[38;5;3m +0ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RoutesResolver] [39m[32mLocationController {/api/v1/locations}:[39m[38;5;3m +0ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/locations/:id, PATCH} route[39m[38;5;3m +0ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/locations/:id, DELETE} route[39m[38;5;3m +1ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RoutesResolver] [39m[32mCommentController {/api/v1/locations}:[39m[38;5;3m +0ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/locations/:locationId/comments, GET} route[39m[38;5;3m +0ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/locations/:locationId/comments, POST} route[39m[38;5;3m +0ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RoutesResolver] [39m[32mPhotoController {/api/v1/photos}:[39m[38;5;3m +0ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/photos/upload-anonymous, POST} route[39m[38;5;3m +1ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/photos/:id, DELETE} route[39m[38;5;3m +0ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RoutesResolver] [39m[32mPublicPhotoController {/api/v1/public-photos}:[39m[38;5;3m +0ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/public-photos, GET} route[39m[38;5;3m +0ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/public-photos/:photoId/comments, GET} route[39m[38;5;3m +0ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/public-photos/:photoId/comments, POST} route[39m[38;5;3m +0ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RoutesResolver] [39m[32mAdminOtpController {/api/v1/admin/otp}:[39m[38;5;3m +1ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/admin/otp/send, POST} route[39m[38;5;3m +0ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/admin/otp/verify, POST} route[39m[38;5;3m +0ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[NestApplication] [39m[32mNest application successfully started[39m[38;5;3m +16ms[39m
|
||||
🚀 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
|
||||
@@ -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(); }
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
@@ -18,10 +18,10 @@ export class AdminGuard implements CanActivate {
|
||||
// Kiểm tra trực tiếp từ database để đảm bảo quyền isAdmin là chính xác nhất cho các tác vụ nhạy cảm
|
||||
const dbUser = await this.prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { isAdmin: true },
|
||||
select: { isAdmin: true, isBlocked: true },
|
||||
});
|
||||
|
||||
if (!dbUser || !dbUser.isAdmin) {
|
||||
if (!dbUser || !dbUser.isAdmin || dbUser.isBlocked) {
|
||||
throw new ForbiddenException('Truy cập bị từ chối. Bạn không có quyền quản trị viên hệ thống.');
|
||||
}
|
||||
|
||||
@@ -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 có thể dùng lại mà 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
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
@@ -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());
|
||||
@@ -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());
|
||||
@@ -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());
|
||||
@@ -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);
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node", // Đảm bảo 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 với CommonJS
|
||||
"baseUrl": "./",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 868 KiB |
BIN
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 |
@@ -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;
|
||||
Vendored
+8
@@ -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;
|
||||
Vendored
+146
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+31
@@ -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 {};
|
||||
Vendored
+154
@@ -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
|
||||
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+22
-4
@@ -4,26 +4,44 @@ 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');
|
||||
if (savedUser) {
|
||||
setUser(JSON.parse(savedUser));
|
||||
const parsedUser = JSON.parse(savedUser);
|
||||
setUser(parsedUser);
|
||||
}
|
||||
setIsUserLoaded(true);
|
||||
fetch(`${API_BASE}/api/v1/auth/status`)
|
||||
.then(res => res.ok ? res.json() : Promise.reject())
|
||||
.then(data => setIsInitialSetup(!!data.isInitialSetup))
|
||||
.catch(() => setIsInitialSetup(false));
|
||||
}, [view]);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (isUserLoaded && user && view === 'landing') {
|
||||
setView('explore');
|
||||
}
|
||||
}, [isUserLoaded, user, view]);
|
||||
const handleLoginSuccess = (userData) => {
|
||||
setUser(userData);
|
||||
setView('explore');
|
||||
};
|
||||
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') })), view === 'detail' && (_jsx(TourDetailPage, {}))] }));
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem('user');
|
||||
localStorage.removeItem('token');
|
||||
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, onViewTour: (id) => {
|
||||
fetchTour(id);
|
||||
setView('detail');
|
||||
} })), view === 'detail' && (_jsx(TourDetailPage, { onBack: () => setView('explore') }))] }));
|
||||
};
|
||||
export default App;
|
||||
//# sourceMappingURL=App.js.map
|
||||
Vendored
+1
-1
@@ -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;IAE5C,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,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC,SAAS,CAAC,CAAC,CAAC;QACjC,CAAC;QAGD,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,CAAC,IAAI,CAAC,CAAC,CAAC;IAEX,MAAM,kBAAkB,GAAG,CAAC,QAAa,EAAE,EAAE;QAC3C,OAAO,CAAC,QAAQ,CAAC,CAAC;QAClB,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,IAAC,MAAM,EAAE,GAAG,EAAE,CAAC,OAAO,CAAC,SAAS,CAAC,GAAI,CACjD,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"}
|
||||
Vendored
+12
@@ -0,0 +1,12 @@
|
||||
import React from 'react';
|
||||
interface ConfirmModalProps {
|
||||
isOpen: boolean;
|
||||
title?: string;
|
||||
message: string;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
export declare const ConfirmModal: React.FC<ConfirmModalProps>;
|
||||
export {};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user