Commit 0d884ac8 authored by root's avatar root

fix: Alpine bash→sh, fastapi version, port conflicts; add team features, meili search, backlinks

parent 2fec4891
# Production Dockerfile for Backend # ============================================
FROM python:3.11-slim # 🔨 Stage 1: BUILDER - Build Dependencies
# ============================================
FROM python:3.11-alpine AS builder
WORKDIR /app
# Install build dependencies for Alpine
RUN apk add --no-cache \
gcc \
g++ \
musl-dev \
linux-headers \
postgresql-dev \
libffi-dev \
openssl-dev \
cargo \
rust
# Copy requirements
COPY requirements.txt .
# Install Python packages to /install directory
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
# ============================================
# ✨ Stage 2: RUNTIME - SIÊU NHẸ với Alpine!
# ============================================
FROM python:3.11-alpine
WORKDIR /app WORKDIR /app
# Set environment variables # Set environment variables
ENV PYTHONUNBUFFERED=1 \ ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \ PYTHONDONTWRITEBYTECODE=1 \
PIP_NO_CACHE_DIR=1 \ PATH="/install/bin:$PATH" \
PIP_DISABLE_PIP_VERSION_CHECK=1 PYTHONPATH="/install/lib/python3.11/site-packages"
# Install system dependencies # Install ONLY runtime dependencies (không build tools!)
RUN apt-get update && apt-get install -y \ RUN apk add --no-cache \
curl \ curl \
&& rm -rf /var/lib/apt/lists/* libpq \
libgcc \
libstdc++
# Copy requirements and install Python dependencies # Copy installed packages from builder stage
COPY requirements.txt . COPY --from=builder /install /install
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code # Copy application code
COPY . . COPY . .
# Copy and set up entrypoint script # Set up entrypoint script
COPY entrypoint.sh /app/entrypoint.sh
RUN chmod +x /app/entrypoint.sh RUN chmod +x /app/entrypoint.sh
# Create data directory # Create data directory
......
""" """
Fashion Q&A Agent Package CuCu Note Agent Package
""" """
from .graph import build_graph from .graph import build_graph
from .models import AgentConfig, AgentState, get_config from .models import AgentConfig, AgentState, get_config
......
""" """
Fashion Q&A Agent Controller CuCu Note Agent Controller
Langfuse will auto-trace via LangChain integration (no code changes needed). Langfuse will auto-trace via LangChain integration (no code changes needed).
""" """
......
""" """
Fashion Q&A Agent Graph CuCu Note Agent Graph
LangGraph workflow với clean architecture. LangGraph workflow với clean architecture.
Tất cả resources (LLM, Tools) khởi tạo trong __init__. Tất cả resources (LLM, Tools) khởi tạo trong __init__.
Sử dụng ConversationManager (Postgres) để lưu history thay vì checkpoint. Sử dụng ConversationManager (MongoDB) để lưu history thay vì checkpoint.
""" """
import logging import logging
...@@ -25,9 +25,9 @@ from .tools.get_tools import get_all_tools, get_collection_tools ...@@ -25,9 +25,9 @@ from .tools.get_tools import get_all_tools, get_collection_tools
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class CANIFAGraph: class CuCuGraph:
""" """
Fashion Q&A Agent Graph Manager. CuCu Note Agent Graph Manager.
""" """
def __init__( def __init__(
...@@ -128,24 +128,27 @@ class CANIFAGraph: ...@@ -128,24 +128,27 @@ class CANIFAGraph:
# --- Singleton & Public API --- # --- Singleton & Public API ---
_instance: list[CANIFAGraph | None] = [None] _instance: CuCuGraph | None = None
def build_graph(config: AgentConfig | None = None, llm: BaseChatModel | None = None, tools: list | None = None) -> Any: def build_graph(config: AgentConfig | None = None, llm: BaseChatModel | None = None, tools: list | None = None) -> Any:
"""Get compiled graph (singleton).""" """Get compiled graph (singleton)."""
if _instance[0] is None: global _instance
_instance[0] = CANIFAGraph(config, llm, tools) if _instance is None:
return _instance[0].build() _instance = CuCuGraph(config, llm, tools)
return _instance.build()
def get_graph_manager( def get_graph_manager(
config: AgentConfig | None = None, llm: BaseChatModel | None = None, tools: list | None = None config: AgentConfig | None = None, llm: BaseChatModel | None = None, tools: list | None = None
) -> CANIFAGraph: ) -> CuCuGraph:
"""Get CANIFAGraph instance.""" """Get CuCuGraph instance."""
if _instance[0] is None: global _instance
_instance[0] = CANIFAGraph(config, llm, tools) if _instance is None:
return _instance[0] _instance = CuCuGraph(config, llm, tools)
return _instance
def reset_graph() -> None: def reset_graph() -> None:
"""Reset singleton for testing.""" """Reset singleton for testing."""
_instance[0] = None global _instance
_instance = None
...@@ -8,7 +8,7 @@ import config as global_config ...@@ -8,7 +8,7 @@ import config as global_config
class QueryRequest(BaseModel): class QueryRequest(BaseModel):
"""API Request model cho Fashion Q&A Chat""" """API Request model cho CuCu Note Chat"""
user_id: str | None = None user_id: str | None = None
user_query: str user_query: str
......
""" """
Tools Package Tools Package
Hiện tại CuCu Agent chỉ dùng memo_retrieval_tool (MongoDB). Hiện tại CuCu Agent chỉ dùng memo_retrieval_tool (MongoDB).
Các tool cũ cho StarRocks / CANIFA đã bỏ. Các tool cũ (StarRocks, etc.) đã được dọn dẹp.
""" """
from .get_tools import get_all_tools from .get_tools import get_all_tools
......
import logging
from langchain_core.tools import tool
from pydantic import BaseModel, Field
from common.embedding_service import create_embedding_async
from common.starrocks_connection import get_db_connection
logger = logging.getLogger(__name__)
class KnowledgeSearchInput(BaseModel):
query: str = Field(
description="Câu hỏi hoặc nhu cầu tìm kiếm thông tin phi sản phẩm của khách hàng (ví dụ: tìm cửa hàng, hỏi chính sách, tra bảng size...)"
)
@tool("canifa_knowledge_search", args_schema=KnowledgeSearchInput)
async def canifa_knowledge_search(query: str) -> str:
"""
Tra cứu TOÀN BỘ thông tin về thương hiệu và dịch vụ của Canifa.
Sử dụng tool này khi khách hàng hỏi về:
1. THƯƠNG HIỆU & GIỚI THIỆU: Lịch sử hình thành, giá trị cốt lõi, sứ mệnh.
2. HỆ THỐNG CỬA HÀNG: Tìm địa chỉ, số điện thoại, giờ mở cửa các cửa hàng tại các tỉnh thành (Hà Nội, HCM, Đà Nẵng, v.v.).
3. CHÍNH SÁCH BÁN HÀNG: Quy định đổi trả, bảo hành, chính sách vận chuyển, phí ship.
4. KHÁCH HÀNG THÂN THIẾT (KHTT): Điều kiện đăng ký thành viên, các hạng thẻ (Green, Silver, Gold, Diamond), quyền lợi tích điểm, thẻ quà tặng.
5. HỖ TRỢ & FAQ: Giải đáp thắc mắc thường gặp, chính sách bảo mật, thông tin liên hệ văn phòng, tuyển dụng.
6. TRA CỨU SIZE (BẢNG KÍCH CỠ): Hướng dẫn chọn size chuẩn cho nam, nữ, trẻ em dựa trên chiều cao, cân nặng.
Ví dụ các câu hỏi phù hợp:
- 'Canifa ở Cầu Giấy địa chỉ ở đâu?'
- 'Chính sách đổi trả hàng trong bao nhiêu ngày?'
- 'Làm sao để lên hạng thẻ Gold?'
- 'Cho mình xem bảng size áo nam.'
- 'Phí vận chuyển đi tỉnh là bao nhiêu?'
- 'Canifa thành lập năm nào?'
"""
logger.info(f"🔍 [Semantic Search] Brand Knowledge query: {query}")
try:
# 1. Tạo embedding cho câu hỏi (Mặc định 1536 chiều như bro yêu cầu)
query_vector = await create_embedding_async(query)
if not query_vector:
return "Xin lỗi, tôi gặp sự cố khi xử lý thông tin. Vui lòng thử lại sau."
v_str = "[" + ",".join(str(v) for v in query_vector) + "]"
# 2. Query StarRocks lấy Top 4 kết quả phù hợp nhất (Không check score)
sql = f"""
SELECT
content,
metadata
FROM shared_source.chatbot_rsa_knowledge
ORDER BY approx_cosine_similarity(embedding, {v_str}) DESC
LIMIT 4
"""
sr = get_db_connection()
results = await sr.execute_query_async(sql)
if not results:
logger.warning(f"⚠️ No knowledge data found in DB for query: {query}")
return "Hiện tại tôi chưa tìm thấy thông tin chính xác về nội dung này trong hệ thống kiến thức của Canifa. Bạn có thể liên hệ hotline 1800 6061 để được hỗ trợ trực tiếp."
# 3. Tổng hợp kết quả
knowledge_texts = []
for i, res in enumerate(results):
content = res.get("content", "")
knowledge_texts.append(content)
# LOG DỮ LIỆU LẤY ĐƯỢC (Chỉ hiển thị nội dung)
logger.info(f"📄 [Knowledge Chunk {i + 1}]: {content[:200]}...")
final_response = "\n\n---\n\n".join(knowledge_texts)
logger.info(f"✅ Found {len(results)} relevant knowledge chunks.")
return final_response
except Exception as e:
logger.error(f"❌ Error in canifa_knowledge_search: {e}")
return "Tôi đang gặp khó khăn khi truy cập kho kiến thức. Bạn muốn hỏi về sản phẩm gì khác không?"
"""
Tool thu thập thông tin khách hàng (Tên, Số điện thoại, Email)
Dùng để đẩy data về CRM hoặc hệ thống lưu trữ khách hàng.
"""
import json
import logging
from langchain_core.tools import tool
logger = logging.getLogger(__name__)
@tool
async def collect_customer_info(name: str, phone: str, email: str | None) -> str:
"""
Sử dụng tool này để ghi lại thông tin khách hàng khi họ muốn tư vấn sâu hơn,
nhận khuyến mãi hoặc đăng ký mua hàng.
Args:
name: Tên của khách hàng
phone: Số điện thoại của khách hàng
email: Email của khách hàng (không bắt buộc)
"""
try:
print(f"\n[TOOL] --- 📝 Thu thập thông tin khách hàng: {name} - {phone} ---")
logger.info(f"📝 Collecting customer info: {name}, {phone}, {email}")
# Giả lập việc đẩy data đi (CRM/Sheet)
# Trong thực tế, bạn sẽ gọi một API ở đây
db_record = {
"customer_name": name,
"phone_number": phone,
"email_address": email,
"status": "pending_consultation",
}
# Trả về kết quả thành công
return json.dumps(
{
"status": "success",
"message": (
f"Cảm ơn anh/chị {name}. CiCi đã ghi nhận thông tin và sẽ có nhân viên "
f"liên hệ tư vấn qua số điện thoại {phone} sớm nhất ạ!"
),
"data_captured": db_record,
},
ensure_ascii=False,
)
except Exception as e:
logger.error(f"❌ Lỗi khi thu thập thông tin: {e}")
return json.dumps(
{
"status": "error",
"message": f"Xin lỗi, CiCi gặp sự cố khi lưu thông tin. Anh/chị vui lòng thử lại sau ạ. Lỗi: {e!s}",
},
ensure_ascii=False,
)
"""
CANIFA Data Retrieval Tool - Tối giản cho Agentic Workflow.
Hỗ trợ Hybrid Search: Semantic (Vector) + Metadata Filter.
"""
import asyncio
import json
import logging
import time
from decimal import Decimal
from langchain_core.tools import tool
from pydantic import BaseModel, Field
from agent.tools.product_search_helpers import build_starrocks_query
from common.embedding_service import create_embeddings_async
from common.starrocks_connection import get_db_connection
# from langsmith import traceable
logger = logging.getLogger(__name__)
class DecimalEncoder(json.JSONEncoder):
"""Xử lý kiểu Decimal từ Database khi convert sang JSON."""
def default(self, obj):
if isinstance(obj, Decimal):
return float(obj)
return super().default(obj)
class SearchItem(BaseModel):
"""
Cấu trúc một mục tìm kiếm đơn lẻ trong Multi-Search.
Lưu ý quan trọng về cách SINH QUERY:
- Trường `query` KHÔNG phải câu hỏi thô của khách.
- Phải là một đoạn text có cấu trúc giống hệt format trong cột `description_text_full` của DB,
ví dụ (chỉ là 1 chuỗi duy nhất, nối các field bằng dấu chấm):
product_name: Pack 3 đôi tất bé gái cổ thấp. master_color: Xanh da trời/ Blue.
product_image_url: https://.... product_image_url_thumbnail: https://....
product_web_url: https://.... description_text: ... material: ...
material_group: Yarn - Sợi. gender_by_product: female. age_by_product: others.
season: Year. style: Feminine. fitting: Slim. size_scale: 4/6.
form_neckline: None. form_sleeve: None. product_line_vn: Tất.
product_color_name: Blue Strip 449.
- Khi khách chỉ nói “áo màu hồng”, hãy suy luận và sinh query dạng:
product_name: Áo thun/áo sơ mi/áo ... màu hồng ... . master_color: Hồng/ Pink.
product_image_url: None. product_image_url_thumbnail: None.
product_web_url: None. description_text: ... (mô tả thêm nếu có).
material: None. material_group: None. gender_by_product: ... (nếu đoán được).
age_by_product: others. season: Year. style: ... (nếu đoán được).
fitting: ... size_scale: None. form_neckline: None. form_sleeve: None.
product_line_vn: Áo. product_color_name: Pink / Hồng (nếu hợp lý).
- Nếu không suy luận được giá trị cho field nào thì để `None` hoặc bỏ trống phần text đó.
"""
query: str = Field(
...,
description=(
"ĐOẠN TEXT CÓ CẤU TRÚC theo format của cột description_text_full trong DB, "
"bao gồm các cặp key: product_name, master_color, product_image_url, "
"product_image_url_thumbnail, product_web_url, description_text, material, "
"material_group, gender_by_product, age_by_product, season, style, fitting, "
"size_scale, form_neckline, form_sleeve, product_line_vn, product_color_name. "
"Ví dụ: 'product_name: Pack 3 đôi tất bé gái cổ thấp. master_color: Xanh da trời/ Blue. "
"product_image_url: https://.... product_web_url: https://.... description_text: ... "
"material: None. material_group: Yarn - Sợi. gender_by_product: female. ...'"
),
)
magento_ref_code: str | None = Field(
..., description="Mã sản phẩm hoặc SKU (Ví dụ: 8TS24W001). CHỈ điền khi khách hỏi mã code cụ thể."
)
price_min: float | None = Field(..., description="Giá thấp nhất (VD: 100000)")
price_max: float | None = Field(..., description="Giá cao nhất (VD: 500000)")
action: str = Field(..., description="Hành động: 'search' (tìm kiếm) hoặc 'visual_search' (phân tích ảnh)")
class MultiSearchParams(BaseModel):
"""Tham số cho Parallel Multi-Search."""
searches: list[SearchItem] = Field(..., description="Danh sách các truy vấn tìm kiếm chạy song song")
@tool(args_schema=MultiSearchParams)
# @traceable(run_type="tool", name="data_retrieval_tool")
async def data_retrieval_tool(searches: list[SearchItem]) -> str:
"""
Siêu công cụ tìm kiếm sản phẩm CANIFA - Hỗ trợ Parallel Multi-Search (chạy song song nhiều truy vấn).
Hướng dẫn dùng nhanh:
- Trường 'query': mô tả chi tiết sản phẩm (tên, chất liệu, giới tính, màu sắc, phong cách, dịp sử dụng), không dùng câu hỏi thô.
- Trường 'magento_ref_code': chỉ dùng khi khách hỏi mã sản phẩm/SKU cụ thể (vd: 8TS24W001).
- Trường 'price_min' / 'price_max': dùng khi khách nói về khoảng giá (vd: dưới 500k, từ 200k đến 400k).
"""
logger.info("data_retrieval_tool started, searches=%s", len(searches))
try:
# 0. Log input tổng quan (không log chi tiết dài)
for idx, item in enumerate(searches):
short_query = (item.query[:60] + "...") if item.query and len(item.query) > 60 else item.query
logger.debug(
"search[%s] query=%r, code=%r, price_min=%r, price_max=%r",
idx,
short_query,
item.magento_ref_code,
item.price_min,
item.price_max,
)
queries_to_embed = [s.query for s in searches if s.query]
all_vectors = []
if queries_to_embed:
logger.info("batch embedding %s queries", len(queries_to_embed))
emb_batch_start = time.time()
all_vectors = await create_embeddings_async(queries_to_embed)
logger.info(
"batch embedding done in %.2f ms",
(time.time() - emb_batch_start) * 1000,
)
# 2. Get DB connection (singleton)
db = get_db_connection()
tasks = []
vector_idx = 0
for item in searches:
current_vector = None
if item.query:
if vector_idx < len(all_vectors):
current_vector = all_vectors[vector_idx]
vector_idx += 1
tasks.append(_execute_single_search(db, item, query_vector=current_vector))
results = await asyncio.gather(*tasks)
# 3. Tổng hợp kết quả
combined_results = []
for i, products in enumerate(results):
combined_results.append(
{
"search_index": i,
"search_criteria": searches[i].dict(exclude_none=True),
"count": len(products),
"products": products,
}
)
logger.info("data_retrieval_tool finished, results=%s", len(combined_results))
return json.dumps(
{"status": "success", "results": combined_results},
ensure_ascii=False,
cls=DecimalEncoder,
)
except Exception as e:
logger.exception("Error in Multi-Search data_retrieval_tool: %s", e)
return json.dumps({"status": "error", "message": str(e)})
async def _execute_single_search(db, item: SearchItem, query_vector: list[float] | None = None) -> list[dict]:
"""Thực thi một search query đơn lẻ (Async)."""
try:
short_query = (item.query[:60] + "...") if item.query and len(item.query) > 60 else item.query
logger.debug(
"_execute_single_search started, query=%r, code=%r",
short_query,
item.magento_ref_code,
)
# Timer: build query (sử dụng vector đã có hoặc build mới)
query_build_start = time.time()
sql = await build_starrocks_query(item, query_vector=query_vector)
query_build_time = (time.time() - query_build_start) * 1000 # Convert to ms
logger.debug("SQL built, length=%s, build_time_ms=%.2f", len(sql), query_build_time)
# Timer: execute DB query
db_start = time.time()
products = await db.execute_query_async(sql)
db_time = (time.time() - db_start) * 1000 # Convert to ms
logger.info(
"_execute_single_search done, products=%s, build_ms=%.2f, db_ms=%.2f, total_ms=%.2f",
len(products),
query_build_time,
db_time,
query_build_time + db_time,
)
return _format_product_results(products)
except Exception as e:
logger.exception("Single search error for item %r: %s", item, e)
return []
def _format_product_results(products: list[dict]) -> list[dict]:
"""Lọc và format kết quả trả về cho Agent."""
max_items = 15
formatted: list[dict] = []
for p in products[:max_items]:
formatted.append(
{
"internal_ref_code": p.get("internal_ref_code"),
# Chuỗi text dài, đã bao gồm: product_name, master_color, image, web_url, material, style, ...
"description_text": p.get("description_text_full"),
"sale_price": p.get("sale_price"),
"original_price": p.get("original_price"),
"discount_amount": p.get("discount_amount"),
"max_score": p.get("max_score"),
}
)
return formatted
import logging
import time
from common.embedding_service import create_embedding_async
logger = logging.getLogger(__name__)
def _escape(val: str) -> str:
"""Thoát dấu nháy đơn để tránh SQL Injection cơ bản."""
return val.replace("'", "''")
def _get_where_clauses(params) -> list[str]:
"""Xây dựng danh sách các điều kiện lọc từ params."""
clauses = []
clauses.extend(_get_price_clauses(params))
clauses.extend(_get_metadata_clauses(params))
clauses.extend(_get_special_clauses(params))
return clauses
def _get_price_clauses(params) -> list[str]:
"""Lọc theo giá."""
clauses = []
p_min = getattr(params, "price_min", None)
if p_min is not None:
clauses.append(f"sale_price >= {p_min}")
p_max = getattr(params, "price_max", None)
if p_max is not None:
clauses.append(f"sale_price <= {p_max}")
return clauses
def _get_metadata_clauses(params) -> list[str]:
"""Xây dựng điều kiện lọc từ metadata (Phối hợp Exact và Partial)."""
clauses = []
# 1. Exact Match (Giới tính, Độ tuổi) - Các trường này cần độ chính xác tuyệt đối
exact_fields = [
("gender_by_product", "gender_by_product"),
("age_by_product", "age_by_product"),
]
for param_name, col_name in exact_fields:
val = getattr(params, param_name, None)
if val:
clauses.append(f"{col_name} = '{_escape(val)}'")
# 2. Partial Match (LIKE) - Giúp map text linh hoạt hơn (Chất liệu, Dòng SP, Phong cách...)
# Cái này giúp map: "Yarn" -> "Yarn - Sợi", "Knit" -> "Knit - Dệt Kim"
partial_fields = [
("season", "season"),
("material_group", "material_group"),
("product_line_vn", "product_line_vn"),
("style", "style"),
("fitting", "fitting"),
("form_neckline", "form_neckline"),
("form_sleeve", "form_sleeve"),
]
for param_name, col_name in partial_fields:
val = getattr(params, param_name, None)
if val:
v = _escape(val).lower()
# Dùng LOWER + LIKE để cân mọi loại ký tự thừa hoặc hoa/thường
clauses.append(f"LOWER({col_name}) LIKE '%{v}%'")
return clauses
def _get_special_clauses(params) -> list[str]:
"""Các trường hợp đặc biệt: Mã sản phẩm, Màu sắc."""
clauses = []
# Mã sản phẩm / SKU
m_code = getattr(params, "magento_ref_code", None)
if m_code:
m = _escape(m_code)
clauses.append(f"(magento_ref_code = '{m}' OR internal_ref_code = '{m}')")
# Màu sắc
color = getattr(params, "master_color", None)
if color:
c = _escape(color).lower()
clauses.append(f"(LOWER(master_color) LIKE '%{c}%' OR LOWER(product_color_name) LIKE '%{c}%')")
return clauses
async def build_starrocks_query(params, query_vector: list[float] | None = None) -> str:
"""
Build SQL cho Product Search với 2 chiến lược:
1. CODE SEARCH: Nếu có magento_ref_code → Tìm trực tiếp theo mã (KHÔNG dùng vector)
2. HYDE SEARCH: Semantic search với HyDE vector (Pure vector approach)
"""
# ============================================================
# CASE 1: CODE SEARCH - Tìm theo mã sản phẩm (No Vector)
# ============================================================
magento_code = getattr(params, "magento_ref_code", None)
if magento_code:
logger.info(f"🎯 [CODE SEARCH] Direct search by code: {magento_code}")
code = _escape(magento_code)
# Tìm trực tiếp theo mã + Lọc trùng (GROUP BY internal_ref_code)
# Tìm chính xác theo mã (Lấy tất cả các bản ghi/màu sắc/size của mã đó)
sql = f"""
SELECT
internal_ref_code,
description_text_full,
sale_price,
original_price,
discount_amount,
1.0 as max_score
FROM shared_source.magento_product_dimension_with_text_embedding
WHERE (magento_ref_code = '{code}' OR internal_ref_code = '{code}')
"""
print("✅ [CODE SEARCH] Query built - No vector search needed!")
# Ghi log debug query FULL vào Background Task (Không làm chậm Request)
# asyncio.create_task(save_query_to_log(sql))
return sql
# ============================================================
# CASE 2: HYDE SEARCH - Semantic Vector Search
# ============================================================
logger.info("🚀 [HYDE RETRIEVER] Starting semantic vector search...")
# 1. Lấy Vector từ HyDE (AI-generated hypothetical document)
query_text = getattr(params, "query", None)
if query_text and query_vector is None:
emb_start = time.time()
query_vector = await create_embedding_async(query_text)
logger.info(f"⏱️ [TIMER] Single HyDE Embedding: {(time.time() - emb_start) * 1000:.2f}ms")
if not query_vector:
logger.warning("⚠️ No vector found, returning empty query.")
return ""
v_str = "[" + ",".join(str(v) for v in query_vector) + "]"
# 2. Build PRICE filter ONLY (chỉ lọc giá, để vector tự semantic search)
price_clauses = _get_price_clauses(params)
where_filter = ""
if price_clauses:
where_filter = " AND " + " AND ".join(price_clauses)
logger.info(f"💰 [PRICE FILTER] Applied: {where_filter}")
# 3. SQL Pure Vector Search + Price Filter Only
sql = f"""
WITH top_matches AS (
SELECT /*+ SET_VAR(ann_params='{{"ef_search":128}}') */
internal_ref_code,
product_color_code,
description_text_full,
sale_price,
original_price,
discount_amount,
approx_cosine_similarity(vector, {v_str}) as similarity_score
FROM shared_source.magento_product_dimension_with_text_embedding
ORDER BY similarity_score DESC
LIMIT 100
)
SELECT
internal_ref_code,
MAX_BY(description_text_full, similarity_score) as description_text_full,
MAX_BY(sale_price, similarity_score) as sale_price,
MAX_BY(original_price, similarity_score) as original_price,
MAX_BY(discount_amount, similarity_score) as discount_amount,
MAX(similarity_score) as max_score
FROM top_matches
WHERE 1=1 {where_filter}
GROUP BY internal_ref_code
ORDER BY max_score DESC
LIMIT 20
"""
return sql
# ============================================================
# TEMPORARILY COMMENTED OUT - save_query_to_log
# ============================================================
# async def save_query_to_log(sql: str):
# """Lưu query full vào file hyde_pure_query.txt."""
# import os
# log_path = r"D:\cnf\chatbot_canifa\backend\logs\hyde_pure_query.txt"
# try:
# log_dir = os.path.dirname(log_path)
# if not os.path.exists(log_dir):
# os.makedirs(log_dir)
# with open(log_path, "w", encoding="utf-8") as f:
# f.write(sql)
# print(f"💾 Full Query saved to: {log_path}")
# except Exception as e:
# print(f"Save query log failed: {e}")
# ============================================================
# TEMPORARILY COMMENTED OUT - save_preview_to_log
# ============================================================
# async def save_preview_to_log(search_query: str, products: list[dict]):
# """Lưu kết quả DB trả về vào db_preview.txt (Format đẹp cho AI)."""
# import os
# preview_path = r"D:\cnf\chatbot_canifa\backend\logs\db_preview.txt"
# try:
# log_dir = os.path.dirname(preview_path)
# if not os.path.exists(log_dir):
# os.makedirs(log_dir)
#
# with open(preview_path, "a", encoding="utf-8") as f:
# f.write(f"\n{'='*60}\n")
# f.write(f"⏰ TIME: {time.strftime('%Y-%m-%d %H:%M:%S')}\n")
# f.write(f"🔍 SEARCH: {search_query}\n")
# f.write(f"📊 RESULTS COUNT: {len(products)}\n")
# f.write(f"{'-'*60}\n")
#
# if not products:
# f.write("❌ NO PRODUCTS FOUND\n")
# else:
# for idx, p in enumerate(products[:5], 1):
# code = p.get("internal_ref_code", "N/A")
# sale = p.get("sale_price", "N/A")
# orig = p.get("original_price", "N/A")
# disc = p.get("discount_amount", "0")
# score = p.get("max_score", p.get("similarity_score", "N/A"))
# desc = p.get("description_text_full", "No Description")
#
# f.write(f"{idx}. [{code}] Score: {score}\n")
# f.write(f" 💰 Price: {sale} (Orig: {orig}, Disc: {disc}%)\n")
# f.write(f" 📝 Desc: {desc}\n")
#
# f.write(f"{'='*60}\n")
# print(f"💾 DB Preview (Results) saved to: {preview_path}")
# except Exception as e:
# print(f"Save preview log failed: {e}")
...@@ -9,6 +9,7 @@ from .shortcut_routes import router as shortcut_router ...@@ -9,6 +9,7 @@ from .shortcut_routes import router as shortcut_router
from .activity_routes import router as activity_router from .activity_routes import router as activity_router
from .idp_routes import router as idp_router from .idp_routes import router as idp_router
from .embedding_routes import router as embedding_router from .embedding_routes import router as embedding_router
from .search_routes import router as search_router
router = APIRouter(prefix="/api/v1") router = APIRouter(prefix="/api/v1")
...@@ -17,6 +18,9 @@ router = APIRouter(prefix="/api/v1") ...@@ -17,6 +18,9 @@ router = APIRouter(prefix="/api/v1")
router.include_router(instance_router, tags=["instance"]) router.include_router(instance_router, tags=["instance"])
router.include_router(auth_router, tags=["auth"]) router.include_router(auth_router, tags=["auth"])
router.include_router(user_router, tags=["users"]) router.include_router(user_router, tags=["users"])
# search_router MUST come before memo_router
# because memo_router has /{memo_id} which would catch /search as a memo_id
router.include_router(search_router, tags=["search"])
router.include_router(memo_router, tags=["memos"]) router.include_router(memo_router, tags=["memos"])
router.include_router(attachment_router, tags=["attachments"]) router.include_router(attachment_router, tags=["attachments"])
router.include_router(shortcut_router, tags=["shortcuts"]) router.include_router(shortcut_router, tags=["shortcuts"])
...@@ -25,3 +29,4 @@ router.include_router(idp_router, tags=["idp"]) ...@@ -25,3 +29,4 @@ router.include_router(idp_router, tags=["idp"])
router.include_router(embedding_router, tags=["memo-embeddings"]) router.include_router(embedding_router, tags=["memo-embeddings"])
"""
Search API routes — Meilisearch keyword search for memos.
"""
import logging
from fastapi import APIRouter, Query, Request
from common.meili_service import meili_service
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/memos")
def get_current_user_id(request: Request) -> str | None:
return getattr(request.state, "user_id", None)
@router.get("/search", summary="Search memos by keyword")
async def search_memos(
request: Request,
q: str = Query(..., min_length=1, description="Search query"),
limit: int = Query(default=20, ge=1, le=100, description="Max results"),
):
"""
Full-text keyword search on memos using Meilisearch.
- Typo-tolerant
- Search-as-you-type ready
- Filtered by current user (multi-tenant)
"""
user_id = get_current_user_id(request)
if not meili_service.enabled:
return {"hits": [], "query": q, "message": "Search service unavailable"}
hits = meili_service.search(
query=q,
creator_id=user_id,
limit=limit,
)
return {
"hits": hits,
"query": q,
"total": len(hits),
}
This diff is collapsed.
...@@ -26,7 +26,7 @@ EMBEDDING_KEY_PREFIX = "emb_cache:" ...@@ -26,7 +26,7 @@ EMBEDDING_KEY_PREFIX = "emb_cache:"
class RedisClient: class RedisClient:
""" """
Hybrid Cache Client for Canifa Chatbot. Hybrid Cache Client for CuCu Chatbot.
Layer 1: Exact Response Cache (Short TTL) Layer 1: Exact Response Cache (Short TTL)
Layer 2: Embedding Cache (Long TTL) Layer 2: Embedding Cache (Long TTL)
""" """
......
...@@ -16,7 +16,7 @@ logger = logging.getLogger(__name__) ...@@ -16,7 +16,7 @@ logger = logging.getLogger(__name__)
def _jwks_client() -> PyJWKClient: def _jwks_client() -> PyJWKClient:
if not CLERK_JWKS_URL: if not CLERK_JWKS_URL:
raise ValueError("CLERK_JWKS_URL is not configured") raise ValueError("CLERK_JWKS_URL is not configured")
return PyJWKClient(CLERK_JWKS_URL) return PyJWKClient(CLERK_JWKS_URL, cache_jwk_set=True, lifespan=300)
def verify_clerk_jwt(token: str) -> dict[str, Any]: def verify_clerk_jwt(token: str) -> dict[str, Any]:
......
"""
Meilisearch Search Service for CuCu Note.
Provides instant keyword search on memos without embeddings.
- Sync memo CRUD → Meilisearch index
- Search by keyword with typo tolerance
- Filter by creator_id for multi-tenant isolation
"""
from __future__ import annotations
import logging
from typing import Any
import meilisearch
from meilisearch.errors import MeilisearchApiError
from config import MEILI_URL, MEILI_MASTER_KEY
logger = logging.getLogger(__name__)
INDEX_NAME = "memos"
class MeiliService:
"""Singleton Meilisearch service for memo search."""
_instance: MeiliService | None = None
_client: meilisearch.Client | None = None
_enabled: bool = False
def __new__(cls) -> MeiliService:
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
async def initialize(self) -> bool:
"""Connect to Meilisearch and configure the memos index."""
if not MEILI_URL or not MEILI_MASTER_KEY:
logger.warning("⚠️ MEILI_URL or MEILI_MASTER_KEY not set — search disabled")
self._enabled = False
return False
try:
self._client = meilisearch.Client(MEILI_URL, MEILI_MASTER_KEY)
# Test connection
self._client.health()
# Create or get index
try:
self._client.create_index(INDEX_NAME, {"primaryKey": "id"})
logger.info("✅ Created Meilisearch index: %s", INDEX_NAME)
except MeilisearchApiError:
pass # Index already exists
# Configure index settings
index = self._client.index(INDEX_NAME)
index.update_settings({
"searchableAttributes": ["content", "tags"],
"filterableAttributes": ["creator_id", "visibility", "pinned", "row_status"],
"sortableAttributes": ["created_at_ts"],
"displayedAttributes": ["id", "uid", "content", "tags", "creator_id",
"visibility", "pinned", "row_status",
"created_at", "updated_at"],
})
self._enabled = True
logger.info("✅ Meilisearch connected: %s", MEILI_URL)
return True
except Exception as e:
logger.warning("⚠️ Meilisearch unavailable: %s — search disabled", e)
self._enabled = False
return False
@property
def enabled(self) -> bool:
return self._enabled
def _memo_to_document(self, doc: dict[str, Any]) -> dict[str, Any]:
"""Convert MongoDB memo doc to Meilisearch document."""
created_at = doc.get("created_at")
updated_at = doc.get("updated_at")
# Meilisearch needs a string id and numeric timestamp for sorting
meili_doc = {
"id": str(doc["_id"]),
"uid": doc.get("uid", ""),
"content": doc.get("content", ""),
"tags": doc.get("payload", {}).get("tags", []),
"creator_id": doc.get("creator_id", "anonymous"),
"visibility": doc.get("visibility", "PRIVATE"),
"pinned": doc.get("pinned", False),
"row_status": doc.get("row_status", "NORMAL"),
"created_at": str(created_at) if created_at else "",
"updated_at": str(updated_at) if updated_at else "",
"created_at_ts": int(created_at.timestamp()) if created_at else 0,
}
return meili_doc
def index_memo(self, doc: dict[str, Any]) -> None:
"""Add or update a memo in the search index."""
if not self._enabled or not self._client:
return
try:
meili_doc = self._memo_to_document(doc)
self._client.index(INDEX_NAME).add_documents([meili_doc])
logger.debug("🔍 Indexed memo: %s", meili_doc["id"])
except Exception as e:
logger.warning("⚠️ Meili index error: %s", e)
def delete_memo(self, memo_id: str) -> None:
"""Remove a memo from the search index."""
if not self._enabled or not self._client:
return
try:
self._client.index(INDEX_NAME).delete_document(memo_id)
logger.debug("🗑️ Deleted memo from index: %s", memo_id)
except Exception as e:
logger.warning("⚠️ Meili delete error: %s", e)
def search(
self,
query: str,
creator_id: str | None = None,
limit: int = 20,
) -> list[dict[str, Any]]:
"""
Search memos by keyword.
Returns list of matching memo documents from Meilisearch.
"""
if not self._enabled or not self._client:
return []
try:
search_params: dict[str, Any] = {
"limit": limit,
"sort": ["created_at_ts:desc"],
}
# Filter by creator for multi-tenant isolation
filters = []
if creator_id:
filters.append(f'creator_id = "{creator_id}"')
filters.append('row_status = "NORMAL"')
search_params["filter"] = " AND ".join(filters)
result = self._client.index(INDEX_NAME).search(query, search_params)
return result.get("hits", [])
except Exception as e:
logger.warning("⚠️ Meili search error: %s", e)
return []
async def sync_all_memos(self) -> int:
"""
Bulk index all memos from MongoDB into Meilisearch.
Called on startup to ensure index is up-to-date.
Returns count of synced documents.
"""
if not self._enabled or not self._client:
return 0
try:
from common.mongo_client import mongodb_client
cursor = mongodb_client.memos.find(
{"parent": {"$exists": False}}, # Only parent memos, not comments
)
docs = await cursor.to_list(length=10000)
if not docs:
logger.info("🔍 No memos to sync to Meilisearch")
return 0
meili_docs = [self._memo_to_document(doc) for doc in docs]
# Batch add (Meilisearch handles batching internally)
self._client.index(INDEX_NAME).add_documents(meili_docs)
logger.info("✅ Synced %d memos to Meilisearch", len(meili_docs))
return len(meili_docs)
except Exception as e:
logger.warning("⚠️ Meili sync error: %s", e)
return 0
# Singleton
meili_service = MeiliService()
...@@ -299,7 +299,15 @@ class MemoService: ...@@ -299,7 +299,15 @@ class MemoService:
result = await mongodb_client.memos.insert_one(doc) result = await mongodb_client.memos.insert_one(doc)
doc["_id"] = result.inserted_id doc["_id"] = result.inserted_id
await self._create_embedding(str(result.inserted_id), payload.content, payload.tags or [], user_id=user_id) import asyncio
asyncio.create_task(self._create_embedding(str(result.inserted_id), payload.content, payload.tags or [], user_id=user_id))
# Sync to Meilisearch
try:
from common.meili_service import meili_service
meili_service.index_memo(doc)
except Exception:
pass # Search down ≠ app down
return self._doc_to_response(doc) return self._doc_to_response(doc)
...@@ -479,12 +487,22 @@ class MemoService: ...@@ -479,12 +487,22 @@ class MemoService:
) )
if payload.content is not None: if payload.content is not None:
await self._create_embedding( import asyncio
asyncio.create_task(self._create_embedding(
str(doc["_id"]), str(doc["_id"]),
payload.content, payload.content,
payload.tags or doc.get("payload", {}).get("tags", []), payload.tags or doc.get("payload", {}).get("tags", []),
user_id=user_id, user_id=user_id,
) ))
# Sync updated memo to Meilisearch
try:
from common.meili_service import meili_service
updated = await mongodb_client.memos.find_one({"_id": doc["_id"]})
if updated:
meili_service.index_memo(updated)
except Exception:
pass # Search down ≠ app down
return await self.get_memo(str(doc["_id"]), user_id=user_id) return await self.get_memo(str(doc["_id"]), user_id=user_id)
...@@ -517,6 +535,13 @@ class MemoService: ...@@ -517,6 +535,13 @@ class MemoService:
) )
await mongodb_client.reactions.delete_many({"content_id": str(doc["_id"])}) await mongodb_client.reactions.delete_many({"content_id": str(doc["_id"])})
# Remove from Meilisearch
try:
from common.meili_service import meili_service
meili_service.delete_memo(str(doc["_id"]))
except Exception:
pass # Search down ≠ app down
async def pin_memo(self, memo_id: str, pinned: bool, user_id: str | None = None) -> schemas.MemoResponse: async def pin_memo(self, memo_id: str, pinned: bool, user_id: str | None = None) -> schemas.MemoResponse:
"""Pin or unpin a memo.""" """Pin or unpin a memo."""
return await self.update_memo( return await self.update_memo(
......
...@@ -5,11 +5,9 @@ Singleton Pattern cho cả 2 services ...@@ -5,11 +5,9 @@ Singleton Pattern cho cả 2 services
from __future__ import annotations from __future__ import annotations
import logging import logging
from collections.abc import Callable
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from fastapi import Request from fastapi import Request
from starlette.middleware.base import BaseHTTPMiddleware
from common.clerk_auth import verify_clerk_jwt from common.clerk_auth import verify_clerk_jwt
from config import DISABLE_AUTH from config import DISABLE_AUTH
...@@ -50,37 +48,51 @@ RATE_LIMITED_PATHS = [ ...@@ -50,37 +48,51 @@ RATE_LIMITED_PATHS = [
"/api/agent/chat", "/api/agent/chat",
] ]
class CanifaAuthMiddleware(BaseHTTPMiddleware): class CuCuAuthMiddleware:
""" """
Authentication + Rate Limit Middleware Pure ASGI Authentication + Rate Limit Middleware.
Replaces BaseHTTPMiddleware to avoid run_in_threadpool overhead (2-5ms/req).
Flow: Flow:
1. Frontend gửi request với Authorization: Bearer <canifa_token> 1. Frontend gửi request với Authorization: Bearer <cucu_token>
2. Middleware verify token với Canifa API → extract customer_id 2. Middleware verify token với Clerk → extract customer_id
3. Check message rate limit (Guest: 10, User: 100) 3. Check message rate limit (Guest: 10, User: 100)
4. Attach user info vào request.state 4. Attach user info vào request.state
5. Routes lấy trực tiếp từ request.state 5. Routes lấy trực tiếp từ request.state
""" """
async def dispatch(self, request: Request, call_next: Callable): def __init__(self, app):
self.app = app
async def __call__(self, scope, receive, send):
if scope["type"] != "http":
await self.app(scope, receive, send)
return
request = Request(scope)
path = request.url.path path = request.url.path
method = request.method method = request.method
# Temporary bypass: skip auth/rate-limit when DISABLE_AUTH=true # Temporary bypass: skip auth/rate-limit when DISABLE_AUTH=true
if DISABLE_AUTH: if DISABLE_AUTH:
return await call_next(request) await self.app(scope, receive, send)
return
# ✅ Allow OPTIONS requests (CORS preflight) # ✅ Allow OPTIONS requests (CORS preflight)
if method == "OPTIONS": if method == "OPTIONS":
return await call_next(request) await self.app(scope, receive, send)
return
# Skip public endpoints # Skip public endpoints
if path in PUBLIC_PATHS: if path in PUBLIC_PATHS:
return await call_next(request) await self.app(scope, receive, send)
return
# Skip public path prefixes # Skip public path prefixes
if any(path.startswith(prefix) for prefix in PUBLIC_PATH_PREFIXES): if any(path.startswith(prefix) for prefix in PUBLIC_PATH_PREFIXES):
return await call_next(request) await self.app(scope, receive, send)
return
# ===================================================================== # =====================================================================
# STEP 1: AUTHENTICATION (Clerk JWT) # STEP 1: AUTHENTICATION (Clerk JWT)
...@@ -91,10 +103,10 @@ class CanifaAuthMiddleware(BaseHTTPMiddleware): ...@@ -91,10 +103,10 @@ class CanifaAuthMiddleware(BaseHTTPMiddleware):
# --- TRƯỜNG HỢP 1: KHÔNG CÓ TOKEN -> GUEST --- # --- TRƯỜNG HỢP 1: KHÔNG CÓ TOKEN -> GUEST ---
if not auth_header or not auth_header.startswith("Bearer "): if not auth_header or not auth_header.startswith("Bearer "):
request.state.user = None scope["state"]["user"] = None
request.state.user_id = None scope["state"]["user_id"] = None
request.state.is_authenticated = False scope["state"]["is_authenticated"] = False
request.state.device_id = device_id scope["state"]["device_id"] = device_id
else: else:
# --- TRƯỜNG HỢP 2: CÓ TOKEN -> VERIFY CLERK JWT --- # --- TRƯỜNG HỢP 2: CÓ TOKEN -> VERIFY CLERK JWT ---
token = auth_header.replace("Bearer ", "") token = auth_header.replace("Bearer ", "")
...@@ -102,31 +114,31 @@ class CanifaAuthMiddleware(BaseHTTPMiddleware): ...@@ -102,31 +114,31 @@ class CanifaAuthMiddleware(BaseHTTPMiddleware):
payload = verify_clerk_jwt(token) payload = verify_clerk_jwt(token)
user_id = payload.get("sub") user_id = payload.get("sub")
if user_id: if user_id:
request.state.user = payload scope["state"]["user"] = payload
request.state.user_id = str(user_id) scope["state"]["user_id"] = str(user_id)
request.state.token = token scope["state"]["token"] = token
request.state.is_authenticated = True scope["state"]["is_authenticated"] = True
request.state.device_id = device_id or str(user_id) scope["state"]["device_id"] = device_id or str(user_id)
logger.debug("✅ Clerk Auth Success: user_id=%s", user_id) logger.debug("✅ Clerk Auth Success: user_id=%s", user_id)
else: else:
logger.warning("⚠️ Clerk token missing sub -> Guest Mode") logger.warning("⚠️ Clerk token missing sub -> Guest Mode")
request.state.user = None scope["state"]["user"] = None
request.state.user_id = None scope["state"]["user_id"] = None
request.state.is_authenticated = False scope["state"]["is_authenticated"] = False
request.state.device_id = device_id scope["state"]["device_id"] = device_id
except Exception as e: except Exception as e:
logger.error("❌ Clerk Auth Error: %s -> Guest Mode", e) logger.error("❌ Clerk Auth Error: %s -> Guest Mode", e)
request.state.user = None scope["state"]["user"] = None
request.state.user_id = None scope["state"]["user_id"] = None
request.state.is_authenticated = False scope["state"]["is_authenticated"] = False
request.state.device_id = device_id scope["state"]["device_id"] = device_id
except Exception as e: except Exception as e:
logger.error(f"❌ Middleware Auth Error: {e}") logger.error(f"❌ Middleware Auth Error: {e}")
request.state.user = None scope["state"]["user"] = None
request.state.user_id = None scope["state"]["user_id"] = None
request.state.is_authenticated = False scope["state"]["is_authenticated"] = False
request.state.device_id = request.headers.get("device_id", "") scope["state"]["device_id"] = request.headers.get("device_id", "")
# ===================================================================== # =====================================================================
# STEP 2: RATE LIMIT CHECK (Chỉ cho các path cần limit) # STEP 2: RATE LIMIT CHECK (Chỉ cho các path cần limit)
...@@ -137,13 +149,11 @@ class CanifaAuthMiddleware(BaseHTTPMiddleware): ...@@ -137,13 +149,11 @@ class CanifaAuthMiddleware(BaseHTTPMiddleware):
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
# Lấy identity_key làm rate limit key # Lấy identity_key làm rate limit key
# Guest: device_id → limit 10 is_authenticated = scope["state"].get("is_authenticated", False)
# User: user_id → limit 100 if is_authenticated and scope["state"].get("user_id"):
is_authenticated = request.state.is_authenticated rate_limit_key = scope["state"]["user_id"]
if is_authenticated and request.state.user_id:
rate_limit_key = request.state.user_id
else: else:
rate_limit_key = request.state.device_id rate_limit_key = scope["state"].get("device_id", "")
if rate_limit_key: if rate_limit_key:
can_send, limit_info = await message_limit_service.check_limit( can_send, limit_info = await message_limit_service.check_limit(
...@@ -151,8 +161,8 @@ class CanifaAuthMiddleware(BaseHTTPMiddleware): ...@@ -151,8 +161,8 @@ class CanifaAuthMiddleware(BaseHTTPMiddleware):
is_authenticated=is_authenticated, is_authenticated=is_authenticated,
) )
# Lưu limit_info vào request.state để route có thể dùng # Lưu limit_info vào state để route có thể dùng
request.state.limit_info = limit_info scope["state"]["limit_info"] = limit_info
if not can_send: if not can_send:
logger.warning( logger.warning(
...@@ -161,7 +171,7 @@ class CanifaAuthMiddleware(BaseHTTPMiddleware): ...@@ -161,7 +171,7 @@ class CanifaAuthMiddleware(BaseHTTPMiddleware):
limit_info["used"], limit_info["used"],
limit_info["limit"], limit_info["limit"],
) )
return JSONResponse( response = JSONResponse(
status_code=429, status_code=429,
content={ content={
"status": "error", "status": "error",
...@@ -176,6 +186,8 @@ class CanifaAuthMiddleware(BaseHTTPMiddleware): ...@@ -176,6 +186,8 @@ class CanifaAuthMiddleware(BaseHTTPMiddleware):
}, },
}, },
) )
await response(scope, receive, send)
return
else: else:
logger.warning(f"⚠️ No identity_key for rate limiting") logger.warning(f"⚠️ No identity_key for rate limiting")
...@@ -183,7 +195,7 @@ class CanifaAuthMiddleware(BaseHTTPMiddleware): ...@@ -183,7 +195,7 @@ class CanifaAuthMiddleware(BaseHTTPMiddleware):
logger.error(f"❌ Rate Limit Check Error: {e}") logger.error(f"❌ Rate Limit Check Error: {e}")
# Cho phép request tiếp tục nếu lỗi rate limit # Cho phép request tiếp tục nếu lỗi rate limit
return await call_next(request) await self.app(scope, receive, send)
# ============================================================================= # =============================================================================
...@@ -234,7 +246,7 @@ class MiddlewareManager: ...@@ -234,7 +246,7 @@ class MiddlewareManager:
Args: Args:
app: FastAPI application app: FastAPI application
enable_auth: Bật Canifa authentication middleware enable_auth: Bật CuCu authentication middleware
enable_rate_limit: Bật rate limiting enable_rate_limit: Bật rate limiting
enable_cors: Bật CORS middleware enable_cors: Bật CORS middleware
cors_origins: List origins cho CORS (default: ["*"]) cors_origins: List origins cho CORS (default: ["*"])
...@@ -274,10 +286,10 @@ class MiddlewareManager: ...@@ -274,10 +286,10 @@ class MiddlewareManager:
logger.info(f"✅ CORS middleware enabled (origins: {origins})") logger.info(f"✅ CORS middleware enabled (origins: {origins})")
def _setup_auth(self, app: FastAPI) -> None: def _setup_auth(self, app: FastAPI) -> None:
"""Setup Canifa auth middleware.""" """Setup CuCu auth middleware."""
app.add_middleware(CanifaAuthMiddleware) app.add_middleware(CuCuAuthMiddleware)
self._auth_enabled = True self._auth_enabled = True
logger.info("✅ Canifa Auth middleware enabled") logger.info("✅ CuCu Auth middleware enabled")
def _setup_rate_limit(self, app: FastAPI) -> None: def _setup_rate_limit(self, app: FastAPI) -> None:
"""Setup rate limiting.""" """Setup rate limiting."""
......
...@@ -30,6 +30,12 @@ COLLECTION_MEMO_EMBEDDINGS = "cuccu_memo_embeddings" ...@@ -30,6 +30,12 @@ COLLECTION_MEMO_EMBEDDINGS = "cuccu_memo_embeddings"
COLLECTION_INBOX = "cuccu_inbox" COLLECTION_INBOX = "cuccu_inbox"
COLLECTION_USER_SETTINGS = "cuccu_user_settings" COLLECTION_USER_SETTINGS = "cuccu_user_settings"
COLLECTION_SHORTCUTS = "cuccu_shortcuts" COLLECTION_SHORTCUTS = "cuccu_shortcuts"
COLLECTION_TEAMS = "cuccu_teams"
COLLECTION_TEAM_MEMBERS = "cuccu_team_members"
COLLECTION_TEAM_MEMOS = "cuccu_team_memos"
COLLECTION_TEAM_COMMENTS = "cuccu_team_comments"
COLLECTION_TEAM_REACTIONS = "cuccu_team_reactions"
COLLECTION_USER_PROFILES = "cuccu_user_profiles"
class MongoDBClient: class MongoDBClient:
...@@ -51,10 +57,11 @@ class MongoDBClient: ...@@ -51,10 +57,11 @@ class MongoDBClient:
if not MONGODB_URI: if not MONGODB_URI:
raise ValueError("MONGODB_URI environment variable is required") raise ValueError("MONGODB_URI environment variable is required")
# Connection pooling configuration from environment # Use config values (sensible defaults for Atlas free tier)
max_pool_size = int(os.getenv("MONGODB_MAX_POOL_SIZE", "50")) from config import MONGODB_MAX_POOL_SIZE, MONGODB_MIN_POOL_SIZE, MONGODB_MAX_IDLE_TIME_MS
min_pool_size = int(os.getenv("MONGODB_MIN_POOL_SIZE", "10")) max_pool_size = MONGODB_MAX_POOL_SIZE or 10
max_idle_time_ms = int(os.getenv("MONGODB_MAX_IDLE_TIME_MS", "45000")) min_pool_size = MONGODB_MIN_POOL_SIZE or 2
max_idle_time_ms = MONGODB_MAX_IDLE_TIME_MS or 45000
self._client = AsyncIOMotorClient( self._client = AsyncIOMotorClient(
MONGODB_URI, MONGODB_URI,
...@@ -121,6 +128,30 @@ class MongoDBClient: ...@@ -121,6 +128,30 @@ class MongoDBClient:
def shortcuts(self): def shortcuts(self):
return self.db[COLLECTION_SHORTCUTS] return self.db[COLLECTION_SHORTCUTS]
@property
def teams(self):
return self.db[COLLECTION_TEAMS]
@property
def team_members(self):
return self.db[COLLECTION_TEAM_MEMBERS]
@property
def team_memos(self):
return self.db[COLLECTION_TEAM_MEMOS]
@property
def team_comments(self):
return self.db[COLLECTION_TEAM_COMMENTS]
@property
def team_reactions(self):
return self.db[COLLECTION_TEAM_REACTIONS]
@property
def cached_user_profiles(self):
return self.db[COLLECTION_USER_PROFILES]
# Singleton instance # Singleton instance
mongodb_client = MongoDBClient() mongodb_client = MongoDBClient()
...@@ -214,6 +245,31 @@ async def create_indexes(): ...@@ -214,6 +245,31 @@ async def create_indexes():
# ====================== SHORTCUTS ====================== # ====================== SHORTCUTS ======================
await db[COLLECTION_SHORTCUTS].create_index([("creator_id", 1)]) await db[COLLECTION_SHORTCUTS].create_index([("creator_id", 1)])
# ====================== TEAMS ======================
await db[COLLECTION_TEAMS].create_index([("owner_id", 1)])
await db[COLLECTION_TEAMS].create_index([("invite_code", 1)], unique=True)
await db[COLLECTION_TEAM_MEMBERS].create_index([("team_id", 1), ("user_id", 1)], unique=True)
await db[COLLECTION_TEAM_MEMBERS].create_index([("user_id", 1)])
await db[COLLECTION_TEAM_MEMOS].create_index([("team_id", 1), ("space", 1), ("created_at", -1)])
await db[COLLECTION_TEAM_MEMOS].create_index([("team_id", 1), ("creator_id", 1)])
# ====================== TEAM COMMENTS ======================
await db[COLLECTION_TEAM_COMMENTS].create_index([("memo_id", 1), ("created_at", 1)])
await db[COLLECTION_TEAM_COMMENTS].create_index([("team_id", 1)])
# ====================== TEAM REACTIONS ======================
await db[COLLECTION_TEAM_REACTIONS].create_index([("memo_id", 1), ("emoji", 1)])
await db[COLLECTION_TEAM_REACTIONS].create_index(
[("memo_id", 1), ("user_id", 1), ("emoji", 1)], unique=True
)
# ====================== CACHED USER PROFILES ======================
await db[COLLECTION_USER_PROFILES].create_index([("user_id", 1)], unique=True)
# TTL: auto-delete after 24 hours
await db[COLLECTION_USER_PROFILES].create_index(
[("cached_at", 1)], expireAfterSeconds=86400
)
logger.info("✅ Database indexes created successfully (Production-ready)") logger.info("✅ Database indexes created successfully (Production-ready)")
except Exception as e: except Exception as e:
logger.warning(f"⚠️ Error creating indexes (may already exist): {e}") logger.warning(f"⚠️ Error creating indexes (may already exist): {e}")
......
"""
Pydantic schemas for Team workspace (Draft → Main flow).
Includes: comments, review flow, reactions, user profile resolution.
"""
from __future__ import annotations
from datetime import datetime
from typing import List, Optional
from pydantic import BaseModel, Field
# ====================== USER PROFILE ======================
class UserProfileResponse(BaseModel):
user_id: str
display_name: str = ""
avatar_url: str = ""
email: str = ""
# ====================== TEAM ======================
class TeamCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
description: str = ""
class TeamUpdate(BaseModel):
name: Optional[str] = Field(default=None, max_length=100)
description: Optional[str] = None
class TeamMemberResponse(BaseModel):
user_id: str
role: str # "owner" | "member"
joined_at: Optional[str] = None
display_name: str = ""
avatar_url: str = ""
class TeamResponse(BaseModel):
id: str
name: str
description: str = ""
owner_id: str
invite_code: str
members: List[TeamMemberResponse] = []
member_count: int = 0
created_at: Optional[str] = None
# ====================== TEAM MEMO ======================
class TeamMemoCreate(BaseModel):
content: str
tags: List[str] = []
class TeamMemoResponse(BaseModel):
id: str
content: str
tags: List[str] = []
creator_id: str
creator_name: str = ""
creator_avatar: str = ""
team_id: str
space: str = "draft" # "draft" | "main"
status: str = "draft" # "draft" | "pending_review" | "approved" | "merged"
pinned: bool = False
merged_at: Optional[str] = None
merged_by: Optional[str] = None
merged_by_name: str = ""
comment_count: int = 0
reaction_counts: dict = {} # {"👍": 2, "💡": 1}
user_reactions: List[str] = [] # reactions by current user
created_at: Optional[str] = None
updated_at: Optional[str] = None
# ====================== COMMENTS ======================
class TeamCommentCreate(BaseModel):
content: str = Field(..., min_length=1, max_length=2000)
class TeamCommentResponse(BaseModel):
id: str
memo_id: str
user_id: str
user_name: str = ""
user_avatar: str = ""
content: str
created_at: Optional[str] = None
# ====================== REVIEW ======================
class ReviewAction(BaseModel):
action: str = Field(..., pattern="^(approve|reject)$")
reason: str = ""
# ====================== REACTIONS ======================
class ReactionToggle(BaseModel):
emoji: str = Field(..., min_length=1, max_length=4)
This diff is collapsed.
""" """
Config file cho Supabase và các environment variables Config file cho CuCu Note backend
Lấy giá trị từ file .env qua os.getenv Lấy giá trị từ file .env qua os.getenv
""" """
...@@ -115,6 +115,8 @@ __all__ = [ ...@@ -115,6 +115,8 @@ __all__ = [
"RATE_LIMIT_BLOCK_MINUTES", "RATE_LIMIT_BLOCK_MINUTES",
"ENCRYPTION_KEY", "ENCRYPTION_KEY",
"ENCRYPTION_PASSWORD", "ENCRYPTION_PASSWORD",
"MEILI_URL",
"MEILI_MASTER_KEY",
] ]
# ====================== SUPABASE CONFIGURATION ====================== # ====================== SUPABASE CONFIGURATION ======================
...@@ -185,9 +187,9 @@ MONGODB_MAX_POOL_SIZE: int = int(os.getenv("MONGODB_MAX_POOL_SIZE", "5")) ...@@ -185,9 +187,9 @@ MONGODB_MAX_POOL_SIZE: int = int(os.getenv("MONGODB_MAX_POOL_SIZE", "5"))
MONGODB_MIN_POOL_SIZE: int = int(os.getenv("MONGODB_MIN_POOL_SIZE", "1")) MONGODB_MIN_POOL_SIZE: int = int(os.getenv("MONGODB_MIN_POOL_SIZE", "1"))
MONGODB_MAX_IDLE_TIME_MS: int = int(os.getenv("MONGODB_MAX_IDLE_TIME_MS", "45000")) MONGODB_MAX_IDLE_TIME_MS: int = int(os.getenv("MONGODB_MAX_IDLE_TIME_MS", "45000"))
# ====================== CANIFA INTERNAL POSTGRES ====================== # ====================== LEGACY POSTGRES (unused) ======================
CHECKPOINT_POSTGRES_URL: str | None = os.getenv("CHECKPOINT_POSTGRES_URL") CHECKPOINT_POSTGRES_URL: str | None = os.getenv("CHECKPOINT_POSTGRES_URL")
CHECKPOINT_POSTGRES_SCHEMA: str = os.getenv("CHECKPOINT_POSTGRES_SCHEMA", "canifa_chat") CHECKPOINT_POSTGRES_SCHEMA: str = os.getenv("CHECKPOINT_POSTGRES_SCHEMA", "cucu_chat")
# ====================== STARROCKS DATA LAKE ====================== # ====================== STARROCKS DATA LAKE ======================
STARROCKS_HOST: str | None = os.getenv("STARROCKS_HOST") STARROCKS_HOST: str | None = os.getenv("STARROCKS_HOST")
...@@ -239,4 +241,6 @@ CORS_ORIGINS: list[str] = ( ...@@ -239,4 +241,6 @@ CORS_ORIGINS: list[str] = (
ENCRYPTION_KEY: str | None = os.getenv("ENCRYPTION_KEY") ENCRYPTION_KEY: str | None = os.getenv("ENCRYPTION_KEY")
ENCRYPTION_PASSWORD: str | None = os.getenv("ENCRYPTION_PASSWORD") # Fallback for dev only ENCRYPTION_PASSWORD: str | None = os.getenv("ENCRYPTION_PASSWORD") # Fallback for dev only
# ====================== MEILISEARCH CONFIGURATION ======================
MEILI_URL: str | None = os.getenv("MEILI_URL")
MEILI_MASTER_KEY: str | None = os.getenv("MEILI_MASTER_KEY")
#!/bin/bash #!/bin/sh
set -e set -e
echo "🚀 Starting CuCu Note Backend..." echo "🚀 Starting CuCu Note Backend..."
# Install runtime deps not in base image (workaround until requirements.txt is fixed)
pip install --quiet --no-cache-dir meilisearch 2>/dev/null || echo "⚠️ Could not install meilisearch SDK"
# Try to connect to MongoDB (non-blocking, app will retry on startup) # Try to connect to MongoDB (non-blocking, app will retry on startup)
if [ -n "$MONGODB_URI" ]; then if [ -n "$MONGODB_URI" ]; then
echo "⏳ Testing MongoDB connection..." echo "⏳ Testing MongoDB connection..."
...@@ -43,11 +46,9 @@ asyncio.run(setup()) ...@@ -43,11 +46,9 @@ asyncio.run(setup())
" || echo "⚠️ Could not set up indexes (will retry on first request)" " || echo "⚠️ Could not set up indexes (will retry on first request)"
# Start the server # Start the server
echo "🌟 Starting Uvicorn server (hot reload enabled)..." echo "🌟 Starting Uvicorn server..."
exec uvicorn server:app \ exec uvicorn server:app \
--host 0.0.0.0 \ --host 0.0.0.0 \
--port 5000 \ --port 5000 \
--reload \
--reload-dir /app \
--log-level info --log-level info
# Core FastAPI # Core FastAPI
fastapi==0.124.4a fastapi
uvicorn==0.38.0 uvicorn==0.38.0
uvloop>=0.21.0 uvloop>=0.21.0
starlette==0.50.0 starlette==0.50.0
...@@ -17,6 +17,9 @@ psycopg-pool==3.3.0 ...@@ -17,6 +17,9 @@ psycopg-pool==3.3.0
# Redis for caching # Redis for caching
redis[hiredis]==5.2.1 redis[hiredis]==5.2.1
# Meilisearch keyword search
meilisearch>=0.31.0
# Auth & Security # Auth & Security
PyJWT==2.10.1 PyJWT==2.10.1
cryptography==46.0.3 cryptography==46.0.3
......
...@@ -7,13 +7,15 @@ from contextlib import asynccontextmanager ...@@ -7,13 +7,15 @@ from contextlib import asynccontextmanager
import uvicorn import uvicorn
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse, ORJSONResponse
from api.chatbot import router as chatbot_router from api.chatbot import router as chatbot_router
from api.memos import router as memos_router from api.memos import router as memos_router
from api.test_chat_route import router as test_router from api.test_chat_route import router as test_router
from api.team_routes import router as team_router
from common.cache import redis_cache from common.cache import redis_cache
from common.langfuse_client import get_langfuse_client from common.langfuse_client import get_langfuse_client
from common.meili_service import meili_service
from common.middleware import middleware_manager from common.middleware import middleware_manager
from config import PORT, CORS_ORIGINS from config import PORT, CORS_ORIGINS
...@@ -51,6 +53,14 @@ async def lifespan(app: FastAPI): ...@@ -51,6 +53,14 @@ async def lifespan(app: FastAPI):
await init_mongodb() await init_mongodb()
logger.info("✅ MongoDB connection initialized") logger.info("✅ MongoDB connection initialized")
# Meilisearch initialization (optional)
meili_ok = await meili_service.initialize()
if meili_ok:
synced = await meili_service.sync_all_memos()
logger.info("✅ Meilisearch ready (%d memos indexed)", synced)
else:
logger.warning("⚠️ Meilisearch unavailable — keyword search disabled")
# Langfuse initialization (optional) # Langfuse initialization (optional)
langfuse_client = get_langfuse_client() langfuse_client = get_langfuse_client()
if langfuse_client: if langfuse_client:
...@@ -78,10 +88,11 @@ async def lifespan(app: FastAPI): ...@@ -78,10 +88,11 @@ async def lifespan(app: FastAPI):
app = FastAPI( app = FastAPI(
title="Contract AI Service", title="CuCu Note API",
description="API for Contract AI Service", description="API for CuCu Note - Privacy-First Note-Taking Application",
version="1.0.0", version="1.0.0",
lifespan=lifespan, lifespan=lifespan,
default_response_class=ORJSONResponse,
) )
...@@ -91,7 +102,7 @@ app = FastAPI( ...@@ -91,7 +102,7 @@ app = FastAPI(
middleware_manager.setup( middleware_manager.setup(
app, app,
enable_auth=True, # bật/tắt Auth enable_auth=True, # bật/tắt Auth
enable_rate_limit=False, # tắt slowapi business rate limit enable_rate_limit=False, # SlowAPI not installed; chat rate limit handled by CuCuAuthMiddleware
enable_cors=True, # bật CORS enable_cors=True, # bật CORS
cors_origins=CORS_ORIGINS, # từ environment variable cors_origins=CORS_ORIGINS, # từ environment variable
) )
...@@ -99,6 +110,7 @@ middleware_manager.setup( ...@@ -99,6 +110,7 @@ middleware_manager.setup(
app.include_router(test_router) # No-auth test endpoints app.include_router(test_router) # No-auth test endpoints
app.include_router(chatbot_router) app.include_router(chatbot_router)
app.include_router(memos_router) app.include_router(memos_router)
app.include_router(team_router)
# ========================================== # ==========================================
# Mount static HTML # Mount static HTML
...@@ -120,7 +132,7 @@ async def root(): ...@@ -120,7 +132,7 @@ async def root():
if __name__ == "__main__": if __name__ == "__main__":
print("=" * 60) print("=" * 60)
print("Contract AI Service Starting...") print("CuCu Note API Starting...")
print("=" * 60) print("=" * 60)
print(f"REST API: http://localhost:{PORT}") print(f"REST API: http://localhost:{PORT}")
print(f"Test Chatbot: http://localhost:{PORT}/static/index.html") print(f"Test Chatbot: http://localhost:{PORT}/static/index.html")
......
"""
Test script: Team Draft → Main full flow.
Run inside container: python /app/temp/test_team_flow.py
"""
import asyncio
import sys
sys.path.insert(0, "/app")
from common.mongo_client import init_mongodb
from common.team.services import team_service
async def main():
await init_mongodb()
user_a = "test_user_alice"
user_b = "test_user_bob"
# 1. Create team
print("=" * 50)
print("1. CREATE TEAM")
team = await team_service.create_team("Test Team", "Testing draft → main", user_a)
team_id = team["id"]
print(f" ✅ Team: {team['name']} (id={team_id})")
print(f" 📎 Invite code: {team['invite_code']}")
# 2. Bob joins
print("\n2. BOB JOINS")
joined = await team_service.join_team(team["invite_code"], user_b)
print(f" ✅ Bob joined: {joined['name']}")
# 3. Create drafts
print("\n3. CREATE DRAFTS")
d1 = await team_service.create_draft(team_id, user_a, "Dùng Google Vision cho OCR", ["#idea"])
d2 = await team_service.create_draft(team_id, user_b, "Tesseract free hơn, cân nhắc?", ["#idea", "#blocker"])
d3 = await team_service.create_draft(team_id, user_a, "Budget: $50/tháng max", ["#plan"])
print(f" ✅ Draft 1: {d1['content'][:40]}... (by Alice)")
print(f" ✅ Draft 2: {d2['content'][:40]}... (by Bob)")
print(f" ✅ Draft 3: {d3['content'][:40]}... (by Alice)")
# 4. List drafts
print("\n4. LIST DRAFTS")
drafts = await team_service.list_drafts(team_id, user_a)
print(f" 📝 {len(drafts)} drafts in team")
for d in drafts:
print(f" - [{d['space']}] {d['content'][:50]}")
# 5. Merge draft 1 → main
print("\n5. MERGE DRAFT → MAIN")
merged = await team_service.merge_to_main(team_id, d1["id"], user_a)
print(f" ✅ Merged: {merged['content'][:40]}...")
print(f" 📌 Space: {merged['space']}, merged_by: {merged['merged_by']}")
# 6. List main
print("\n6. LIST MAIN")
main_items = await team_service.list_main(team_id, user_a)
print(f" ✅ {len(main_items)} items in main")
for m in main_items:
print(f" - [{m['space']}] {m['content'][:50]}")
# 7. List drafts (should be -1)
print("\n7. DRAFTS AFTER MERGE")
drafts_after = await team_service.list_drafts(team_id, user_a)
print(f" 📝 {len(drafts_after)} drafts remaining")
# 8. Unmerge back to draft
print("\n8. UNMERGE MAIN → DRAFT")
unmerged = await team_service.unmerge_to_draft(team_id, d1["id"], user_a)
print(f" ↩️ Unmerged: {unmerged['content'][:40]}... (space={unmerged['space']})")
# 9. Get team detail (with members)
print("\n9. TEAM DETAIL")
detail = await team_service.get_team(team_id, user_a)
print(f" 👥 Members: {len(detail['members'])}")
for m in detail["members"]:
print(f" - {m['user_id']} ({m['role']})")
# 10. Cleanup
print("\n10. CLEANUP")
await team_service.delete_team(team_id, user_a)
print(" 🗑️ Team deleted")
print("\n" + "=" * 50)
print("✅ ALL TESTS PASSED!")
print("=" * 50)
asyncio.run(main())
...@@ -13,16 +13,22 @@ services: ...@@ -13,16 +13,22 @@ services:
container_name: cuccu_backend container_name: cuccu_backend
restart: unless-stopped restart: unless-stopped
ports: ports:
- "5000:5000" - "5100:5000"
env_file: env_file:
- ./backend/.env - ./backend/.env
environment: environment:
# MongoDB connection string from Atlas # MongoDB — pulled from backend/.env (gitignored)
MONGODB_URI: "mongodb+srv://20010841:vuhoanganh1704@cluster0.h6qro.mongodb.net/cucu_note?retryWrites=true&w=majority&appName=Cluster0" MONGODB_URI: ${MONGODB_URI:-mongodb://localhost:27017/cucu_note}
MONGODB_DB_NAME: "cucu_note" MONGODB_DB_NAME: ${MONGODB_DB_NAME:-cucu_note}
# Meilisearch
MEILI_URL: "http://meilisearch:7700"
MEILI_MASTER_KEY: ${MEILI_MASTER_KEY:-changeme}
volumes: volumes:
- ./backend:/app - ./backend:/app
- backend_data:/app/data - backend_data:/app/data
depends_on:
meilisearch:
condition: service_healthy
networks: networks:
- cuccu_network - cuccu_network
healthcheck: healthcheck:
...@@ -40,6 +46,32 @@ services: ...@@ -40,6 +46,32 @@ services:
memory: 128M memory: 128M
cpus: '0.5' cpus: '0.5'
# Meilisearch - Lightning-fast keyword search
meilisearch:
image: getmeili/meilisearch:v1.13
container_name: cuccu_meilisearch
restart: unless-stopped
ports:
- "7700:7700"
environment:
MEILI_MASTER_KEY: ${MEILI_MASTER_KEY:-changeme}
MEILI_ENV: "production"
MEILI_NO_ANALYTICS: "true"
volumes:
- meili_data:/meili_data
networks:
- cuccu_network
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:7700/health"]
interval: 15s
timeout: 5s
retries: 3
start_period: 10s
deploy:
resources:
limits:
memory: 64M
# Frontend (Production build with nginx - saves ~190MB RAM) # Frontend (Production build with nginx - saves ~190MB RAM)
frontend: frontend:
build: build:
...@@ -51,7 +83,7 @@ services: ...@@ -51,7 +83,7 @@ services:
container_name: cuccu_frontend container_name: cuccu_frontend
restart: unless-stopped restart: unless-stopped
ports: ports:
- "3001:80" - "3010:80"
depends_on: depends_on:
- backend - backend
networks: networks:
...@@ -70,8 +102,11 @@ services: ...@@ -70,8 +102,11 @@ services:
volumes: volumes:
backend_data: backend_data:
driver: local driver: local
meili_data:
driver: local
networks: networks:
cuccu_network: cuccu_network:
driver: bridge driver: bridge
/**
* ChrysanthemumFlower — Hoa cúc SVG component
* Vietnamese golden chrysanthemum, pure SVG, no external assets needed.
*/
import { useMemo } from "react";
interface Props {
size?: number;
className?: string;
/** 0–360 — initial rotation offset */
rotate?: number;
/** Slow spin animation */
animate?: boolean;
/** Primary petal color */
color?: string;
}
let _idCounter = 0;
const ChrysanthemumFlower = ({
size = 64,
className = "",
rotate = 0,
animate = false,
color = "#FFD700",
}: Props) => {
// Unique IDs per instance to avoid SVG gradient conflicts
const uid = useMemo(() => `cucu-${++_idCounter}`, []);
const outerPetals = Array.from({ length: 16 });
const innerPetals = Array.from({ length: 8 });
return (
<svg
width={size}
height={size}
viewBox="-50 -50 100 100"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
style={{
transform: `rotate(${rotate}deg)`,
...(animate ? { animation: `cucuSpin-${uid} 18s linear infinite` } : {}),
}}
>
<defs>
<radialGradient id={`${uid}-po`} cx="50%" cy="80%" r="60%">
<stop offset="0%" stopColor={color} stopOpacity="1" />
<stop offset="100%" stopColor="#FFA500" stopOpacity="0.75" />
</radialGradient>
<radialGradient id={`${uid}-pi`} cx="50%" cy="70%" r="60%">
<stop offset="0%" stopColor="#FFF176" stopOpacity="1" />
<stop offset="100%" stopColor={color} stopOpacity="0.9" />
</radialGradient>
<radialGradient id={`${uid}-c`} cx="50%" cy="40%" r="70%">
<stop offset="0%" stopColor="#FFF9C4" />
<stop offset="60%" stopColor="#FFD700" />
<stop offset="100%" stopColor="#E65100" />
</radialGradient>
<filter id={`${uid}-glow`}>
<feGaussianBlur stdDeviation="1.2" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
{animate && (
<style>{`
@keyframes cucuSpin-${uid} {
from { transform: rotate(${rotate}deg); }
to { transform: rotate(${rotate + 360}deg); }
}
`}</style>
)}
</defs>
{/* Outer petals — 16 */}
{outerPetals.map((_, i) => (
<g key={`op-${i}`} transform={`rotate(${(i * 360) / 16})`}>
<ellipse
cx="0"
cy="-30"
rx="6"
ry="18"
fill={`url(#${uid}-po)`}
opacity="0.9"
filter={`url(#${uid}-glow)`}
/>
</g>
))}
{/* Inner petals — 8 */}
{innerPetals.map((_, i) => (
<g key={`ip-${i}`} transform={`rotate(${(i * 360) / 8 + 22.5})`}>
<ellipse
cx="0"
cy="-18"
rx="5"
ry="13"
fill={`url(#${uid}-pi)`}
opacity="0.95"
/>
</g>
))}
{/* Centre disc */}
<circle r="10" fill={`url(#${uid}-c)`} />
{/* Centre highlight */}
<circle r="3.5" cx="-1" cy="-1" fill="#FFFDE7" opacity="0.7" />
</svg>
);
};
export default ChrysanthemumFlower;
import ChrysanthemumFlower from "./ChrysanthemumFlower";
const Envelope = ({ side, delay = "0s", message = "Chúc Mừng Năm Mới! ✨" }: { side: "left" | "right", delay?: string, message?: string }) => ( const Envelope = ({ side, delay = "0s", message = "Chúc Mừng Năm Mới! ✨" }: { side: "left" | "right", delay?: string, message?: string }) => (
<div className={`fixed top-0 ${side === "left" ? "left-4 md:left-48" : "right-4"} z-[9999] pointer-events-none select-none hidden md:block`}> <div className={`fixed top-0 ${side === "left" ? "left-4 md:left-48" : "right-4"} z-[9999] pointer-events-none select-none hidden md:block`}>
<div className="relative animate-[sway_4s_ease-in-out_infinite] origin-top" style={{ animationDelay: delay }}> <div className="relative animate-[sway_4s_ease-in-out_infinite] origin-top" style={{ animationDelay: delay }}>
...@@ -24,12 +26,12 @@ const Envelope = ({ side, delay = "0s", message = "Chúc Mừng Năm Mới! ✨" ...@@ -24,12 +26,12 @@ const Envelope = ({ side, delay = "0s", message = "Chúc Mừng Năm Mới! ✨"
</div> </div>
); );
const Blossom = ({ top, left, right, bottom, size = "text-xl", delay = "0s" }: { top?: string, left?: string, right?: string, bottom?: string, size?: string, delay?: string }) => ( const Blossom = ({ top, left, right, bottom, size = 28, delay = "0s", spin = false }: { top?: string, left?: string, right?: string, bottom?: string, size?: number, delay?: string, spin?: boolean }) => (
<div <div
className={`fixed ${size} pointer-events-none select-none animate-[sway_5s_ease-in-out_infinite] opacity-40 z-[5] hidden md:block`} className={`fixed pointer-events-none select-none animate-[sway_5s_ease-in-out_infinite] opacity-50 z-[5] hidden md:block`}
style={{ top, left, right, bottom, animationDelay: delay }} style={{ top, left, right, bottom, animationDelay: delay }}
> >
{Math.random() > 0.5 ? "🌸" : "🌼"} <ChrysanthemumFlower size={size} animate={spin} rotate={Math.floor(Math.random() * 360)} />
</div> </div>
); );
...@@ -44,12 +46,17 @@ const FestiveCorner = () => { ...@@ -44,12 +46,17 @@ const FestiveCorner = () => {
{/* Two original side envelopes */} {/* Two original side envelopes */}
<Envelope side="right" message="Chúc Mừng Năm Mới! ✨" /> <Envelope side="right" message="Chúc Mừng Năm Mới! ✨" />
{/* Scattered Blossoms and Icons around the screen */} {/* Large decorative chrysanthemum — top-left corner */}
<Blossom top="20%" left="5%" delay="-1s" size="text-2xl" /> <div className="fixed top-4 left-4 md:left-52 pointer-events-none select-none opacity-60 z-[5] hidden md:block animate-[sway_7s_ease-in-out_infinite]">
<Blossom top="70%" left="2%" delay="-3s" size="text-xl" /> <ChrysanthemumFlower size={72} animate={true} color="#FFD700" />
<Blossom top="15%" right="15%" delay="-2s" size="text-3xl" /> </div>
<Blossom bottom="20%" left="15%" delay="-4s" size="text-2xl" />
<Blossom bottom="10%" right="25%" delay="-1.5s" size="text-xl" /> {/* Scattered Blossoms — SVG hoa cúc */}
<Blossom top="20%" left="5%" delay="-1s" size={32} spin />
<Blossom top="70%" left="2%" delay="-3s" size={24} />
<Blossom top="15%" right="15%" delay="-2s" size={40} spin />
<Blossom bottom="20%" left="15%" delay="-4s" size={28} />
<Blossom bottom="10%" right="25%" delay="-1.5s" size={22} spin />
{/* Small floating Sparkles */} {/* Small floating Sparkles */}
<div className="fixed top-1/4 right-1/3 text-yellow-400 animate-pulse opacity-20 pointer-events-none select-none text-2xl hidden md:block"></div> <div className="fixed top-1/4 right-1/3 text-yellow-400 animate-pulse opacity-20 pointer-events-none select-none text-2xl hidden md:block"></div>
......
import type { Element } from "hast"; import type { Element } from "hast";
import React from "react"; import React from "react";
import { isTagElement, isTaskListItemElement } from "@/types/markdown";
/** /**
* Creates a conditional component that renders different components * Creates a conditional component that renders different components
...@@ -33,4 +33,4 @@ export const createConditionalComponent = <P extends Record<string, unknown>>( ...@@ -33,4 +33,4 @@ export const createConditionalComponent = <P extends Record<string, unknown>>(
}; };
// Re-export type guards for convenience // Re-export type guards for convenience
export { isTagElement as isTagNode, isTaskListItemElement as isTaskListItemNode }; export { isTagElement as isTagNode, isTaskListItemElement as isTaskListItemNode } from "@/types/markdown";
import { create } from "@bufbuild/protobuf";
import { timestampDate } from "@bufbuild/protobuf/wkt";
import { useCallback, useEffect, useRef, useState } from "react";
import getCaretCoordinates from "textarea-caret";
import { cn } from "@/lib/utils";
import useCurrentUser from "@/hooks/useCurrentUser";
import { extractUserIdFromName } from "@/helpers/resource-names";
import { memoServiceClient } from "@/service";
import { type Memo, MemoRelation_MemoSchema, MemoRelation_Type, MemoRelationSchema } from "@/types/proto/api/v1/memo_service_pb";
import type { TagSuggestionsProps } from "../types";
import { useEditorContext } from "../state";
/**
* BacklinkSuggestions — triggered by typing [[
* Searches existing memos via API and creates a MemoRelation (REFERENCE) on selection.
* The linked memo appears in the RelationList section of both memos.
*/
export default function BacklinkSuggestions({ editorRef, editorActions }: TagSuggestionsProps) {
const user = useCurrentUser();
const { state, actions, dispatch } = useEditorContext();
const [position, setPosition] = useState<{ left: number; top: number; height: number } | null>(null);
const [memos, setMemos] = useState<Memo[]>([]);
const [selectedIndex, setSelectedIndex] = useState(0);
const [searchQuery, setSearchQuery] = useState("");
const [isOpen, setIsOpen] = useState(false);
const [isFetching, setIsFetching] = useState(false);
const isProcessingRef = useRef(false);
const debounceTimerRef = useRef<ReturnType<typeof setTimeout>>();
// Refs for event handlers (avoid stale closures)
const memosRef = useRef(memos);
memosRef.current = memos;
const selectedRef = useRef(selectedIndex);
selectedRef.current = selectedIndex;
const isOpenRef = useRef(isOpen);
isOpenRef.current = isOpen;
// Get the current [[ word and its start index
const getCurrentWord = useCallback((): [string, number] => {
const editor = editorRef.current;
if (!editor) return ["", 0];
const cursorPos = editor.selectionEnd;
const textBefore = editor.value.slice(0, cursorPos);
// Look for [[ backwards from cursor
const bracketIdx = textBefore.lastIndexOf("[[");
if (bracketIdx === -1) return ["", 0];
// Check nothing between [[ and cursor is a space (allow searching multi-word)
const textAfterBracket = textBefore.slice(bracketIdx);
// Also get text after cursor until whitespace or end
const textAfterCursor = editor.value.slice(cursorPos).match(/^[^\s\]]*/) || { 0: "" };
return [textAfterBracket + textAfterCursor[0], bracketIdx];
}, [editorRef]);
const hide = useCallback(() => {
setIsOpen(false);
setPosition(null);
setMemos([]);
setSearchQuery("");
}, []);
// Fetch memos from API with debounce
useEffect(() => {
if (!isOpen) return;
if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
setIsFetching(true);
debounceTimerRef.current = setTimeout(async () => {
try {
const conditions = [`creator_id == ${extractUserIdFromName(user?.name ?? "")}`];
if (searchQuery) {
conditions.push(`content.contains("${searchQuery}")`);
}
const { memos: fetchedMemos } = await memoServiceClient.listMemos({
filter: conditions.join(" && "),
});
// Filter out memos already in relations
const existingNames = new Set(state.metadata.relations.map((r) => r.relatedMemo?.name));
const filtered = fetchedMemos.filter((m) => !existingNames.has(m.name));
setMemos(filtered);
setSelectedIndex(0);
} catch (error) {
console.error("BacklinkSuggestions: fetch error", error);
setMemos([]);
} finally {
setIsFetching(false);
}
}, 300);
return () => {
if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
};
}, [isOpen, searchQuery, user?.name, state.metadata.relations]);
// Handle selecting a memo — use ref-based values to avoid stale closures
const handleSelectMemoRef = useRef<(memo: Memo) => void>();
handleSelectMemoRef.current = (memo: Memo) => {
if (!editorActions || !("current" in editorActions) || !editorActions.current) return;
isProcessingRef.current = true;
// 1. Create MemoRelation
const relation = create(MemoRelationSchema, {
type: MemoRelation_Type.REFERENCE,
relatedMemo: create(MemoRelation_MemoSchema, {
name: memo.name,
snippet: memo.snippet,
}),
});
dispatch(actions.addRelation(relation));
// 2. Replace the [[ text with a readable reference
const [word, index] = getCurrentWord();
const snippet = memo.snippet.slice(0, 30) || "memo";
const ea = editorActions.current;
ea.removeText(index, word.length);
ea.insertText(`📎${snippet} `);
hide();
queueMicrotask(() => {
isProcessingRef.current = false;
});
};
// Handle keyboard and input events
useEffect(() => {
const editor = editorRef.current;
if (!editor) return;
const handleInput = () => {
if (isProcessingRef.current) return;
setSelectedIndex(0);
const editorEl = editorRef.current;
if (!editorEl) return;
const cursorPos = editorEl.selectionEnd;
const textBefore = editorEl.value.slice(0, cursorPos);
const bracketIdx = textBefore.lastIndexOf("[[");
if (bracketIdx !== -1) {
// Check that there's no ]] between [[ and cursor
const between = textBefore.slice(bracketIdx + 2);
if (!between.includes("]]") && !between.includes("\n")) {
const query = between.toLowerCase();
setSearchQuery(query);
const coords = getCaretCoordinates(editorEl, bracketIdx);
coords.top -= editorEl.scrollTop;
setPosition(coords);
setIsOpen(true);
return;
}
}
// No valid [[ found
if (isOpenRef.current) {
setIsOpen(false);
setPosition(null);
setMemos([]);
setSearchQuery("");
}
};
const handleKeyDown = (e: KeyboardEvent) => {
if (!isOpenRef.current) return;
const currentMemos = memosRef.current;
const selected = selectedRef.current;
if (["Escape", "ArrowLeft", "ArrowRight"].includes(e.code)) {
setIsOpen(false);
setPosition(null);
setMemos([]);
setSearchQuery("");
return;
}
if (e.code === "ArrowDown" && currentMemos.length > 0) {
setSelectedIndex((selected + 1) % currentMemos.length);
e.preventDefault();
e.stopPropagation();
return;
}
if (e.code === "ArrowUp" && currentMemos.length > 0) {
setSelectedIndex((selected - 1 + currentMemos.length) % currentMemos.length);
e.preventDefault();
e.stopPropagation();
return;
}
if (["Enter", "Tab"].includes(e.code) && currentMemos.length > 0) {
handleSelectMemoRef.current?.(currentMemos[selected]);
e.preventDefault();
e.stopImmediatePropagation();
}
};
const handleHide = () => {
setIsOpen(false);
setPosition(null);
setMemos([]);
setSearchQuery("");
};
editor.addEventListener("input", handleInput);
editor.addEventListener("keydown", handleKeyDown);
editor.addEventListener("click", handleHide);
editor.addEventListener("blur", handleHide);
return () => {
editor.removeEventListener("input", handleInput);
editor.removeEventListener("keydown", handleKeyDown);
editor.removeEventListener("click", handleHide);
editor.removeEventListener("blur", handleHide);
};
}, [editorRef]);
if (!isOpen || !position) return null;
return (
<div
className="z-20 absolute p-1 mt-1 -ml-2 max-w-72 max-h-60 rounded border bg-popover text-popover-foreground shadow-lg flex flex-col overflow-y-auto overflow-x-hidden"
style={{ left: position.left, top: position.top + position.height }}
>
{isFetching && memos.length === 0 && (
<div className="p-2 text-sm text-muted-foreground">Đang tìm...</div>
)}
{!isFetching && memos.length === 0 && (
<div className="p-2 text-sm text-muted-foreground">Không tìm thấy memo</div>
)}
{memos.map((memo, i) => (
<div
key={memo.name}
onMouseDown={(e) => {
e.preventDefault();
handleSelectMemoRef.current?.(memo);
}}
className={cn(
"rounded p-1.5 px-2 w-full text-sm cursor-pointer transition-colors select-none hover:bg-accent hover:text-accent-foreground",
i === selectedIndex && "bg-accent text-accent-foreground",
)}
>
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground">
{memo.displayTime && timestampDate(memo.displayTime).toLocaleDateString()}
</span>
<span className="line-clamp-2 leading-tight">{memo.snippet || memo.content.slice(0, 50)}</span>
</div>
</div>
))}
</div>
);
}
...@@ -4,6 +4,7 @@ import { cn } from "@/lib/utils"; ...@@ -4,6 +4,7 @@ import { cn } from "@/lib/utils";
import { EDITOR_HEIGHT } from "../constants"; import { EDITOR_HEIGHT } from "../constants";
import type { EditorProps } from "../types"; import type { EditorProps } from "../types";
import { editorCommands } from "./commands"; import { editorCommands } from "./commands";
import BacklinkSuggestions from "./BacklinkSuggestions";
import SlashCommands from "./SlashCommands"; import SlashCommands from "./SlashCommands";
import TagSuggestions from "./TagSuggestions"; import TagSuggestions from "./TagSuggestions";
import { useListCompletion } from "./useListCompletion"; import { useListCompletion } from "./useListCompletion";
...@@ -205,6 +206,7 @@ const Editor = forwardRef(function Editor(props: EditorProps, ref: React.Forward ...@@ -205,6 +206,7 @@ const Editor = forwardRef(function Editor(props: EditorProps, ref: React.Forward
onCompositionEnd={onCompositionEnd} onCompositionEnd={onCompositionEnd}
></textarea> ></textarea>
<TagSuggestions editorRef={editorRef} editorActions={ref} /> <TagSuggestions editorRef={editorRef} editorActions={ref} />
<BacklinkSuggestions editorRef={editorRef} editorActions={ref} />
<SlashCommands editorRef={editorRef} editorActions={ref} commands={editorCommands} /> <SlashCommands editorRef={editorRef} editorActions={ref} commands={editorCommands} />
</div> </div>
); );
......
import getCaretCoordinates from "textarea-caret";
import { useEffect, useRef, useState } from "react";
import type { EditorRefActions } from "./Editor";
interface Position {
top: number;
left: number;
}
interface Props {
editorRef: React.RefObject<EditorRefActions | null>;
}
export const FloatingFormatToolbar: React.FC<Props> = ({ editorRef }) => {
const [pos, setPos] = useState<Position | null>(null);
const mouseDown = useRef(false);
useEffect(() => {
const onMouseDown = () => {
mouseDown.current = true;
};
const onMouseUp = () => {
mouseDown.current = false;
const editor = editorRef.current?.getEditor();
if (!editor || document.activeElement !== editor) {
setPos(null);
return;
}
const start = editor.selectionStart;
const end = editor.selectionEnd;
if (start === end) {
setPos(null);
return;
}
const rect = editor.getBoundingClientRect();
const caret = getCaretCoordinates(editor, end);
setPos({
top: rect.top + caret.top - editor.scrollTop - 44,
left: Math.min(
Math.max(rect.left + caret.left - 80, 8),
window.innerWidth - 220,
),
});
};
const onSelectionChange = () => {
if (mouseDown.current) return;
const editor = editorRef.current?.getEditor();
if (!editor || document.activeElement !== editor) {
setPos(null);
return;
}
if (editor.selectionStart === editor.selectionEnd) {
setPos(null);
}
};
const onKeyUp = () => {
const editor = editorRef.current?.getEditor();
if (!editor || document.activeElement !== editor) return;
if (editor.selectionStart === editor.selectionEnd) {
setPos(null);
return;
}
const rect = editor.getBoundingClientRect();
const caret = getCaretCoordinates(editor, editor.selectionEnd);
setPos({
top: rect.top + caret.top - editor.scrollTop - 44,
left: Math.min(
Math.max(rect.left + caret.left - 80, 8),
window.innerWidth - 220,
),
});
};
document.addEventListener("mousedown", onMouseDown);
document.addEventListener("mouseup", onMouseUp);
document.addEventListener("selectionchange", onSelectionChange);
document.addEventListener("keyup", onKeyUp);
return () => {
document.removeEventListener("mousedown", onMouseDown);
document.removeEventListener("mouseup", onMouseUp);
document.removeEventListener("selectionchange", onSelectionChange);
document.removeEventListener("keyup", onKeyUp);
};
}, [editorRef]);
if (!pos) return null;
const apply = (prefix: string, suffix: string) => {
editorRef.current?.insertText("", prefix, suffix);
setPos(null);
};
const applyLink = () => {
const editor = editorRef.current;
if (!editor) return;
editor.insertText("", "[", "](url)");
setPos(null);
};
return (
<div
className="fixed z-50 flex items-center gap-0.5 px-1.5 py-1 bg-popover border border-border rounded-lg shadow-lg select-none"
style={{ top: pos.top, left: pos.left }}
onMouseDown={(e) => e.preventDefault()}
>
<button
className="px-2 py-0.5 text-sm font-bold hover:bg-accent rounded transition-colors"
title="Bold (Ctrl+B)"
onClick={() => apply("**", "**")}
>
B
</button>
<button
className="px-2 py-0.5 text-sm italic hover:bg-accent rounded transition-colors"
title="Italic (Ctrl+I)"
onClick={() => apply("_", "_")}
>
I
</button>
<button
className="px-2 py-0.5 text-sm line-through hover:bg-accent rounded transition-colors"
title="Strikethrough"
onClick={() => apply("~~", "~~")}
>
S
</button>
<div className="w-px h-4 bg-border mx-0.5" />
<button
className="px-2 py-0.5 text-xs font-mono hover:bg-accent rounded transition-colors"
title="Inline code"
onClick={() => apply("`", "`")}
>
{"<>"}
</button>
<button
className="px-2 py-0.5 text-sm hover:bg-accent rounded transition-colors"
title="Link (Ctrl+K)"
onClick={applyLink}
>
🔗
</button>
</div>
);
};
...@@ -6,6 +6,18 @@ import InsertMenu from "../Toolbar/InsertMenu"; ...@@ -6,6 +6,18 @@ import InsertMenu from "../Toolbar/InsertMenu";
import VisibilitySelector from "../Toolbar/VisibilitySelector"; import VisibilitySelector from "../Toolbar/VisibilitySelector";
import type { EditorToolbarProps } from "../types"; import type { EditorToolbarProps } from "../types";
const WordCounter: FC<{ content: string }> = ({ content }) => {
const trimmed = content.trim();
const words = trimmed ? trimmed.split(/\s+/).length : 0;
const chars = content.length;
if (chars === 0) return null;
return (
<span className="text-xs text-muted-foreground tabular-nums select-none">
{words}w · {chars}c
</span>
);
};
export const EditorToolbar: FC<EditorToolbarProps> = ({ onSave, onCancel, memoName }) => { export const EditorToolbar: FC<EditorToolbarProps> = ({ onSave, onCancel, memoName }) => {
const { state, actions, dispatch } = useEditorContext(); const { state, actions, dispatch } = useEditorContext();
const { valid } = validationService.canSave(state); const { valid } = validationService.canSave(state);
...@@ -37,6 +49,7 @@ export const EditorToolbar: FC<EditorToolbarProps> = ({ onSave, onCancel, memoNa ...@@ -37,6 +49,7 @@ export const EditorToolbar: FC<EditorToolbarProps> = ({ onSave, onCancel, memoNa
</div> </div>
<div className="flex flex-row justify-end items-center gap-2"> <div className="flex flex-row justify-end items-center gap-2">
<WordCounter content={state.content} />
<VisibilitySelector value={state.metadata.visibility} onChange={handleVisibilityChange} /> <VisibilitySelector value={state.metadata.visibility} onChange={handleVisibilityChange} />
{onCancel && ( {onCancel && (
......
import { useEffect } from "react"; import { useEffect, useRef } from "react";
import { cacheService } from "../services"; import { cacheService } from "../services";
export const useAutoSave = (content: string, username: string, cacheKey: string | undefined) => { export const useAutoSave = (content: string, username: string, cacheKey: string | undefined) => {
const timerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
useEffect(() => { useEffect(() => {
clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => {
const key = cacheService.key(username, cacheKey); const key = cacheService.key(username, cacheKey);
cacheService.save(key, content); cacheService.save(key, content);
}, 300);
return () => clearTimeout(timerRef.current);
}, [content, username, cacheKey]); }, [content, username, cacheKey]);
}; };
...@@ -3,18 +3,99 @@ import type { EditorRefActions } from "../Editor"; ...@@ -3,18 +3,99 @@ import type { EditorRefActions } from "../Editor";
interface UseKeyboardOptions { interface UseKeyboardOptions {
onSave: () => void; onSave: () => void;
onNewNote?: () => void;
} }
export const useKeyboard = (_editorRef: React.RefObject<EditorRefActions | null>, options: UseKeyboardOptions) => { export const useKeyboard = (editorRef: React.RefObject<EditorRefActions | null>, options: UseKeyboardOptions) => {
useEffect(() => { useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") { const ctrl = event.metaKey || event.ctrlKey;
// Ctrl+Enter → save
if (ctrl && event.key === "Enter") {
event.preventDefault(); event.preventDefault();
options.onSave(); options.onSave();
return;
}
// Ctrl+N → new note (global)
if (ctrl && event.key === "n") {
event.preventDefault();
options.onNewNote?.();
return;
}
// Formatting shortcuts (only when editor is focused)
const editor = editorRef.current;
if (!editor) return;
const activeTag = (document.activeElement as HTMLElement)?.tagName;
if (activeTag !== "TEXTAREA") return;
// Ctrl+D → insert current datetime
if (ctrl && event.key === "d") {
event.preventDefault();
const now = new Date();
const pad = (n: number) => String(n).padStart(2, "0");
const dateStr = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}`;
editor.insertText(dateStr);
return;
}
// Ctrl+B → **bold**
if (ctrl && event.key === "b") {
event.preventDefault();
editor.insertText("", "**", "**");
return;
}
// Ctrl+I → _italic_
if (ctrl && event.key === "i") {
event.preventDefault();
editor.insertText("", "_", "_");
return;
}
// Ctrl+K → [text](url)
if (ctrl && event.key === "k") {
event.preventDefault();
const selected = editor.getSelectedContent();
if (selected) {
editor.insertText("", "[", "](url)");
} else {
editor.insertText("[text](url)");
// Select the word "text" for quick replacement
const pos = editor.getCursorPosition();
editor.setCursorPosition(pos - "](url)".length - "text".length, pos - "](url)".length);
}
return;
}
// Ctrl+Shift+C → - [ ] checkbox
if (ctrl && event.shiftKey && event.key === "C") {
event.preventDefault();
const lineNum = editor.getCursorLineNumber();
const line = editor.getLine(lineNum);
editor.setLine(lineNum, "- [ ] " + line);
return;
}
// Tab → indent list item
if (event.key === "Tab") {
const lineNum = editor.getCursorLineNumber();
const line = editor.getLine(lineNum);
if (/^(\s*[-*+]|\s*\d+\.)/.test(line)) {
event.preventDefault();
if (event.shiftKey) {
// Shift+Tab → unindent
editor.setLine(lineNum, line.replace(/^ /, ""));
} else {
editor.setLine(lineNum, " " + line);
}
}
} }
}; };
window.addEventListener("keydown", handleKeyDown); window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown);
}, [options]); }, [editorRef, options, options.onNewNote]);
}; };
...@@ -14,6 +14,7 @@ import { Visibility } from "@/types/proto/api/v1/memo_service_pb"; ...@@ -14,6 +14,7 @@ import { Visibility } from "@/types/proto/api/v1/memo_service_pb";
import { EditorContent, EditorMetadata, EditorToolbar, FocusModeExitButton, FocusModeOverlay } from "./components"; import { EditorContent, EditorMetadata, EditorToolbar, FocusModeExitButton, FocusModeOverlay } from "./components";
import { FOCUS_MODE_STYLES } from "./constants"; import { FOCUS_MODE_STYLES } from "./constants";
import type { EditorRefActions } from "./Editor"; import type { EditorRefActions } from "./Editor";
import { FloatingFormatToolbar } from "./FloatingFormatToolbar";
import { useAutoSave, useFocusMode, useKeyboard, useMemoInit } from "./hooks"; import { useAutoSave, useFocusMode, useKeyboard, useMemoInit } from "./hooks";
import { cacheService, errorService, memoService, validationService } from "./services"; import { cacheService, errorService, memoService, validationService } from "./services";
import { EditorProvider, useEditorContext } from "./state"; import { EditorProvider, useEditorContext } from "./state";
...@@ -80,7 +81,13 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({ ...@@ -80,7 +81,13 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({
dispatch(actions.toggleFocusMode()); dispatch(actions.toggleFocusMode());
}; };
useKeyboard(editorRef, { onSave: handleSave }); const handleNewNote = () => {
dispatch(actions.reset());
// Small delay so reset completes before focusing
setTimeout(() => editorRef.current?.focus(), 50);
};
useKeyboard(editorRef, { onSave: handleSave, onNewNote: handleNewNote });
async function handleSave() { async function handleSave() {
// Validate before saving // Validate before saving
...@@ -171,6 +178,9 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({ ...@@ -171,6 +178,9 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({
{/* Exit button is absolutely positioned in top-right corner when active */} {/* Exit button is absolutely positioned in top-right corner when active */}
<FocusModeExitButton isActive={state.ui.isFocusMode} onToggle={handleToggleFocusMode} title={t("editor.exit-focus-mode")} /> <FocusModeExitButton isActive={state.ui.isFocusMode} onToggle={handleToggleFocusMode} title={t("editor.exit-focus-mode")} />
{/* Floating format toolbar on text selection */}
<FloatingFormatToolbar editorRef={editorRef} />
{/* Editor content grows to fill available space in focus mode */} {/* Editor content grows to fill available space in focus mode */}
<EditorContent ref={editorRef} placeholder={placeholder} autoFocus={autoFocus} /> <EditorContent ref={editorRef} placeholder={placeholder} autoFocus={autoFocus} />
......
import { useEffect, useState } from "react";
import { SignedIn, SignedOut } from "@clerk/clerk-react"; import { SignedIn, SignedOut } from "@clerk/clerk-react";
import { ArchiveIcon, BellIcon, BookOpenIcon, EarthIcon, LibraryIcon, PaperclipIcon, SettingsIcon, User2Icon } from "lucide-react"; import { ArchiveIcon, BellIcon, BookOpenIcon, EarthIcon, LibraryIcon, PaperclipIcon, SettingsIcon, User2Icon, UsersIcon } from "lucide-react";
import { NavLink } from "react-router-dom"; import { NavLink } from "react-router-dom";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { useNotifications } from "@/hooks/useUserQueries"; import { useNotifications } from "@/hooks/useUserQueries";
...@@ -75,6 +76,45 @@ const Navigation = (props: Props) => { ...@@ -75,6 +76,45 @@ const Navigation = (props: Props) => {
}, },
]; ];
// Dynamic teams from API
const [teams, setTeams] = useState<{ id: string; name: string }[]>([]);
useEffect(() => {
const fetchTeams = async () => {
try {
const { listTeams } = await import("@/service/teamService");
const data = await listTeams();
setTeams(data.map((t) => ({ id: t.id, name: t.name })));
} catch {
// Not logged in or API error — ignore
}
};
fetchTeams();
}, []);
const teamNavLinks: NavLinkItem[] = [
// "Teams" overview link
{
id: "header-teams",
path: Routes.TEAMS,
title: "Teams",
icon: <UsersIcon className="w-5 h-5 shrink-0" />,
requiresAuth: true,
},
// Individual team links
...teams.map((team) => ({
id: `header-team-${team.id}`,
path: `/app/teams/${team.id}`,
title: team.name,
icon: (
<div className="w-5 h-5 rounded-md bg-amber-500/20 text-amber-600 dark:text-amber-400 flex items-center justify-center text-[11px] font-bold shrink-0">
{team.name.charAt(0).toUpperCase()}
</div>
),
requiresAuth: true,
})),
];
const systemNavLinks: NavLinkItem[] = [ const systemNavLinks: NavLinkItem[] = [
{ {
id: "header-setting", id: "header-setting",
...@@ -167,6 +207,12 @@ const Navigation = (props: Props) => { ...@@ -167,6 +207,12 @@ const Navigation = (props: Props) => {
{/* Main section */} {/* Main section */}
{mainNavLinks.map(renderNavLink)} {mainNavLinks.map(renderNavLink)}
{/* Teams section */}
<SignedIn>
{sectionLabel("Teams")}
{teamNavLinks.map(renderNavLink)}
</SignedIn>
{/* System section */} {/* System section */}
{sectionLabel("System")} {sectionLabel("System")}
{systemNavLinks.map(renderNavLink)} {systemNavLinks.map(renderNavLink)}
......
import { LightbulbIcon, TargetIcon, Sparkles } from "lucide-react"; import { LightbulbIcon, TargetIcon, Sparkles } from "lucide-react";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
import { useTranslate } from "@/utils/i18n"; import { useTranslate } from "@/utils/i18n";
import ChrysanthemumFlower from "@/components/ChrysanthemumFlower";
const AboutCuCu = () => { const AboutCuCu = () => {
const t = useTranslate(); const t = useTranslate();
...@@ -8,10 +9,20 @@ const AboutCuCu = () => { ...@@ -8,10 +9,20 @@ const AboutCuCu = () => {
return ( return (
<section className="@container w-full max-w-3xl mx-auto min-h-full flex flex-col justify-start items-start gap-6 py-8"> <section className="@container w-full max-w-3xl mx-auto min-h-full flex flex-col justify-start items-start gap-6 py-8">
<header className="w-full flex flex-col gap-2"> <header className="w-full flex flex-col gap-2">
{/* Brand header with chrysanthemum flowers */}
<div className="flex items-center gap-3 mb-1">
<ChrysanthemumFlower size={48} animate color="#FFD700" />
<div className="flex flex-col">
<h1 className="text-2xl font-semibold tracking-tight flex items-center gap-2"> <h1 className="text-2xl font-semibold tracking-tight flex items-center gap-2">
<LightbulbIcon className="w-6 h-6 text-primary" /> <LightbulbIcon className="w-6 h-6 text-primary" />
<span>CuCu Note là gì?</span> <span>CuCu Note là gì?</span>
</h1> </h1>
<p className="text-xs text-amber-500/80 font-medium italic">
"Cúc" — hoa cúc vàng, loài hoa tượng trưng cho sự thanh bình và trí nhớ bền vững.
</p>
</div>
<ChrysanthemumFlower size={36} animate color="#FFA500" rotate={45} />
</div>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Sổ nháp số để bạn ghi nhanh suy nghĩ, chia sẻ cho người khác xem và góp ý, trước khi biến chúng thành ghi chú hoàn chỉnh ở bất kỳ đâu. Sổ nháp số để bạn ghi nhanh suy nghĩ, chia sẻ cho người khác xem và góp ý, trước khi biến chúng thành ghi chú hoàn chỉnh ở bất kỳ đâu.
</p> </p>
......
/**
* Team invite join page — /teams/join/:inviteCode
* Flow: Chưa login → redirect login → quay lại → hiện Accept → join → vào workspace
*/
import { useEffect, useState } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { useAuth } from "@clerk/clerk-react";
import { Button } from "@/components/ui/button";
import { joinTeam } from "@/service/teamService";
const TeamJoin = () => {
const { inviteCode } = useParams<{ inviteCode: string }>();
const navigate = useNavigate();
const { isSignedIn, isLoaded } = useAuth();
const [joining, setJoining] = useState(false);
const [error, setError] = useState("");
// Chờ Clerk load xong
if (!isLoaded) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<div className="animate-spin w-5 h-5 border-2 border-primary border-t-transparent rounded-full" />
</div>
);
}
// Chưa login → redirect đến trang auth, lưu URL hiện tại để quay lại
if (!isSignedIn) {
const currentUrl = window.location.pathname;
return (
<div className="flex flex-col items-center justify-center min-h-[60vh] gap-4">
<div className="text-center space-y-2">
<h2 className="text-lg font-semibold">Bạn được mời vào team</h2>
<p className="text-sm text-muted-foreground">Đăng nhập để tham gia</p>
</div>
<Button onClick={() => navigate(`/auth?redirect=${encodeURIComponent(currentUrl)}`)}>
Đăng nhập
</Button>
</div>
);
}
const handleAccept = async () => {
if (!inviteCode) return;
setJoining(true);
setError("");
try {
const team = await joinTeam(inviteCode);
navigate(`/app/teams/${team.id}`, { replace: true });
} catch (e: any) {
setError(e.message || "Không thể join team. Code không hợp lệ hoặc đã hết hạn.");
setJoining(false);
}
};
return (
<div className="flex flex-col items-center justify-center min-h-[60vh] gap-6">
<div className="text-center space-y-2">
<h2 className="text-lg font-semibold">Bạn được mời vào team</h2>
<p className="text-sm text-muted-foreground">
Invite code: <span className="font-mono">{inviteCode}</span>
</p>
</div>
{error && <p className="text-sm text-red-500">{error}</p>}
<div className="flex gap-3">
<Button variant="outline" onClick={() => navigate("/app/teams")}>
Hủy
</Button>
<Button onClick={handleAccept} disabled={joining}>
{joining ? "Đang tham gia..." : "Tham gia team"}
</Button>
</div>
</div>
);
};
export default TeamJoin;
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
...@@ -23,6 +23,9 @@ const AuthCallback = lazy(() => import("@/pages/AuthCallback")); ...@@ -23,6 +23,9 @@ const AuthCallback = lazy(() => import("@/pages/AuthCallback"));
const AuthSsoCallback = lazy(() => import("@/pages/AuthSsoCallback")); const AuthSsoCallback = lazy(() => import("@/pages/AuthSsoCallback"));
const Landing = lazy(() => import("@/pages/Landing")); const Landing = lazy(() => import("@/pages/Landing"));
const AboutCuCu = lazy(() => import("@/pages/AboutCuCu")); const AboutCuCu = lazy(() => import("@/pages/AboutCuCu"));
const Teams = lazy(() => import("@/pages/Teams"));
const TeamWorkspace = lazy(() => import("@/pages/TeamWorkspace"));
const TeamJoin = lazy(() => import("@/pages/TeamJoin"));
import { ROUTES } from "./routes"; import { ROUTES } from "./routes";
...@@ -80,6 +83,9 @@ const router = createBrowserRouter([ ...@@ -80,6 +83,9 @@ const router = createBrowserRouter([
{ path: "inbox", element: <LazyRoute component={Inboxes} /> }, { path: "inbox", element: <LazyRoute component={Inboxes} /> },
{ path: "setting", element: <LazyRoute component={Setting} /> }, { path: "setting", element: <LazyRoute component={Setting} /> },
{ path: "about", element: <LazyRoute component={AboutCuCu} /> }, { path: "about", element: <LazyRoute component={AboutCuCu} /> },
{ path: "teams", element: <LazyRoute component={Teams} /> },
{ path: "teams/:teamId", element: <LazyRoute component={TeamWorkspace} /> },
{ path: "teams/join/:inviteCode", element: <LazyRoute component={TeamJoin} /> },
{ path: "memos/:uid", element: <LazyRoute component={MemoDetail} /> }, { path: "memos/:uid", element: <LazyRoute component={MemoDetail} /> },
// Redirect old path to new path // Redirect old path to new path
{ path: "m/:uid", element: <LazyRoute component={MemoDetailRedirect} /> }, { path: "m/:uid", element: <LazyRoute component={MemoDetailRedirect} /> },
...@@ -88,6 +94,8 @@ const router = createBrowserRouter([ ...@@ -88,6 +94,8 @@ const router = createBrowserRouter([
{ path: "*", element: <LazyRoute component={NotFound} /> }, { path: "*", element: <LazyRoute component={NotFound} /> },
], ],
}, },
// Support direct /teams/join/:inviteCode path for share links
{ path: "teams/join/:inviteCode", element: <LazyRoute component={TeamJoin} /> },
// Support direct /memos/:uid path (without /app prefix) for sharing // Support direct /memos/:uid path (without /app prefix) for sharing
{ path: "memos/:uid", element: <LazyRoute component={MemoDetail} /> }, { path: "memos/:uid", element: <LazyRoute component={MemoDetail} /> },
{ path: "*", element: <LazyRoute component={NotFound} /> }, { path: "*", element: <LazyRoute component={NotFound} /> },
......
...@@ -8,6 +8,7 @@ export const ROUTES = { ...@@ -8,6 +8,7 @@ export const ROUTES = {
SETTING: "/app/setting", SETTING: "/app/setting",
EXPLORE: "/app/explore", EXPLORE: "/app/explore",
ABOUT: "/app/about", ABOUT: "/app/about",
TEAMS: "/app/teams",
} as const; } as const;
export type RouteKey = keyof typeof ROUTES; export type RouteKey = keyof typeof ROUTES;
......
/**
* Team API service — CRUD for teams, members, drafts, main memos,
* comments, review flow, reactions, and user profile resolution.
*/
import { fetchJson } from "./apiClient";
// ====================== TYPES ======================
export interface UserProfile {
user_id: string;
display_name: string;
avatar_url: string;
email: string;
}
export interface TeamMember {
user_id: string;
role: "owner" | "member";
joined_at?: string;
display_name: string;
avatar_url: string;
}
export interface Team {
id: string;
name: string;
description: string;
owner_id: string;
invite_code: string;
members: TeamMember[];
member_count: number;
created_at?: string;
}
export type MemoStatus = "draft" | "pending_review" | "approved" | "merged";
export interface TeamMemo {
id: string;
content: string;
tags: string[];
creator_id: string;
creator_name: string;
creator_avatar: string;
team_id: string;
space: "draft" | "main";
status: MemoStatus;
pinned: boolean;
merged_at?: string;
merged_by?: string;
merged_by_name: string;
comment_count: number;
reaction_counts: Record<string, number>;
user_reactions: string[];
review_reason?: string;
created_at?: string;
updated_at?: string;
}
export interface TeamComment {
id: string;
memo_id: string;
user_id: string;
user_name: string;
user_avatar: string;
content: string;
created_at?: string;
}
// ====================== TEAM CRUD ======================
export const createTeam = (name: string, description = "") =>
fetchJson<Team>("/teams", { method: "POST", body: { name, description } });
export const listTeams = () => fetchJson<Team[]>("/teams");
export const getTeam = (teamId: string) => fetchJson<Team>(`/teams/${teamId}`);
export const updateTeam = (teamId: string, data: { name?: string; description?: string }) =>
fetchJson<Team>(`/teams/${teamId}`, { method: "PATCH", body: data });
export const deleteTeam = (teamId: string) =>
fetchJson<{ status: string }>(`/teams/${teamId}`, { method: "DELETE" });
// ====================== MEMBERS ======================
export const joinTeam = (inviteCode: string) =>
fetchJson<Team>("/teams/join", { method: "POST", body: { invite_code: inviteCode } });
export const listMembers = (teamId: string) =>
fetchJson<{ members: TeamMember[] }>(`/teams/${teamId}/members`);
export const removeMember = (teamId: string, userId: string) =>
fetchJson<{ status: string }>(`/teams/${teamId}/members/${userId}`, { method: "DELETE" });
export const leaveTeam = (teamId: string) =>
fetchJson<{ status: string }>(`/teams/${teamId}/leave`, { method: "POST" });
// ====================== USER PROFILES ======================
export const resolveUsers = (userIds: string[]) =>
fetchJson<{ profiles: Record<string, UserProfile> }>("/teams/resolve-users", {
method: "POST",
body: { user_ids: userIds },
});
// ====================== TEAM MEMOS ======================
export const createDraft = (teamId: string, content: string, tags: string[] = []) =>
fetchJson<TeamMemo>(`/teams/${teamId}/memos`, { method: "POST", body: { content, tags } });
export const listDrafts = (teamId: string) => fetchJson<TeamMemo[]>(`/teams/${teamId}/drafts`);
export const listMain = (teamId: string) => fetchJson<TeamMemo[]>(`/teams/${teamId}/main`);
export const mergeMemo = (teamId: string, memoId: string) =>
fetchJson<TeamMemo>(`/teams/${teamId}/memos/${memoId}/merge`, { method: "POST" });
export const unmergeMemo = (teamId: string, memoId: string) =>
fetchJson<TeamMemo>(`/teams/${teamId}/memos/${memoId}/unmerge`, { method: "POST" });
export const pinMemo = (teamId: string, memoId: string) =>
fetchJson<TeamMemo>(`/teams/${teamId}/memos/${memoId}/pin`, { method: "POST" });
export const updateTeamMemo = (teamId: string, memoId: string, content: string, tags: string[] = []) =>
fetchJson<TeamMemo>(`/teams/${teamId}/memos/${memoId}`, { method: "PATCH", body: { content, tags } });
export const deleteTeamMemo = (teamId: string, memoId: string) =>
fetchJson<{ status: string }>(`/teams/${teamId}/memos/${memoId}`, { method: "DELETE" });
// ====================== REVIEW FLOW ======================
export const requestReview = (teamId: string, memoId: string) =>
fetchJson<TeamMemo>(`/teams/${teamId}/memos/${memoId}/request-review`, { method: "POST" });
export const submitReview = (teamId: string, memoId: string, action: "approve" | "reject", reason = "") =>
fetchJson<TeamMemo>(`/teams/${teamId}/memos/${memoId}/review`, {
method: "POST",
body: { action, reason },
});
// ====================== COMMENTS ======================
export const listComments = (teamId: string, memoId: string) =>
fetchJson<TeamComment[]>(`/teams/${teamId}/memos/${memoId}/comments`);
export const createComment = (teamId: string, memoId: string, content: string) =>
fetchJson<TeamComment>(`/teams/${teamId}/memos/${memoId}/comments`, {
method: "POST",
body: { content },
});
export const deleteComment = (teamId: string, commentId: string) =>
fetchJson<{ status: string }>(`/teams/${teamId}/comments/${commentId}`, { method: "DELETE" });
// ====================== REACTIONS ======================
export const toggleReaction = (teamId: string, memoId: string, emoji: string) =>
fetchJson<{ memo_id: string; reaction_counts: Record<string, number>; user_reactions: string[] }>(
`/teams/${teamId}/memos/${memoId}/reactions`,
{ method: "POST", body: { emoji } },
);
...@@ -42,3 +42,4 @@ export function isTaskListItemElement(node: HastElement): boolean { ...@@ -42,3 +42,4 @@ export function isTaskListItemElement(node: HastElement): boolean {
const type = node.properties?.type; const type = node.properties?.type;
return typeof type === "string" && type === "checkbox"; return typeof type === "string" && type === "checkbox";
} }
# Guest Mode — Dùng thử không cần đăng nhập
## Ý tưởng
Người dùng mới vào app → nhấn "Dùng thử ngay" → vào shared space → dùng thoải mái.
Giảm friction tối đa, không ai phải đăng ký mới được test.
## Flow
```
Landing Page
├── [Đăng nhập] → Vào space cá nhân (có sẵn ✅)
├── [Đăng ký] → Tạo tài khoản (có sẵn ✅)
└── [🚀 Dùng thử ngay] → NEW
├── Tạo anonymous session (guest_abc123)
├── Vào shared "Demo Team" chung
├── Ai cũng thấy notes của nhau
└── Dùng thử draft → merge → main
```
## So sánh Guest vs User
| | Guest (chưa login) | User (đã login) |
|---|---|---|
| Xem notes | Shared space chung | Space riêng |
| Tạo notes | ✅ Ghi vào demo team | ✅ Ghi vào team riêng |
| Teams | 1 demo team | Tạo nhiều team |
| Draft → Main | ✅ | ✅ |
| Data | Mất khi clear browser | Lưu vĩnh viễn |
## Cần làm
### Frontend
1. Landing page: thêm nút "🚀 Dùng thử ngay"
2. Khi nhấn → tạo `guest_<random>` lưu vào localStorage
3. Redirect vào `/app/teams/<demo_team_id>`
4. Header hiện: "Bạn đang dùng thử — Đăng ký để lưu vĩnh viễn"
### Backend
1. API: `POST /api/v1/guest/session` → tạo guest session, trả guest_id
2. Auto-create "Demo Team" nếu chưa có (shared cho tất cả guest)
3. Middleware: cho phép guest_id bypass auth (chỉ truy cập demo team)
4. Cleanup: cronjob xóa guest data cũ hơn 7 ngày
### Data Model
```json
// Guest session (lưu trong MongoDB)
{
"_id": "ObjectId",
"guest_id": "guest_abc123",
"created_at": "...",
"last_active": "..."
}
// Demo Team (1 team duy nhất, shared)
{
"name": "Demo Team",
"owner_id": "system",
"is_demo": true
}
```
## Thứ tự triển khai
1. Backend: Guest session API + demo team auto-create
2. Backend: Middleware cho guest bypass
3. Frontend: Nút "Dùng thử" trên Landing
4. Frontend: Guest banner "Đăng ký để lưu vĩnh viễn"
5. Backend: Cleanup cronjob (optional, sau)
This diff is collapsed.
This diff is collapsed.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment