Cuong2004 commited on
Commit
d7a7993
·
1 Parent(s): 920888d

add user endpoint + image handling

Browse files
app/api/router.py CHANGED
@@ -158,6 +158,21 @@ class HistoryResponse(BaseModel):
158
  message_count: int
159
 
160
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
161
  @router.post(
162
  "/nearby",
163
  response_model=NearbyResponse,
@@ -394,6 +409,34 @@ async def get_history_info(user_id: str) -> HistoryResponse:
394
  )
395
 
396
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
397
  class ImageSearchResult(BaseModel):
398
  """Image search result model."""
399
 
 
158
  message_count: int
159
 
160
 
161
+ class MessageItem(BaseModel):
162
+ """Single chat message."""
163
+ role: str
164
+ content: str
165
+ timestamp: str
166
+
167
+
168
+ class MessagesResponse(BaseModel):
169
+ """Chat messages response."""
170
+ user_id: str
171
+ session_id: str
172
+ messages: list[MessageItem]
173
+ count: int
174
+
175
+
176
  @router.post(
177
  "/nearby",
178
  response_model=NearbyResponse,
 
409
  )
410
 
411
 
412
+ @router.get(
413
+ "/chat/messages/{user_id}",
414
+ response_model=MessagesResponse,
415
+ summary="Get chat messages",
416
+ description="Get actual chat messages from a specific session.",
417
+ )
418
+ async def get_chat_messages(
419
+ user_id: str,
420
+ session_id: str = "default",
421
+ ) -> MessagesResponse:
422
+ """Get chat messages for a session."""
423
+ messages = chat_history.get_messages(user_id, session_id)
424
+
425
+ return MessagesResponse(
426
+ user_id=user_id,
427
+ session_id=session_id,
428
+ messages=[
429
+ MessageItem(
430
+ role=m.role,
431
+ content=m.content,
432
+ timestamp=m.timestamp.isoformat(),
433
+ )
434
+ for m in messages
435
+ ],
436
+ count=len(messages),
437
+ )
438
+
439
+
440
  class ImageSearchResult(BaseModel):
441
  """Image search result model."""
442
 
app/itineraries/__init__.py ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Itineraries models."""
2
+
3
+ from datetime import datetime, date
4
+ from pydantic import BaseModel, Field
5
+
6
+
7
+ class StopBase(BaseModel):
8
+ """Base stop fields."""
9
+ place_id: str = Field(..., description="Place ID from places_metadata")
10
+ day_index: int = Field(..., ge=1, description="Day number (1-indexed)")
11
+ order_index: int = Field(..., ge=1, description="Order within the day")
12
+ arrival_time: datetime | None = Field(None, description="Planned arrival time")
13
+ stay_minutes: int | None = Field(None, description="Duration in minutes")
14
+ notes: str | None = Field(None, description="User notes")
15
+ tags: list[str] = Field(default_factory=list, description="Tags")
16
+
17
+
18
+ class StopCreate(StopBase):
19
+ """Create stop request."""
20
+ pass
21
+
22
+
23
+ class StopUpdate(BaseModel):
24
+ """Update stop - all optional."""
25
+ day_index: int | None = None
26
+ order_index: int | None = None
27
+ arrival_time: datetime | None = None
28
+ stay_minutes: int | None = None
29
+ notes: str | None = None
30
+ tags: list[str] | None = None
31
+
32
+
33
+ class Stop(StopBase):
34
+ """Full stop with metadata."""
35
+ id: str
36
+ itinerary_id: str
37
+ # Snapshot from places_metadata
38
+ snapshot: dict | None = None
39
+ created_at: datetime
40
+ updated_at: datetime
41
+
42
+ class Config:
43
+ from_attributes = True
44
+
45
+
46
+ class ItineraryBase(BaseModel):
47
+ """Base itinerary fields."""
48
+ title: str = Field(..., description="Itinerary title")
49
+ start_date: date | None = Field(None, description="Start date")
50
+ end_date: date | None = Field(None, description="End date")
51
+ total_days: int = Field(..., ge=1, description="Total trip days")
52
+ total_budget: float | None = Field(None, description="Budget amount")
53
+ currency: str = Field(default="VND", description="Currency code")
54
+
55
+
56
+ class ItineraryCreate(ItineraryBase):
57
+ """Create itinerary request."""
58
+ pass
59
+
60
+
61
+ class ItineraryUpdate(BaseModel):
62
+ """Update itinerary - all optional."""
63
+ title: str | None = None
64
+ start_date: date | None = None
65
+ end_date: date | None = None
66
+ total_days: int | None = None
67
+ total_budget: float | None = None
68
+ currency: str | None = None
69
+
70
+
71
+ class Itinerary(ItineraryBase):
72
+ """Full itinerary with stops."""
73
+ id: str
74
+ user_id: str
75
+ stops: list[Stop] = Field(default_factory=list)
76
+ meta: dict | None = None
77
+ created_at: datetime
78
+ updated_at: datetime
79
+
80
+ class Config:
81
+ from_attributes = True
82
+
83
+
84
+ class ItineraryListItem(BaseModel):
85
+ """Summary for list view."""
86
+ id: str
87
+ title: str
88
+ start_date: date | None
89
+ end_date: date | None
90
+ total_days: int
91
+ stop_count: int
92
+ created_at: datetime
93
+
94
+
95
+ class ItineraryResponse(BaseModel):
96
+ """API response wrapper."""
97
+ itinerary: Itinerary
98
+ message: str = "Success"
99
+
100
+
101
+ class StopResponse(BaseModel):
102
+ """API response for stop."""
103
+ stop: Stop
104
+ message: str = "Success"
app/itineraries/router.py ADDED
@@ -0,0 +1,458 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Itineraries Router - Multi-day trip planning with persistent storage."""
2
+
3
+ from fastapi import APIRouter, HTTPException, Depends, Query
4
+ from sqlalchemy.ext.asyncio import AsyncSession
5
+ from sqlalchemy import text
6
+
7
+ from app.shared.db.session import get_db
8
+ from app.itineraries import (
9
+ Itinerary, ItineraryCreate, ItineraryUpdate, ItineraryResponse,
10
+ ItineraryListItem, Stop, StopCreate, StopUpdate, StopResponse
11
+ )
12
+
13
+
14
+ router = APIRouter(prefix="/itineraries", tags=["Itineraries"])
15
+
16
+
17
+ # ==================== ITINERARY CRUD ====================
18
+
19
+ @router.post(
20
+ "",
21
+ response_model=ItineraryResponse,
22
+ summary="Create new itinerary",
23
+ description="Creates a new multi-day trip itinerary.",
24
+ )
25
+ async def create_itinerary(
26
+ request: ItineraryCreate,
27
+ user_id: str = Query(..., description="User ID"),
28
+ db: AsyncSession = Depends(get_db),
29
+ ) -> ItineraryResponse:
30
+ """Create a new itinerary."""
31
+ result = await db.execute(
32
+ text("""
33
+ INSERT INTO itineraries (user_id, title, start_date, end_date, total_days, total_budget, currency)
34
+ VALUES (:user_id, :title, :start_date, :end_date, :total_days, :total_budget, :currency)
35
+ RETURNING id, user_id, title, start_date, end_date, total_days, total_budget, currency, meta, created_at, updated_at
36
+ """),
37
+ {
38
+ "user_id": user_id,
39
+ "title": request.title,
40
+ "start_date": request.start_date,
41
+ "end_date": request.end_date,
42
+ "total_days": request.total_days,
43
+ "total_budget": request.total_budget,
44
+ "currency": request.currency,
45
+ }
46
+ )
47
+ await db.commit()
48
+ row = result.fetchone()
49
+
50
+ itinerary = Itinerary(
51
+ id=str(row.id),
52
+ user_id=str(row.user_id),
53
+ title=row.title,
54
+ start_date=row.start_date,
55
+ end_date=row.end_date,
56
+ total_days=row.total_days,
57
+ total_budget=float(row.total_budget) if row.total_budget else None,
58
+ currency=row.currency,
59
+ meta=row.meta,
60
+ stops=[],
61
+ created_at=row.created_at,
62
+ updated_at=row.updated_at,
63
+ )
64
+
65
+ return ItineraryResponse(itinerary=itinerary, message="Itinerary created")
66
+
67
+
68
+ @router.get(
69
+ "",
70
+ response_model=list[ItineraryListItem],
71
+ summary="List user's itineraries",
72
+ description="Returns all itineraries for a user.",
73
+ )
74
+ async def list_itineraries(
75
+ user_id: str = Query(..., description="User ID"),
76
+ db: AsyncSession = Depends(get_db),
77
+ ) -> list[ItineraryListItem]:
78
+ """List all itineraries for a user."""
79
+ result = await db.execute(
80
+ text("""
81
+ SELECT i.id, i.title, i.start_date, i.end_date, i.total_days, i.created_at,
82
+ COUNT(s.id) as stop_count
83
+ FROM itineraries i
84
+ LEFT JOIN itinerary_stops s ON s.itinerary_id = i.id
85
+ WHERE i.user_id = :user_id
86
+ GROUP BY i.id
87
+ ORDER BY i.created_at DESC
88
+ """),
89
+ {"user_id": user_id}
90
+ )
91
+ rows = result.fetchall()
92
+
93
+ return [
94
+ ItineraryListItem(
95
+ id=str(row.id),
96
+ title=row.title,
97
+ start_date=row.start_date,
98
+ end_date=row.end_date,
99
+ total_days=row.total_days,
100
+ stop_count=row.stop_count,
101
+ created_at=row.created_at,
102
+ )
103
+ for row in rows
104
+ ]
105
+
106
+
107
+ @router.get(
108
+ "/{itinerary_id}",
109
+ response_model=ItineraryResponse,
110
+ summary="Get itinerary with stops",
111
+ description="Returns full itinerary details including all stops.",
112
+ )
113
+ async def get_itinerary(
114
+ itinerary_id: str,
115
+ user_id: str = Query(..., description="User ID"),
116
+ db: AsyncSession = Depends(get_db),
117
+ ) -> ItineraryResponse:
118
+ """Get itinerary by ID with all stops."""
119
+ # Get itinerary
120
+ result = await db.execute(
121
+ text("""
122
+ SELECT id, user_id, title, start_date, end_date, total_days, total_budget, currency, meta, created_at, updated_at
123
+ FROM itineraries
124
+ WHERE id = :id AND user_id = :user_id
125
+ """),
126
+ {"id": itinerary_id, "user_id": user_id}
127
+ )
128
+ row = result.fetchone()
129
+
130
+ if not row:
131
+ raise HTTPException(status_code=404, detail="Itinerary not found")
132
+
133
+ # Get stops
134
+ stops_result = await db.execute(
135
+ text("""
136
+ SELECT id, itinerary_id, day_index, order_index, place_id, arrival_time, stay_minutes, notes, tags, snapshot, created_at, updated_at
137
+ FROM itinerary_stops
138
+ WHERE itinerary_id = :itinerary_id
139
+ ORDER BY day_index, order_index
140
+ """),
141
+ {"itinerary_id": itinerary_id}
142
+ )
143
+ stop_rows = stops_result.fetchall()
144
+
145
+ stops = [
146
+ Stop(
147
+ id=str(s.id),
148
+ itinerary_id=str(s.itinerary_id),
149
+ day_index=s.day_index,
150
+ order_index=s.order_index,
151
+ place_id=s.place_id,
152
+ arrival_time=s.arrival_time,
153
+ stay_minutes=s.stay_minutes,
154
+ notes=s.notes,
155
+ tags=s.tags or [],
156
+ snapshot=s.snapshot,
157
+ created_at=s.created_at,
158
+ updated_at=s.updated_at,
159
+ )
160
+ for s in stop_rows
161
+ ]
162
+
163
+ itinerary = Itinerary(
164
+ id=str(row.id),
165
+ user_id=str(row.user_id),
166
+ title=row.title,
167
+ start_date=row.start_date,
168
+ end_date=row.end_date,
169
+ total_days=row.total_days,
170
+ total_budget=float(row.total_budget) if row.total_budget else None,
171
+ currency=row.currency,
172
+ meta=row.meta,
173
+ stops=stops,
174
+ created_at=row.created_at,
175
+ updated_at=row.updated_at,
176
+ )
177
+
178
+ return ItineraryResponse(itinerary=itinerary, message="Itinerary retrieved")
179
+
180
+
181
+ @router.put(
182
+ "/{itinerary_id}",
183
+ response_model=ItineraryResponse,
184
+ summary="Update itinerary",
185
+ description="Updates itinerary details (not stops).",
186
+ )
187
+ async def update_itinerary(
188
+ itinerary_id: str,
189
+ updates: ItineraryUpdate,
190
+ user_id: str = Query(..., description="User ID"),
191
+ db: AsyncSession = Depends(get_db),
192
+ ) -> ItineraryResponse:
193
+ """Update itinerary."""
194
+ update_fields = []
195
+ params = {"id": itinerary_id, "user_id": user_id}
196
+
197
+ if updates.title is not None:
198
+ update_fields.append("title = :title")
199
+ params["title"] = updates.title
200
+ if updates.start_date is not None:
201
+ update_fields.append("start_date = :start_date")
202
+ params["start_date"] = updates.start_date
203
+ if updates.end_date is not None:
204
+ update_fields.append("end_date = :end_date")
205
+ params["end_date"] = updates.end_date
206
+ if updates.total_days is not None:
207
+ update_fields.append("total_days = :total_days")
208
+ params["total_days"] = updates.total_days
209
+ if updates.total_budget is not None:
210
+ update_fields.append("total_budget = :total_budget")
211
+ params["total_budget"] = updates.total_budget
212
+ if updates.currency is not None:
213
+ update_fields.append("currency = :currency")
214
+ params["currency"] = updates.currency
215
+
216
+ if not update_fields:
217
+ raise HTTPException(status_code=400, detail="No fields to update")
218
+
219
+ update_fields.append("updated_at = NOW()")
220
+
221
+ query = f"""
222
+ UPDATE itineraries
223
+ SET {', '.join(update_fields)}
224
+ WHERE id = :id AND user_id = :user_id
225
+ RETURNING id
226
+ """
227
+
228
+ result = await db.execute(text(query), params)
229
+ await db.commit()
230
+
231
+ if not result.fetchone():
232
+ raise HTTPException(status_code=404, detail="Itinerary not found")
233
+
234
+ # Return updated itinerary
235
+ return await get_itinerary(itinerary_id, user_id, db)
236
+
237
+
238
+ @router.delete(
239
+ "/{itinerary_id}",
240
+ summary="Delete itinerary",
241
+ description="Deletes an itinerary and all its stops.",
242
+ )
243
+ async def delete_itinerary(
244
+ itinerary_id: str,
245
+ user_id: str = Query(..., description="User ID"),
246
+ db: AsyncSession = Depends(get_db),
247
+ ) -> dict:
248
+ """Delete itinerary."""
249
+ # Delete stops first (cascade)
250
+ await db.execute(
251
+ text("DELETE FROM itinerary_stops WHERE itinerary_id = :id"),
252
+ {"id": itinerary_id}
253
+ )
254
+
255
+ # Delete itinerary
256
+ result = await db.execute(
257
+ text("DELETE FROM itineraries WHERE id = :id AND user_id = :user_id RETURNING id"),
258
+ {"id": itinerary_id, "user_id": user_id}
259
+ )
260
+ await db.commit()
261
+
262
+ if not result.fetchone():
263
+ raise HTTPException(status_code=404, detail="Itinerary not found")
264
+
265
+ return {"status": "success", "message": "Itinerary deleted"}
266
+
267
+
268
+ # ==================== STOPS CRUD ====================
269
+
270
+ @router.post(
271
+ "/{itinerary_id}/stops",
272
+ response_model=StopResponse,
273
+ summary="Add stop to itinerary",
274
+ description="Adds a new stop to the itinerary.",
275
+ )
276
+ async def add_stop(
277
+ itinerary_id: str,
278
+ request: StopCreate,
279
+ user_id: str = Query(..., description="User ID"),
280
+ db: AsyncSession = Depends(get_db),
281
+ ) -> StopResponse:
282
+ """Add a stop to the itinerary."""
283
+ # Verify itinerary exists and belongs to user
284
+ check = await db.execute(
285
+ text("SELECT id FROM itineraries WHERE id = :id AND user_id = :user_id"),
286
+ {"id": itinerary_id, "user_id": user_id}
287
+ )
288
+ if not check.fetchone():
289
+ raise HTTPException(status_code=404, detail="Itinerary not found")
290
+
291
+ # Get place snapshot
292
+ place_result = await db.execute(
293
+ text("SELECT name, category, address, rating FROM places_metadata WHERE place_id = :place_id"),
294
+ {"place_id": request.place_id}
295
+ )
296
+ place_row = place_result.fetchone()
297
+ snapshot = None
298
+ if place_row:
299
+ snapshot = {
300
+ "name": place_row.name,
301
+ "category": place_row.category,
302
+ "address": place_row.address,
303
+ "rating": float(place_row.rating) if place_row.rating else None,
304
+ }
305
+
306
+ # Insert stop
307
+ result = await db.execute(
308
+ text("""
309
+ INSERT INTO itinerary_stops (itinerary_id, day_index, order_index, place_id, arrival_time, stay_minutes, notes, tags, snapshot)
310
+ VALUES (:itinerary_id, :day_index, :order_index, :place_id, :arrival_time, :stay_minutes, :notes, :tags, :snapshot)
311
+ RETURNING id, itinerary_id, day_index, order_index, place_id, arrival_time, stay_minutes, notes, tags, snapshot, created_at, updated_at
312
+ """),
313
+ {
314
+ "itinerary_id": itinerary_id,
315
+ "day_index": request.day_index,
316
+ "order_index": request.order_index,
317
+ "place_id": request.place_id,
318
+ "arrival_time": request.arrival_time,
319
+ "stay_minutes": request.stay_minutes,
320
+ "notes": request.notes,
321
+ "tags": request.tags,
322
+ "snapshot": snapshot,
323
+ }
324
+ )
325
+ await db.commit()
326
+ row = result.fetchone()
327
+
328
+ stop = Stop(
329
+ id=str(row.id),
330
+ itinerary_id=str(row.itinerary_id),
331
+ day_index=row.day_index,
332
+ order_index=row.order_index,
333
+ place_id=row.place_id,
334
+ arrival_time=row.arrival_time,
335
+ stay_minutes=row.stay_minutes,
336
+ notes=row.notes,
337
+ tags=row.tags or [],
338
+ snapshot=row.snapshot,
339
+ created_at=row.created_at,
340
+ updated_at=row.updated_at,
341
+ )
342
+
343
+ return StopResponse(stop=stop, message="Stop added")
344
+
345
+
346
+ @router.put(
347
+ "/{itinerary_id}/stops/{stop_id}",
348
+ response_model=StopResponse,
349
+ summary="Update stop",
350
+ description="Updates a stop in the itinerary.",
351
+ )
352
+ async def update_stop(
353
+ itinerary_id: str,
354
+ stop_id: str,
355
+ updates: StopUpdate,
356
+ user_id: str = Query(..., description="User ID"),
357
+ db: AsyncSession = Depends(get_db),
358
+ ) -> StopResponse:
359
+ """Update a stop."""
360
+ # Verify ownership
361
+ check = await db.execute(
362
+ text("""
363
+ SELECT s.id FROM itinerary_stops s
364
+ JOIN itineraries i ON i.id = s.itinerary_id
365
+ WHERE s.id = :stop_id AND s.itinerary_id = :itinerary_id AND i.user_id = :user_id
366
+ """),
367
+ {"stop_id": stop_id, "itinerary_id": itinerary_id, "user_id": user_id}
368
+ )
369
+ if not check.fetchone():
370
+ raise HTTPException(status_code=404, detail="Stop not found")
371
+
372
+ update_fields = []
373
+ params = {"stop_id": stop_id}
374
+
375
+ if updates.day_index is not None:
376
+ update_fields.append("day_index = :day_index")
377
+ params["day_index"] = updates.day_index
378
+ if updates.order_index is not None:
379
+ update_fields.append("order_index = :order_index")
380
+ params["order_index"] = updates.order_index
381
+ if updates.arrival_time is not None:
382
+ update_fields.append("arrival_time = :arrival_time")
383
+ params["arrival_time"] = updates.arrival_time
384
+ if updates.stay_minutes is not None:
385
+ update_fields.append("stay_minutes = :stay_minutes")
386
+ params["stay_minutes"] = updates.stay_minutes
387
+ if updates.notes is not None:
388
+ update_fields.append("notes = :notes")
389
+ params["notes"] = updates.notes
390
+ if updates.tags is not None:
391
+ update_fields.append("tags = :tags")
392
+ params["tags"] = updates.tags
393
+
394
+ if not update_fields:
395
+ raise HTTPException(status_code=400, detail="No fields to update")
396
+
397
+ update_fields.append("updated_at = NOW()")
398
+
399
+ query = f"""
400
+ UPDATE itinerary_stops
401
+ SET {', '.join(update_fields)}
402
+ WHERE id = :stop_id
403
+ RETURNING id, itinerary_id, day_index, order_index, place_id, arrival_time, stay_minutes, notes, tags, snapshot, created_at, updated_at
404
+ """
405
+
406
+ result = await db.execute(text(query), params)
407
+ await db.commit()
408
+ row = result.fetchone()
409
+
410
+ stop = Stop(
411
+ id=str(row.id),
412
+ itinerary_id=str(row.itinerary_id),
413
+ day_index=row.day_index,
414
+ order_index=row.order_index,
415
+ place_id=row.place_id,
416
+ arrival_time=row.arrival_time,
417
+ stay_minutes=row.stay_minutes,
418
+ notes=row.notes,
419
+ tags=row.tags or [],
420
+ snapshot=row.snapshot,
421
+ created_at=row.created_at,
422
+ updated_at=row.updated_at,
423
+ )
424
+
425
+ return StopResponse(stop=stop, message="Stop updated")
426
+
427
+
428
+ @router.delete(
429
+ "/{itinerary_id}/stops/{stop_id}",
430
+ summary="Remove stop",
431
+ description="Removes a stop from the itinerary.",
432
+ )
433
+ async def delete_stop(
434
+ itinerary_id: str,
435
+ stop_id: str,
436
+ user_id: str = Query(..., description="User ID"),
437
+ db: AsyncSession = Depends(get_db),
438
+ ) -> dict:
439
+ """Delete a stop."""
440
+ # Verify ownership and delete
441
+ result = await db.execute(
442
+ text("""
443
+ DELETE FROM itinerary_stops s
444
+ USING itineraries i
445
+ WHERE s.id = :stop_id
446
+ AND s.itinerary_id = :itinerary_id
447
+ AND i.id = s.itinerary_id
448
+ AND i.user_id = :user_id
449
+ RETURNING s.id
450
+ """),
451
+ {"stop_id": stop_id, "itinerary_id": itinerary_id, "user_id": user_id}
452
+ )
453
+ await db.commit()
454
+
455
+ if not result.fetchone():
456
+ raise HTTPException(status_code=404, detail="Stop not found")
457
+
458
+ return {"status": "success", "message": "Stop deleted"}
app/main.py CHANGED
@@ -11,6 +11,8 @@ from fastapi.middleware.cors import CORSMiddleware
11
 
12
  from app.api.router import router as api_router
13
  from app.planner.router import router as planner_router
 
 
14
  from app.shared.db.session import engine
15
  from app.shared.integrations.neo4j_client import neo4j_client
16
 
@@ -55,7 +57,7 @@ Intelligent travel assistant for Da Nang with 3 MCP tools + Trip Planner:
55
  - "Nhà hàng hải sản nào được review tốt?"
56
  - "Quán nào có không gian xanh mát?" (with image_url)
57
  """,
58
- version="0.2.1",
59
  lifespan=lifespan,
60
  )
61
 
@@ -71,6 +73,12 @@ app.add_middleware(
71
  # Include API routers
72
  app.include_router(api_router, prefix="/api/v1", tags=["Chat"])
73
  app.include_router(planner_router, prefix="/api/v1", tags=["Trip Planner"])
 
 
 
 
 
 
74
 
75
 
76
  @app.get("/health", tags=["System"])
 
11
 
12
  from app.api.router import router as api_router
13
  from app.planner.router import router as planner_router
14
+ from app.users.router import router as users_router
15
+ from app.itineraries.router import router as itineraries_router
16
  from app.shared.db.session import engine
17
  from app.shared.integrations.neo4j_client import neo4j_client
18
 
 
57
  - "Nhà hàng hải sản nào được review tốt?"
58
  - "Quán nào có không gian xanh mát?" (with image_url)
59
  """,
60
+ version="0.3.0",
61
  lifespan=lifespan,
62
  )
63
 
 
73
  # Include API routers
74
  app.include_router(api_router, prefix="/api/v1", tags=["Chat"])
75
  app.include_router(planner_router, prefix="/api/v1", tags=["Trip Planner"])
76
+ app.include_router(users_router, prefix="/api/v1", tags=["Users"])
77
+ app.include_router(itineraries_router, prefix="/api/v1", tags=["Itineraries"])
78
+
79
+ # Upload router
80
+ from app.upload import router as upload_router
81
+ app.include_router(upload_router, prefix="/api/v1", tags=["Upload"])
82
 
83
 
84
  @app.get("/health", tags=["System"])
app/upload/__init__.py ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Upload Router - Image upload to Supabase Storage."""
2
+
3
+ import uuid
4
+ from datetime import datetime
5
+ from fastapi import APIRouter, UploadFile, File, HTTPException
6
+ from pydantic import BaseModel, Field
7
+
8
+ from app.shared.integrations.supabase_client import supabase
9
+ from app.core.config import settings
10
+
11
+
12
+ router = APIRouter(prefix="/upload", tags=["Upload"])
13
+
14
+ # Supabase Storage bucket name
15
+ BUCKET_NAME = "image"
16
+
17
+ # Allowed image types
18
+ ALLOWED_TYPES = {"image/jpeg", "image/png", "image/webp", "image/gif"}
19
+ MAX_SIZE_MB = 10
20
+
21
+
22
+ class UploadResponse(BaseModel):
23
+ """Upload response model."""
24
+ url: str = Field(..., description="Public URL of uploaded image")
25
+ path: str = Field(..., description="Storage path")
26
+ size: int = Field(..., description="File size in bytes")
27
+ content_type: str = Field(..., description="MIME type")
28
+
29
+
30
+ @router.post(
31
+ "/image",
32
+ response_model=UploadResponse,
33
+ summary="Upload image to storage",
34
+ description="""
35
+ Upload an image file to Supabase Storage.
36
+
37
+ Returns a public URL that can be used with `/chat` endpoint's `image_url` parameter.
38
+
39
+ Supported formats: JPEG, PNG, WebP, GIF
40
+ Max size: 10MB
41
+ """,
42
+ )
43
+ async def upload_image(
44
+ file: UploadFile = File(..., description="Image file to upload"),
45
+ user_id: str = "anonymous",
46
+ ) -> UploadResponse:
47
+ """Upload image to Supabase Storage and return public URL."""
48
+
49
+ # Validate content type
50
+ if file.content_type not in ALLOWED_TYPES:
51
+ raise HTTPException(
52
+ status_code=400,
53
+ detail=f"Invalid file type. Allowed: {', '.join(ALLOWED_TYPES)}"
54
+ )
55
+
56
+ # Read file content
57
+ content = await file.read()
58
+
59
+ # Validate size
60
+ size = len(content)
61
+ if size > MAX_SIZE_MB * 1024 * 1024:
62
+ raise HTTPException(
63
+ status_code=400,
64
+ detail=f"File too large. Max size: {MAX_SIZE_MB}MB"
65
+ )
66
+
67
+ # Generate unique filename
68
+ ext = file.filename.split(".")[-1] if file.filename and "." in file.filename else "jpg"
69
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
70
+ unique_id = str(uuid.uuid4())[:8]
71
+ filename = f"{user_id}/{timestamp}_{unique_id}.{ext}"
72
+
73
+ try:
74
+ # Upload to Supabase Storage
75
+ result = supabase.storage.from_(BUCKET_NAME).upload(
76
+ path=filename,
77
+ file=content,
78
+ file_options={"content-type": file.content_type}
79
+ )
80
+
81
+ # Get public URL
82
+ public_url = supabase.storage.from_(BUCKET_NAME).get_public_url(filename)
83
+
84
+ return UploadResponse(
85
+ url=public_url,
86
+ path=filename,
87
+ size=size,
88
+ content_type=file.content_type,
89
+ )
90
+
91
+ except Exception as e:
92
+ raise HTTPException(
93
+ status_code=500,
94
+ detail=f"Upload failed: {str(e)}"
95
+ )
app/users/__init__.py ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """User Profile models."""
2
+
3
+ from datetime import datetime
4
+ from pydantic import BaseModel, Field
5
+
6
+
7
+ class ProfileBase(BaseModel):
8
+ """Base profile fields."""
9
+ full_name: str = Field(default="", description="User's full name")
10
+ phone: str | None = Field(None, description="Phone number")
11
+ locale: str = Field(default="vi_VN", description="Locale setting")
12
+ avatar_url: str | None = Field(None, description="Avatar URL")
13
+
14
+
15
+ class ProfileCreate(ProfileBase):
16
+ """Create profile request (usually auto-created on signup)."""
17
+ pass
18
+
19
+
20
+ class ProfileUpdate(BaseModel):
21
+ """Update profile request - all fields optional."""
22
+ full_name: str | None = None
23
+ phone: str | None = None
24
+ locale: str | None = None
25
+ avatar_url: str | None = None
26
+
27
+
28
+ class Profile(ProfileBase):
29
+ """Full profile response."""
30
+ id: str = Field(..., description="User ID (UUID)")
31
+ role: str = Field(default="tourist", description="User role: tourist, driver, admin")
32
+ created_at: datetime
33
+ updated_at: datetime
34
+
35
+ class Config:
36
+ from_attributes = True
37
+
38
+
39
+ class ProfileResponse(BaseModel):
40
+ """API response wrapper."""
41
+ profile: Profile
42
+ message: str = "Success"
app/users/router.py ADDED
@@ -0,0 +1,149 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """User Profile Router."""
2
+
3
+ from fastapi import APIRouter, HTTPException, Depends, Query
4
+ from sqlalchemy.ext.asyncio import AsyncSession
5
+ from sqlalchemy import text
6
+
7
+ from app.shared.db.session import get_db
8
+ from app.users import Profile, ProfileUpdate, ProfileResponse
9
+
10
+
11
+ router = APIRouter(prefix="/users", tags=["Users"])
12
+
13
+
14
+ @router.get(
15
+ "/me",
16
+ response_model=ProfileResponse,
17
+ summary="Get current user profile",
18
+ description="Returns the profile for the authenticated user.",
19
+ )
20
+ async def get_my_profile(
21
+ user_id: str = Query(..., description="User ID (from auth)"),
22
+ db: AsyncSession = Depends(get_db),
23
+ ) -> ProfileResponse:
24
+ """Get current user's profile."""
25
+ result = await db.execute(
26
+ text("""
27
+ SELECT id, full_name, phone, role, locale, avatar_url, created_at, updated_at
28
+ FROM profiles
29
+ WHERE id = :user_id
30
+ """),
31
+ {"user_id": user_id}
32
+ )
33
+ row = result.fetchone()
34
+
35
+ if not row:
36
+ raise HTTPException(status_code=404, detail="Profile not found")
37
+
38
+ profile = Profile(
39
+ id=str(row.id),
40
+ full_name=row.full_name,
41
+ phone=row.phone,
42
+ role=row.role,
43
+ locale=row.locale,
44
+ avatar_url=row.avatar_url,
45
+ created_at=row.created_at,
46
+ updated_at=row.updated_at,
47
+ )
48
+
49
+ return ProfileResponse(profile=profile, message="Profile retrieved")
50
+
51
+
52
+ @router.put(
53
+ "/me",
54
+ response_model=ProfileResponse,
55
+ summary="Update current user profile",
56
+ description="Updates the profile for the authenticated user.",
57
+ )
58
+ async def update_my_profile(
59
+ updates: ProfileUpdate,
60
+ user_id: str = Query(..., description="User ID (from auth)"),
61
+ db: AsyncSession = Depends(get_db),
62
+ ) -> ProfileResponse:
63
+ """Update current user's profile."""
64
+ # Build dynamic update query
65
+ update_fields = []
66
+ params = {"user_id": user_id}
67
+
68
+ if updates.full_name is not None:
69
+ update_fields.append("full_name = :full_name")
70
+ params["full_name"] = updates.full_name
71
+ if updates.phone is not None:
72
+ update_fields.append("phone = :phone")
73
+ params["phone"] = updates.phone
74
+ if updates.locale is not None:
75
+ update_fields.append("locale = :locale")
76
+ params["locale"] = updates.locale
77
+ if updates.avatar_url is not None:
78
+ update_fields.append("avatar_url = :avatar_url")
79
+ params["avatar_url"] = updates.avatar_url
80
+
81
+ if not update_fields:
82
+ raise HTTPException(status_code=400, detail="No fields to update")
83
+
84
+ update_fields.append("updated_at = NOW()")
85
+
86
+ query = f"""
87
+ UPDATE profiles
88
+ SET {', '.join(update_fields)}
89
+ WHERE id = :user_id
90
+ RETURNING id, full_name, phone, role, locale, avatar_url, created_at, updated_at
91
+ """
92
+
93
+ result = await db.execute(text(query), params)
94
+ await db.commit()
95
+
96
+ row = result.fetchone()
97
+ if not row:
98
+ raise HTTPException(status_code=404, detail="Profile not found")
99
+
100
+ profile = Profile(
101
+ id=str(row.id),
102
+ full_name=row.full_name,
103
+ phone=row.phone,
104
+ role=row.role,
105
+ locale=row.locale,
106
+ avatar_url=row.avatar_url,
107
+ created_at=row.created_at,
108
+ updated_at=row.updated_at,
109
+ )
110
+
111
+ return ProfileResponse(profile=profile, message="Profile updated")
112
+
113
+
114
+ @router.get(
115
+ "/{user_id}",
116
+ response_model=ProfileResponse,
117
+ summary="Get user profile by ID",
118
+ description="Returns the profile for a specific user (admin only).",
119
+ )
120
+ async def get_profile_by_id(
121
+ user_id: str,
122
+ db: AsyncSession = Depends(get_db),
123
+ ) -> ProfileResponse:
124
+ """Get user profile by ID."""
125
+ result = await db.execute(
126
+ text("""
127
+ SELECT id, full_name, phone, role, locale, avatar_url, created_at, updated_at
128
+ FROM profiles
129
+ WHERE id = :user_id
130
+ """),
131
+ {"user_id": user_id}
132
+ )
133
+ row = result.fetchone()
134
+
135
+ if not row:
136
+ raise HTTPException(status_code=404, detail="Profile not found")
137
+
138
+ profile = Profile(
139
+ id=str(row.id),
140
+ full_name=row.full_name,
141
+ phone=row.phone,
142
+ role=row.role,
143
+ locale=row.locale,
144
+ avatar_url=row.avatar_url,
145
+ created_at=row.created_at,
146
+ updated_at=row.updated_at,
147
+ )
148
+
149
+ return ProfileResponse(profile=profile, message="Profile retrieved")
docs/API_REFERENCE.md ADDED
@@ -0,0 +1,643 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # LocalMate API Documentation
2
+
3
+ > **Base URL:** `https://cuong2004-localmate.hf.space/api/v1`
4
+ > **Swagger UI:** `/docs` | **ReDoc:** `/redoc`
5
+ > **Version:** 0.3.0
6
+
7
+ ---
8
+
9
+ ## 📋 Table of Contents
10
+
11
+ 1. [Chat API](#chat-api)
12
+ 2. [User Profile API](#user-profile-api)
13
+ 3. [Itineraries API](#itineraries-api)
14
+ 4. [Trip Planner API](#trip-planner-api)
15
+ 5. [Utility Endpoints](#utility-endpoints)
16
+ 6. [Models](#models)
17
+
18
+ ---
19
+
20
+ ## Chat API
21
+
22
+ ### POST `/chat`
23
+
24
+ Main endpoint for interacting with the AI assistant.
25
+
26
+ **Request:**
27
+ ```json
28
+ {
29
+ "message": "Quán cafe view đẹp gần Mỹ Khê",
30
+ "user_id": "user_123",
31
+ "session_id": "default",
32
+ "provider": "MegaLLM",
33
+ "model": "deepseek-ai/deepseek-v3.1-terminus",
34
+ "image_url": null,
35
+ "react_mode": false,
36
+ "max_steps": 5
37
+ }
38
+ ```
39
+
40
+ | Field | Type | Required | Description |
41
+ |-------|------|----------|-------------|
42
+ | `message` | string | ✅ | User's question in Vietnamese |
43
+ | `user_id` | string | ❌ | User ID for session (default: "anonymous") |
44
+ | `session_id` | string | ❌ | Session ID (default: "default") |
45
+ | `provider` | string | ❌ | "Google" or "MegaLLM" (default: "MegaLLM") |
46
+ | `model` | string | ❌ | LLM model name |
47
+ | `image_url` | string | ❌ | Base64 image for visual search |
48
+ | `react_mode` | boolean | ❌ | Enable multi-step reasoning (default: false) |
49
+ | `max_steps` | integer | ❌ | Max reasoning steps 1-10 (default: 5) |
50
+
51
+ **Response:**
52
+ ```json
53
+ {
54
+ "response": "Dựa trên yêu cầu của bạn, tôi tìm thấy...",
55
+ "status": "success",
56
+ "provider": "MegaLLM",
57
+ "model": "deepseek-ai/deepseek-v3.1-terminus",
58
+ "user_id": "user_123",
59
+ "session_id": "default",
60
+ "workflow": {
61
+ "query": "Quán cafe view đẹp gần Mỹ Khê",
62
+ "intent_detected": "location_search",
63
+ "tools_used": ["find_nearby_places"],
64
+ "steps": [
65
+ {
66
+ "step": "Execute find_nearby_places",
67
+ "tool": "find_nearby_places",
68
+ "purpose": "Tìm địa điểm gần vị trí được nhắc đến",
69
+ "results": 5
70
+ }
71
+ ],
72
+ "total_duration_ms": 5748.23
73
+ },
74
+ "tools_used": ["find_nearby_places"],
75
+ "duration_ms": 5748.23
76
+ }
77
+ ```
78
+
79
+ ---
80
+
81
+ ### POST `/chat/clear`
82
+
83
+ Clear conversation history.
84
+
85
+ **Request:**
86
+ ```json
87
+ {
88
+ "user_id": "user_123",
89
+ "session_id": "default"
90
+ }
91
+ ```
92
+
93
+ **Response:**
94
+ ```json
95
+ {
96
+ "status": "success",
97
+ "message": "Cleared session 'default' for user_123"
98
+ }
99
+ ```
100
+
101
+ ---
102
+
103
+ ### GET `/chat/history/{user_id}`
104
+
105
+ Get chat session metadata (list of sessions, counts).
106
+
107
+ **Response:**
108
+ ```json
109
+ {
110
+ "user_id": "user_123",
111
+ "sessions": ["default", "trip_planning"],
112
+ "current_session": "default",
113
+ "message_count": 12
114
+ }
115
+ ```
116
+
117
+ ---
118
+
119
+ ### GET `/chat/messages/{user_id}`
120
+
121
+ Get actual chat messages from a session.
122
+
123
+ **Query Params:** `?session_id=default`
124
+
125
+ **Response:**
126
+ ```json
127
+ {
128
+ "user_id": "user_123",
129
+ "session_id": "default",
130
+ "messages": [
131
+ {
132
+ "role": "user",
133
+ "content": "Tìm quán cafe gần Mỹ Khê",
134
+ "timestamp": "2025-01-01T10:00:00"
135
+ },
136
+ {
137
+ "role": "assistant",
138
+ "content": "Dựa trên yêu cầu của bạn...",
139
+ "timestamp": "2025-01-01T10:00:05"
140
+ }
141
+ ],
142
+ "count": 2
143
+ }
144
+ ```
145
+
146
+ ---
147
+
148
+ ### POST `/image-search`
149
+
150
+ Search places by uploaded image.
151
+
152
+ **Request:** `multipart/form-data`
153
+ - `image`: File (required)
154
+ - `limit`: integer (default: 10)
155
+
156
+ **Response:**
157
+ ```json
158
+ {
159
+ "results": [
160
+ {
161
+ "place_id": "cafe_123",
162
+ "name": "Nhớ Một Người",
163
+ "category": "Cafe",
164
+ "rating": 4.9,
165
+ "similarity": 0.85,
166
+ "image_url": "https://..."
167
+ }
168
+ ],
169
+ "total": 5
170
+ }
171
+ ```
172
+
173
+ ---
174
+
175
+ ## Upload API
176
+
177
+ Base path: `/upload`
178
+
179
+ ### POST `/upload/image`
180
+
181
+ Upload image to Supabase Storage and get public URL.
182
+
183
+ > Use this to get an image URL for the `/chat` endpoint's `image_url` parameter.
184
+
185
+ **Request:** `multipart/form-data`
186
+ - `file`: Image file (required)
187
+ - `user_id`: string (optional, for organizing uploads)
188
+
189
+ **Supported formats:** JPEG, PNG, WebP, GIF
190
+ **Max size:** 10MB
191
+
192
+ **Response:**
193
+ ```json
194
+ {
195
+ "url": "https://xxx.supabase.co/storage/v1/object/public/image/user123/20250101_120000_abc123.jpg",
196
+ "path": "user123/20250101_120000_abc123.jpg",
197
+ "size": 245678,
198
+ "content_type": "image/jpeg"
199
+ }
200
+ ```
201
+
202
+ **Usage Flow:**
203
+ ```
204
+ 1. POST /upload/image → get URL
205
+ 2. POST /chat { image_url: URL } → visual search
206
+ ```
207
+
208
+ ---
209
+
210
+ ## User Profile API
211
+
212
+ Base path: `/users`
213
+
214
+ ### GET `/users/me`
215
+
216
+ Get current user's profile.
217
+
218
+ **Query:** `?user_id=uuid-here`
219
+
220
+ **Response:**
221
+ ```json
222
+ {
223
+ "profile": {
224
+ "id": "uuid-here",
225
+ "full_name": "Nguyen Van A",
226
+ "phone": "0901234567",
227
+ "role": "tourist",
228
+ "locale": "vi_VN",
229
+ "avatar_url": "https://...",
230
+ "created_at": "2025-01-01T00:00:00Z",
231
+ "updated_at": "2025-01-01T00:00:00Z"
232
+ },
233
+ "message": "Profile retrieved"
234
+ }
235
+ ```
236
+
237
+ ---
238
+
239
+ ### PUT `/users/me`
240
+
241
+ Update current user's profile.
242
+
243
+ **Query:** `?user_id=uuid-here`
244
+
245
+ **Request:**
246
+ ```json
247
+ {
248
+ "full_name": "Nguyen Van B",
249
+ "phone": "0909876543",
250
+ "locale": "en_US",
251
+ "avatar_url": "https://..."
252
+ }
253
+ ```
254
+
255
+ ---
256
+
257
+ ### GET `/users/{user_id}`
258
+
259
+ Get user profile by ID (admin only).
260
+
261
+ ---
262
+
263
+ ## Itineraries API
264
+
265
+ Base path: `/itineraries`
266
+
267
+ > Multi-day trip planning with persistent storage (Supabase)
268
+
269
+ ### POST `/itineraries`
270
+
271
+ Create new itinerary.
272
+
273
+ **Query:** `?user_id=uuid-here`
274
+
275
+ **Request:**
276
+ ```json
277
+ {
278
+ "title": "Da Nang 3 Days Trip",
279
+ "start_date": "2025-02-01",
280
+ "end_date": "2025-02-03",
281
+ "total_days": 3,
282
+ "total_budget": 5000000,
283
+ "currency": "VND"
284
+ }
285
+ ```
286
+
287
+ **Response:**
288
+ ```json
289
+ {
290
+ "itinerary": {
291
+ "id": "itinerary-uuid",
292
+ "user_id": "user-uuid",
293
+ "title": "Da Nang 3 Days Trip",
294
+ "start_date": "2025-02-01",
295
+ "end_date": "2025-02-03",
296
+ "total_days": 3,
297
+ "total_budget": 5000000,
298
+ "currency": "VND",
299
+ "stops": [],
300
+ "created_at": "...",
301
+ "updated_at": "..."
302
+ },
303
+ "message": "Itinerary created"
304
+ }
305
+ ```
306
+
307
+ ---
308
+
309
+ ### GET `/itineraries`
310
+
311
+ List user's itineraries.
312
+
313
+ **Query:** `?user_id=uuid-here`
314
+
315
+ **Response:**
316
+ ```json
317
+ [
318
+ {
319
+ "id": "itinerary-uuid",
320
+ "title": "Da Nang 3 Days Trip",
321
+ "start_date": "2025-02-01",
322
+ "end_date": "2025-02-03",
323
+ "total_days": 3,
324
+ "stop_count": 8,
325
+ "created_at": "..."
326
+ }
327
+ ]
328
+ ```
329
+
330
+ ---
331
+
332
+ ### GET `/itineraries/{itinerary_id}`
333
+
334
+ Get itinerary with all stops.
335
+
336
+ **Query:** `?user_id=uuid-here`
337
+
338
+ ---
339
+
340
+ ### PUT `/itineraries/{itinerary_id}`
341
+
342
+ Update itinerary details.
343
+
344
+ **Request:**
345
+ ```json
346
+ {
347
+ "title": "Updated Title",
348
+ "total_budget": 6000000
349
+ }
350
+ ```
351
+
352
+ ---
353
+
354
+ ### DELETE `/itineraries/{itinerary_id}`
355
+
356
+ Delete itinerary and all stops.
357
+
358
+ ---
359
+
360
+ ### POST `/itineraries/{itinerary_id}/stops`
361
+
362
+ Add stop to itinerary.
363
+
364
+ **Request:**
365
+ ```json
366
+ {
367
+ "place_id": "cafe_123",
368
+ "day_index": 1,
369
+ "order_index": 1,
370
+ "arrival_time": "2025-02-01T09:00:00Z",
371
+ "stay_minutes": 60,
372
+ "notes": "Morning coffee",
373
+ "tags": ["cafe", "breakfast"]
374
+ }
375
+ ```
376
+
377
+ ---
378
+
379
+ ### PUT `/itineraries/{itinerary_id}/stops/{stop_id}`
380
+
381
+ Update stop.
382
+
383
+ ---
384
+
385
+ ### DELETE `/itineraries/{itinerary_id}/stops/{stop_id}`
386
+
387
+ Remove stop.
388
+
389
+ ---
390
+
391
+ ## Trip Planner API
392
+
393
+ Base path: `/planner`
394
+
395
+ ### POST `/planner/create`
396
+
397
+ Create a new trip plan.
398
+
399
+ **Query:** `?user_id=user_123`
400
+
401
+ **Request:**
402
+ ```json
403
+ {
404
+ "name": "My Da Nang Trip"
405
+ }
406
+ ```
407
+
408
+ **Response:**
409
+ ```json
410
+ {
411
+ "plan_id": "plan_abc123",
412
+ "name": "My Da Nang Trip",
413
+ "message": "Created plan 'My Da Nang Trip'"
414
+ }
415
+ ```
416
+
417
+ ---
418
+
419
+ ### GET `/planner/{plan_id}`
420
+
421
+ Get a plan by ID.
422
+
423
+ **Query:** `?user_id=user_123`
424
+
425
+ **Response:**
426
+ ```json
427
+ {
428
+ "plan": {
429
+ "plan_id": "plan_abc123",
430
+ "name": "My Da Nang Trip",
431
+ "items": [
432
+ {
433
+ "item_id": "item_1",
434
+ "order": 0,
435
+ "name": "FIRGUN CORNER COFFEE",
436
+ "category": "Cafe",
437
+ "lat": 16.06,
438
+ "lng": 108.22,
439
+ "rating": 4.5,
440
+ "distance_from_prev_km": null
441
+ }
442
+ ],
443
+ "total_distance_km": 0,
444
+ "estimated_duration_min": 0
445
+ },
446
+ "message": "Plan retrieved"
447
+ }
448
+ ```
449
+
450
+ ---
451
+
452
+ ### POST `/planner/{plan_id}/add`
453
+
454
+ Add a place to the plan.
455
+
456
+ **Query:** `?user_id=user_123`
457
+
458
+ **Request:**
459
+ ```json
460
+ {
461
+ "place": {
462
+ "place_id": "cafe_123",
463
+ "name": "FIRGUN CORNER COFFEE",
464
+ "category": "Cafe",
465
+ "lat": 16.06,
466
+ "lng": 108.22,
467
+ "rating": 4.5
468
+ },
469
+ "notes": "Morning coffee"
470
+ }
471
+ ```
472
+
473
+ **Response:** `PlanItem` object
474
+
475
+ ---
476
+
477
+ ### DELETE `/planner/{plan_id}/remove/{item_id}`
478
+
479
+ Remove a place from the plan.
480
+
481
+ **Query:** `?user_id=user_123`
482
+
483
+ **Response:**
484
+ ```json
485
+ {
486
+ "status": "success",
487
+ "message": "Removed item item_1"
488
+ }
489
+ ```
490
+
491
+ ---
492
+
493
+ ### PUT `/planner/{plan_id}/reorder`
494
+
495
+ Reorder places manually.
496
+
497
+ **Query:** `?user_id=user_123`
498
+
499
+ **Request:**
500
+ ```json
501
+ {
502
+ "new_order": ["item_3", "item_1", "item_2"]
503
+ }
504
+ ```
505
+
506
+ ---
507
+
508
+ ### POST `/planner/{plan_id}/optimize`
509
+
510
+ Optimize route using TSP algorithm.
511
+
512
+ **Query:** `?user_id=user_123&start_index=0`
513
+
514
+ **Response:**
515
+ ```json
516
+ {
517
+ "plan_id": "plan_abc123",
518
+ "items": [...],
519
+ "total_distance_km": 12.5,
520
+ "estimated_duration_min": 45,
521
+ "distance_saved_km": 3.2,
522
+ "message": "Route optimized! Total: 12.5km, ~45min"
523
+ }
524
+ ```
525
+
526
+ ---
527
+
528
+ ### DELETE `/planner/{plan_id}`
529
+
530
+ Delete a plan.
531
+
532
+ **Query:** `?user_id=user_123`
533
+
534
+ ---
535
+
536
+ ## Utility Endpoints
537
+
538
+ ### GET `/health`
539
+
540
+ Health check. Returns `{"status": "ok"}`
541
+
542
+ ### POST `/nearby`
543
+
544
+ Find nearby places (direct Neo4j query).
545
+
546
+ **Request:**
547
+ ```json
548
+ {
549
+ "lat": 16.0626442,
550
+ "lng": 108.2462143,
551
+ "max_distance_km": 3.0,
552
+ "category": "cafe",
553
+ "limit": 10
554
+ }
555
+ ```
556
+
557
+ ---
558
+
559
+ ## Models
560
+
561
+ ### Place Object
562
+ ```typescript
563
+ interface Place {
564
+ place_id: string;
565
+ name: string;
566
+ category?: string;
567
+ lat?: number;
568
+ lng?: number;
569
+ rating?: number;
570
+ description?: string;
571
+ }
572
+ ```
573
+
574
+ ### PlanItem
575
+ ```typescript
576
+ interface PlanItem {
577
+ item_id: string;
578
+ order: number;
579
+ name: string;
580
+ category?: string;
581
+ lat: number;
582
+ lng: number;
583
+ rating?: number;
584
+ notes?: string;
585
+ distance_from_prev_km?: number;
586
+ }
587
+ ```
588
+
589
+ ### WorkflowStep
590
+ ```typescript
591
+ interface WorkflowStep {
592
+ step: string;
593
+ tool?: string;
594
+ purpose: string;
595
+ results: number;
596
+ }
597
+ ```
598
+
599
+ ---
600
+
601
+ ## Usage Examples
602
+
603
+ ### JavaScript/Fetch
604
+
605
+ ```javascript
606
+ // Chat
607
+ const response = await fetch('https://cuong2004-localmate.hf.space/api/v1/chat', {
608
+ method: 'POST',
609
+ headers: { 'Content-Type': 'application/json' },
610
+ body: JSON.stringify({
611
+ message: 'Quán cafe view đẹp gần Mỹ Khê',
612
+ user_id: 'my_user',
613
+ react_mode: false
614
+ })
615
+ });
616
+ const data = await response.json();
617
+ console.log(data.response);
618
+ ```
619
+
620
+ ### cURL
621
+
622
+ ```bash
623
+ curl -X POST "https://cuong2004-localmate.hf.space/api/v1/chat" \
624
+ -H "Content-Type: application/json" \
625
+ -d '{"message": "Nhà hàng hải sản ngon", "user_id": "test"}'
626
+ ```
627
+
628
+ ---
629
+
630
+ ## Error Responses
631
+
632
+ | Status | Description |
633
+ |--------|-------------|
634
+ | 400 | Bad Request - Invalid parameters |
635
+ | 404 | Not Found - Resource doesn't exist |
636
+ | 422 | Validation Error - Check request body |
637
+ | 500 | Server Error - Check logs |
638
+
639
+ ```json
640
+ {
641
+ "detail": "Plan not found"
642
+ }
643
+ ```