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),
}
"""
Team API routes — CRUD for teams, members, draft/main memos,
comments, review flow, and reactions.
"""
import logging
from typing import List
from fastapi import APIRouter, Body, Depends, HTTPException, Request
from common.team.schemas import (
TeamCreate,
TeamUpdate,
TeamResponse,
TeamMemoCreate,
TeamMemoResponse,
TeamCommentCreate,
TeamCommentResponse,
ReviewAction,
ReactionToggle,
UserProfileResponse,
)
from common.team.services import team_service
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/teams", tags=["teams"])
def get_current_user_id(request: Request) -> str | None:
return getattr(request.state, "user_id", None)
def require_user(request: Request) -> str:
user_id = get_current_user_id(request)
if not user_id:
raise HTTPException(status_code=401, detail="Authentication required")
return user_id
# ========================= TEAM CRUD =========================
@router.post("", response_model=TeamResponse, summary="Create team")
async def create_team(request: Request, payload: TeamCreate):
user_id = require_user(request)
result = await team_service.create_team(
name=payload.name,
description=payload.description,
owner_id=user_id,
)
return result
@router.get("", response_model=List[TeamResponse], summary="List my teams")
async def list_teams(request: Request):
user_id = require_user(request)
return await team_service.list_teams(user_id)
@router.get("/{team_id}", response_model=TeamResponse, summary="Get team detail")
async def get_team(request: Request, team_id: str):
user_id = require_user(request)
result = await team_service.get_team(team_id, user_id)
if not result:
raise HTTPException(status_code=404, detail="Team not found or not a member")
return result
@router.patch("/{team_id}", response_model=TeamResponse, summary="Update team")
async def update_team(request: Request, team_id: str, payload: TeamUpdate):
user_id = require_user(request)
result = await team_service.update_team(
team_id, user_id,
name=payload.name,
description=payload.description,
)
if not result:
raise HTTPException(status_code=403, detail="Not team owner")
return result
@router.delete("/{team_id}", summary="Delete team")
async def delete_team(request: Request, team_id: str):
user_id = require_user(request)
ok = await team_service.delete_team(team_id, user_id)
if not ok:
raise HTTPException(status_code=403, detail="Not team owner")
return {"status": "deleted"}
# ========================= MEMBERS =========================
@router.post("/join", summary="Join team by invite code")
async def join_team(request: Request, invite_code: str = Body(..., embed=True)):
user_id = require_user(request)
result = await team_service.join_team(invite_code, user_id)
if not result:
raise HTTPException(status_code=404, detail="Invalid invite code")
return result
@router.get("/{team_id}/members", summary="List team members")
async def list_members(request: Request, team_id: str):
user_id = require_user(request)
team = await team_service.get_team(team_id, user_id)
if not team:
raise HTTPException(status_code=404, detail="Team not found")
return {"members": team.get("members", [])}
@router.delete("/{team_id}/members/{target_user_id}", summary="Remove member")
async def remove_member(request: Request, team_id: str, target_user_id: str):
user_id = require_user(request)
ok = await team_service.remove_member(team_id, target_user_id, user_id)
if not ok:
raise HTTPException(status_code=403, detail="Not owner or can't remove")
return {"status": "removed"}
@router.post("/{team_id}/leave", summary="Leave team")
async def leave_team(request: Request, team_id: str):
user_id = require_user(request)
ok = await team_service.leave_team(team_id, user_id)
if not ok:
raise HTTPException(status_code=400, detail="Owner can't leave (delete team instead)")
return {"status": "left"}
# ========================= USER PROFILES =========================
@router.post("/resolve-users", summary="Batch resolve user IDs to profiles")
async def resolve_users(request: Request, user_ids: List[str] = Body(..., embed=True)):
require_user(request)
profiles = await team_service.resolve_user_profiles(user_ids)
return {"profiles": profiles}
# ========================= TEAM MEMOS (Draft / Main) =========================
@router.post("/{team_id}/memos", response_model=TeamMemoResponse, summary="Create draft memo")
async def create_draft(request: Request, team_id: str, payload: TeamMemoCreate):
user_id = require_user(request)
result = await team_service.create_draft(
team_id, user_id,
content=payload.content,
tags=payload.tags,
)
if not result:
raise HTTPException(status_code=403, detail="Not a team member")
return result
@router.get("/{team_id}/drafts", response_model=List[TeamMemoResponse], summary="List drafts")
async def list_drafts(request: Request, team_id: str):
user_id = require_user(request)
return await team_service.list_drafts(team_id, user_id)
@router.get("/{team_id}/main", response_model=List[TeamMemoResponse], summary="List main (merged)")
async def list_main(request: Request, team_id: str):
user_id = require_user(request)
return await team_service.list_main(team_id, user_id)
@router.post("/{team_id}/memos/{memo_id}/merge", response_model=TeamMemoResponse, summary="Merge draft → main")
async def merge_memo(request: Request, team_id: str, memo_id: str):
user_id = require_user(request)
result = await team_service.merge_to_main(team_id, memo_id, user_id)
if not result:
raise HTTPException(status_code=403, detail="Not found or not authorized (owner only)")
return result
@router.post("/{team_id}/memos/{memo_id}/unmerge", response_model=TeamMemoResponse, summary="Unmerge main → draft")
async def unmerge_memo(request: Request, team_id: str, memo_id: str):
user_id = require_user(request)
result = await team_service.unmerge_to_draft(team_id, memo_id, user_id)
if not result:
raise HTTPException(status_code=403, detail="Not found or not owner")
return result
@router.patch("/{team_id}/memos/{memo_id}", response_model=TeamMemoResponse, summary="Update team memo")
async def update_memo(request: Request, team_id: str, memo_id: str, payload: TeamMemoCreate):
user_id = require_user(request)
result = await team_service.update_memo(
team_id, memo_id, user_id,
content=payload.content,
tags=payload.tags,
)
if not result:
raise HTTPException(status_code=403, detail="Not found or not authorized")
return result
@router.post("/{team_id}/memos/{memo_id}/pin", response_model=TeamMemoResponse, summary="Pin/unpin team memo")
async def pin_memo(request: Request, team_id: str, memo_id: str):
user_id = require_user(request)
result = await team_service.pin_memo(team_id, memo_id, user_id)
if not result:
raise HTTPException(status_code=403, detail="Not found or not authorized")
return result
@router.delete("/{team_id}/memos/{memo_id}", summary="Delete team memo")
async def delete_memo(request: Request, team_id: str, memo_id: str):
user_id = require_user(request)
ok = await team_service.delete_memo(team_id, memo_id, user_id)
if not ok:
raise HTTPException(status_code=403, detail="Not found or not authorized")
return {"status": "deleted"}
# ========================= REVIEW FLOW =========================
@router.post("/{team_id}/memos/{memo_id}/request-review", response_model=TeamMemoResponse, summary="Request review")
async def request_review(request: Request, team_id: str, memo_id: str):
user_id = require_user(request)
result = await team_service.request_review(team_id, memo_id, user_id)
if not result:
raise HTTPException(status_code=403, detail="Not found or not authorized")
return result
@router.post("/{team_id}/memos/{memo_id}/review", response_model=TeamMemoResponse, summary="Approve or reject review")
async def submit_review(request: Request, team_id: str, memo_id: str, payload: ReviewAction):
user_id = require_user(request)
result = await team_service.submit_review(
team_id, memo_id, user_id,
action=payload.action,
reason=payload.reason,
)
if not result:
raise HTTPException(status_code=403, detail="Not found or not owner")
return result
# ========================= COMMENTS =========================
@router.post("/{team_id}/memos/{memo_id}/comments", response_model=TeamCommentResponse, summary="Add comment")
async def create_comment(request: Request, team_id: str, memo_id: str, payload: TeamCommentCreate):
user_id = require_user(request)
result = await team_service.create_comment(team_id, memo_id, user_id, payload.content)
if not result:
raise HTTPException(status_code=403, detail="Not found or not a member")
return result
@router.get("/{team_id}/memos/{memo_id}/comments", response_model=List[TeamCommentResponse], summary="List comments")
async def list_comments(request: Request, team_id: str, memo_id: str):
user_id = require_user(request)
return await team_service.list_comments(team_id, memo_id, user_id)
@router.delete("/{team_id}/comments/{comment_id}", summary="Delete comment")
async def delete_comment(request: Request, team_id: str, comment_id: str):
user_id = require_user(request)
ok = await team_service.delete_comment(team_id, comment_id, user_id)
if not ok:
raise HTTPException(status_code=403, detail="Not found or not authorized")
return {"status": "deleted"}
# ========================= REACTIONS =========================
@router.post("/{team_id}/memos/{memo_id}/reactions", summary="Toggle reaction")
async def toggle_reaction(request: Request, team_id: str, memo_id: str, payload: ReactionToggle):
user_id = require_user(request)
result = await team_service.toggle_reaction(team_id, memo_id, user_id, payload.emoji)
if not result:
raise HTTPException(status_code=400, detail="Invalid emoji or not found")
return result
...@@ -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)
"""
Team service — MongoDB operations for Team workspace.
Flow: Tạo Team → Ghi Nháp → Comment → Review → Merge vào Chính
Features: User profile resolution, comments, review flow, reactions.
"""
from __future__ import annotations
import logging
import secrets
import string
from datetime import datetime, timezone
from typing import Any
import httpx
from bson import ObjectId
from common.mongo_client import mongodb_client, utc_now
logger = logging.getLogger(__name__)
# Collection names
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"
# Valid reactions
VALID_REACTIONS = {"👍", "💡", "❓", "✅", "🔥", "❤️"}
class TeamService:
"""Team CRUD + Comments + Reviews + Reactions service."""
@property
def teams(self):
return mongodb_client.db[COLLECTION_TEAMS]
@property
def members(self):
return mongodb_client.db[COLLECTION_TEAM_MEMBERS]
@property
def team_memos(self):
return mongodb_client.db[COLLECTION_TEAM_MEMOS]
@property
def comments(self):
return mongodb_client.db[COLLECTION_TEAM_COMMENTS]
@property
def reactions(self):
return mongodb_client.db[COLLECTION_TEAM_REACTIONS]
@property
def user_profiles(self):
return mongodb_client.db[COLLECTION_USER_PROFILES]
# ========================= USER PROFILE RESOLUTION =========================
async def resolve_user_profiles(self, user_ids: list[str]) -> dict[str, dict]:
"""
Resolve Clerk user IDs → display names + avatars.
Uses MongoDB cache (TTL 24h) to avoid hammering Clerk API.
Returns {user_id: {display_name, avatar_url, email}}.
"""
if not user_ids:
return {}
unique_ids = list(set(user_ids))
result: dict[str, dict] = {}
# 1. Check cache first
cached = await self.user_profiles.find(
{"user_id": {"$in": unique_ids}}
).to_list(length=200)
cached_map = {}
for c in cached:
cached_map[c["user_id"]] = {
"display_name": c.get("display_name", ""),
"avatar_url": c.get("avatar_url", ""),
"email": c.get("email", ""),
}
result.update(cached_map)
# 2. Find uncached IDs
uncached = [uid for uid in unique_ids if uid not in cached_map]
if not uncached:
return result
# 3. Fetch from Clerk Backend API
try:
from config import CLERK_SECRET_KEY
if not CLERK_SECRET_KEY:
logger.warning("⚠️ CLERK_SECRET_KEY not set, using fallback names")
for uid in uncached:
result[uid] = {"display_name": uid[:12], "avatar_url": "", "email": ""}
return result
async with httpx.AsyncClient(timeout=10.0) as client:
# Clerk API: GET /v1/users?user_id=id1&user_id=id2
params = [("user_id", uid) for uid in uncached]
resp = await client.get(
"https://api.clerk.com/v1/users",
params=params,
headers={"Authorization": f"Bearer {CLERK_SECRET_KEY}"},
)
if resp.status_code == 200:
users_data = resp.json()
# Handle both list and paginated response
if isinstance(users_data, dict) and "data" in users_data:
users_list = users_data["data"]
elif isinstance(users_data, list):
users_list = users_data
else:
users_list = []
now = utc_now()
for user in users_list:
uid = user.get("id", "")
first = user.get("first_name", "") or ""
last = user.get("last_name", "") or ""
display_name = f"{first} {last}".strip() or uid[:12]
avatar_url = user.get("image_url", "") or user.get("profile_image_url", "") or ""
email = ""
email_addresses = user.get("email_addresses", [])
if email_addresses:
email = email_addresses[0].get("email_address", "")
profile = {
"display_name": display_name,
"avatar_url": avatar_url,
"email": email,
}
result[uid] = profile
# Cache in MongoDB (upsert)
await self.user_profiles.update_one(
{"user_id": uid},
{"$set": {
"user_id": uid,
**profile,
"cached_at": now,
}},
upsert=True,
)
else:
logger.error("❌ Clerk API error: %s %s", resp.status_code, resp.text[:200])
for uid in uncached:
result[uid] = {"display_name": uid[:12], "avatar_url": "", "email": ""}
except Exception as e:
logger.error("❌ Failed to resolve user profiles: %s", e)
for uid in uncached:
if uid not in result:
result[uid] = {"display_name": uid[:12], "avatar_url": "", "email": ""}
return result
def _get_profile(self, profiles: dict, user_id: str) -> tuple[str, str]:
"""Helper to get (display_name, avatar_url) from resolved profiles."""
p = profiles.get(user_id, {})
return p.get("display_name", user_id[:12] if user_id else ""), p.get("avatar_url", "")
# ========================= TEAM CRUD =========================
async def create_team(self, name: str, description: str, owner_id: str) -> dict:
"""Tạo team mới. Owner tự động thành member."""
now = utc_now()
# Use only unambiguous chars (no O/0/I/l/1)
alphabet = string.ascii_uppercase.replace('O', '').replace('I', '') + string.digits.replace('0', '').replace('1', '')
invite_code = ''.join(secrets.choice(alphabet) for _ in range(8))
doc = {
"name": name,
"description": description,
"owner_id": owner_id,
"invite_code": invite_code,
"created_at": now,
"updated_at": now,
}
result = await self.teams.insert_one(doc)
doc["_id"] = result.inserted_id
# Add owner as member
await self.members.insert_one({
"team_id": str(result.inserted_id),
"user_id": owner_id,
"role": "owner",
"joined_at": now,
})
logger.info("✅ Team created: %s by %s", name, owner_id)
return self._team_to_response(doc, member_count=1)
async def list_teams(self, user_id: str) -> list[dict]:
"""List all teams user belongs to. Uses aggregation to avoid N+1."""
# Find team_ids user is member of
member_docs = await self.members.find(
{"user_id": user_id}
).to_list(length=100)
team_ids = [m["team_id"] for m in member_docs]
if not team_ids:
return []
# Use aggregation to get teams with member counts in one query
object_ids = [ObjectId(tid) for tid in team_ids if ObjectId.is_valid(tid)]
pipeline = [
{"$match": {"_id": {"$in": object_ids}}},
{"$lookup": {
"from": COLLECTION_TEAM_MEMBERS,
"let": {"team_id": {"$toString": "$_id"}},
"pipeline": [
{"$match": {"$expr": {"$eq": ["$team_id", "$$team_id"]}}},
{"$count": "count"},
],
"as": "member_info",
}},
{"$addFields": {
"member_count": {
"$ifNull": [{"$arrayElemAt": ["$member_info.count", 0]}, 0]
},
}},
{"$project": {"member_info": 0}},
{"$sort": {"created_at": -1}},
]
teams = await self.teams.aggregate(pipeline).to_list(length=100)
results = []
for team in teams:
count = team.pop("member_count", 0)
results.append(self._team_to_response(team, member_count=count))
return results
async def get_team(self, team_id: str, user_id: str) -> dict | None:
"""Get team detail with resolved member names. Only members can view."""
if not await self._is_member(team_id, user_id):
return None
team = await self.teams.find_one({"_id": ObjectId(team_id)})
if not team:
return None
# Get members
member_docs = await self.members.find(
{"team_id": team_id}
).to_list(length=100)
# Resolve all member names
member_ids = [m["user_id"] for m in member_docs]
profiles = await self.resolve_user_profiles(member_ids)
members = []
for m in member_docs:
name, avatar = self._get_profile(profiles, m["user_id"])
members.append({
"user_id": m["user_id"],
"role": m["role"],
"joined_at": str(m.get("joined_at", "")) if m.get("joined_at") else None,
"display_name": name,
"avatar_url": avatar,
})
response = self._team_to_response(team, member_count=len(members))
response["members"] = members
return response
async def update_team(self, team_id: str, user_id: str, **fields) -> dict | None:
"""Update team. Only owner."""
if not await self._is_owner(team_id, user_id):
return None
update = {"updated_at": utc_now()}
for key in ("name", "description"):
if key in fields and fields[key] is not None:
update[key] = fields[key]
await self.teams.update_one({"_id": ObjectId(team_id)}, {"$set": update})
return await self.get_team(team_id, user_id)
async def delete_team(self, team_id: str, user_id: str) -> bool:
"""Delete team + all members + all memos + comments + reactions. Only owner."""
if not await self._is_owner(team_id, user_id):
return False
await self.teams.delete_one({"_id": ObjectId(team_id)})
await self.members.delete_many({"team_id": team_id})
await self.team_memos.delete_many({"team_id": team_id})
await self.comments.delete_many({"team_id": team_id})
await self.reactions.delete_many({"team_id": team_id})
logger.info("🗑️ Team deleted: %s by %s", team_id, user_id)
return True
# ========================= MEMBERS =========================
async def join_team(self, invite_code: str, user_id: str) -> dict | None:
"""Join team bằng invite code."""
# Case-insensitive search to avoid O/0 confusion
import re
team = await self.teams.find_one({
"invite_code": re.compile(f"^{re.escape(invite_code)}$", re.IGNORECASE)
})
if not team:
return None
team_id = str(team["_id"])
# Check already member
existing = await self.members.find_one({
"team_id": team_id, "user_id": user_id
})
if existing:
return self._team_to_response(team)
await self.members.insert_one({
"team_id": team_id,
"user_id": user_id,
"role": "member",
"joined_at": utc_now(),
})
logger.info("✅ User %s joined team %s", user_id, team["name"])
return self._team_to_response(team)
async def remove_member(self, team_id: str, target_user_id: str, requester_id: str) -> bool:
"""Remove member. Owner only (can't remove self)."""
if not await self._is_owner(team_id, requester_id):
return False
if target_user_id == requester_id:
return False # Can't remove yourself
result = await self.members.delete_one({
"team_id": team_id, "user_id": target_user_id
})
return result.deleted_count > 0
async def leave_team(self, team_id: str, user_id: str) -> bool:
"""Leave team. Owner can't leave (must delete team)."""
if await self._is_owner(team_id, user_id):
return False
result = await self.members.delete_one({
"team_id": team_id, "user_id": user_id
})
return result.deleted_count > 0
# ========================= TEAM MEMOS (Draft / Main) =========================
async def create_draft(self, team_id: str, user_id: str, content: str, tags: list[str] = None) -> dict | None:
"""Tạo note nháp trong team."""
if not await self._is_member(team_id, user_id):
return None
now = utc_now()
doc = {
"team_id": team_id,
"creator_id": user_id,
"content": content,
"tags": tags or [],
"space": "draft",
"status": "draft",
"pinned": False,
"merged_at": None,
"merged_by": None,
"review_reason": None,
"created_at": now,
"updated_at": now,
}
result = await self.team_memos.insert_one(doc)
doc["_id"] = result.inserted_id
# Resolve creator name
profiles = await self.resolve_user_profiles([user_id])
return self._memo_to_response(doc, profiles=profiles)
async def list_drafts(self, team_id: str, user_id: str) -> list[dict]:
"""List nháp của team with user profiles resolved."""
if not await self._is_member(team_id, user_id):
return []
cursor = self.team_memos.find({
"team_id": team_id,
"space": "draft",
}).sort("created_at", -1)
docs = await cursor.to_list(length=200)
if not docs:
return []
# Collect all user IDs to resolve
user_ids = set()
for doc in docs:
user_ids.add(doc.get("creator_id", ""))
if doc.get("merged_by"):
user_ids.add(doc["merged_by"])
user_ids.discard("")
profiles = await self.resolve_user_profiles(list(user_ids))
# Get comment counts + reactions in batch
memo_ids = [str(doc["_id"]) for doc in docs]
# Comment counts
comment_pipeline = [
{"$match": {"memo_id": {"$in": memo_ids}}},
{"$group": {"_id": "$memo_id", "count": {"$sum": 1}}},
]
comment_counts = {}
async for item in self.comments.aggregate(comment_pipeline):
comment_counts[item["_id"]] = item["count"]
# Reaction counts
reaction_pipeline = [
{"$match": {"memo_id": {"$in": memo_ids}}},
{"$group": {"_id": {"memo_id": "$memo_id", "emoji": "$emoji"}, "count": {"$sum": 1}}},
]
reaction_counts: dict[str, dict] = {} # {memo_id: {emoji: count}}
async for item in self.reactions.aggregate(reaction_pipeline):
mid = item["_id"]["memo_id"]
emoji = item["_id"]["emoji"]
reaction_counts.setdefault(mid, {})[emoji] = item["count"]
# User's own reactions
user_reactions_pipeline = [
{"$match": {"memo_id": {"$in": memo_ids}, "user_id": user_id}},
{"$group": {"_id": "$memo_id", "emojis": {"$push": "$emoji"}}},
]
user_reactions: dict[str, list] = {}
async for item in self.reactions.aggregate(user_reactions_pipeline):
user_reactions[item["_id"]] = item["emojis"]
results = []
for doc in docs:
mid = str(doc["_id"])
resp = self._memo_to_response(doc, profiles=profiles)
resp["comment_count"] = comment_counts.get(mid, 0)
resp["reaction_counts"] = reaction_counts.get(mid, {})
resp["user_reactions"] = user_reactions.get(mid, [])
results.append(resp)
return results
async def list_main(self, team_id: str, user_id: str) -> list[dict]:
"""List bản chính của team with user profiles resolved."""
if not await self._is_member(team_id, user_id):
return []
cursor = self.team_memos.find({
"team_id": team_id,
"space": "main",
}).sort("merged_at", -1)
docs = await cursor.to_list(length=200)
if not docs:
return []
# Collect user IDs
user_ids = set()
for doc in docs:
user_ids.add(doc.get("creator_id", ""))
if doc.get("merged_by"):
user_ids.add(doc["merged_by"])
user_ids.discard("")
profiles = await self.resolve_user_profiles(list(user_ids))
memo_ids = [str(doc["_id"]) for doc in docs]
# Comment counts
comment_pipeline = [
{"$match": {"memo_id": {"$in": memo_ids}}},
{"$group": {"_id": "$memo_id", "count": {"$sum": 1}}},
]
comment_counts = {}
async for item in self.comments.aggregate(comment_pipeline):
comment_counts[item["_id"]] = item["count"]
# Reaction counts
reaction_pipeline = [
{"$match": {"memo_id": {"$in": memo_ids}}},
{"$group": {"_id": {"memo_id": "$memo_id", "emoji": "$emoji"}, "count": {"$sum": 1}}},
]
reaction_counts: dict[str, dict] = {}
async for item in self.reactions.aggregate(reaction_pipeline):
mid = item["_id"]["memo_id"]
emoji = item["_id"]["emoji"]
reaction_counts.setdefault(mid, {})[emoji] = item["count"]
# User reactions
user_reactions_pipeline = [
{"$match": {"memo_id": {"$in": memo_ids}, "user_id": user_id}},
{"$group": {"_id": "$memo_id", "emojis": {"$push": "$emoji"}}},
]
user_reactions: dict[str, list] = {}
async for item in self.reactions.aggregate(user_reactions_pipeline):
user_reactions[item["_id"]] = item["emojis"]
results = []
for doc in docs:
mid = str(doc["_id"])
resp = self._memo_to_response(doc, profiles=profiles)
resp["comment_count"] = comment_counts.get(mid, 0)
resp["reaction_counts"] = reaction_counts.get(mid, {})
resp["user_reactions"] = user_reactions.get(mid, [])
results.append(resp)
return results
async def merge_to_main(self, team_id: str, memo_id: str, user_id: str) -> dict | None:
"""Merge note nháp → bản chính. Only owner can merge."""
if not await self._is_owner(team_id, user_id):
return None
doc = await self.team_memos.find_one({
"_id": ObjectId(memo_id),
"team_id": team_id,
"space": "draft",
})
if not doc:
return None
now = utc_now()
await self.team_memos.update_one(
{"_id": doc["_id"]},
{"$set": {
"space": "main",
"status": "merged",
"merged_at": now,
"merged_by": user_id,
"updated_at": now,
}}
)
updated = await self.team_memos.find_one({"_id": doc["_id"]})
profiles = await self.resolve_user_profiles([
updated.get("creator_id", ""), user_id
])
logger.info("✅ Merged draft → main: %s in team %s", memo_id, team_id)
return self._memo_to_response(updated, profiles=profiles)
async def unmerge_to_draft(self, team_id: str, memo_id: str, user_id: str) -> dict | None:
"""Đưa note từ chính về nháp. Chỉ owner."""
if not await self._is_owner(team_id, user_id):
return None
doc = await self.team_memos.find_one({
"_id": ObjectId(memo_id),
"team_id": team_id,
"space": "main",
})
if not doc:
return None
await self.team_memos.update_one(
{"_id": doc["_id"]},
{"$set": {
"space": "draft",
"status": "draft",
"merged_at": None,
"merged_by": None,
"updated_at": utc_now(),
}}
)
updated = await self.team_memos.find_one({"_id": doc["_id"]})
profiles = await self.resolve_user_profiles([updated.get("creator_id", "")])
return self._memo_to_response(updated, profiles=profiles)
async def update_memo(self, team_id: str, memo_id: str, user_id: str, content: str = None, tags: list[str] = None) -> dict | None:
"""Update team memo. Creator hoặc owner."""
doc = await self.team_memos.find_one({
"_id": ObjectId(memo_id),
"team_id": team_id,
})
if not doc:
return None
is_owner = await self._is_owner(team_id, user_id)
if not is_owner and doc.get("creator_id") != user_id:
return None
# Can't edit if pending_review (unless owner)
if doc.get("status") == "pending_review" and not is_owner:
return None
update: dict[str, Any] = {"updated_at": utc_now()}
if content is not None:
update["content"] = content
if tags is not None:
update["tags"] = tags
await self.team_memos.update_one({"_id": doc["_id"]}, {"$set": update})
updated = await self.team_memos.find_one({"_id": doc["_id"]})
profiles = await self.resolve_user_profiles([updated.get("creator_id", "")])
return self._memo_to_response(updated, profiles=profiles)
async def delete_memo(self, team_id: str, memo_id: str, user_id: str) -> bool:
"""Delete team memo. Creator hoặc owner. Must unmerge first if in main."""
doc = await self.team_memos.find_one({
"_id": ObjectId(memo_id),
"team_id": team_id,
})
if not doc:
return False
is_owner = await self._is_owner(team_id, user_id)
if not is_owner and doc.get("creator_id") != user_id:
return False
# Must unmerge before deleting
if doc.get("space") == "main" and not is_owner:
return False
await self.team_memos.delete_one({"_id": doc["_id"]})
# Clean up comments and reactions
await self.comments.delete_many({"memo_id": str(doc["_id"])})
await self.reactions.delete_many({"memo_id": str(doc["_id"])})
return True
async def pin_memo(self, team_id: str, memo_id: str, user_id: str) -> dict | None:
"""Pin/unpin team memo. Owner hoặc creator."""
doc = await self.team_memos.find_one({
"_id": ObjectId(memo_id),
"team_id": team_id,
})
if not doc:
return None
is_owner = await self._is_owner(team_id, user_id)
if not is_owner and doc.get("creator_id") != user_id:
return None
new_pinned = not doc.get("pinned", False)
await self.team_memos.update_one(
{"_id": doc["_id"]},
{"$set": {"pinned": new_pinned, "updated_at": utc_now()}}
)
updated = await self.team_memos.find_one({"_id": doc["_id"]})
profiles = await self.resolve_user_profiles([updated.get("creator_id", "")])
return self._memo_to_response(updated, profiles=profiles)
# ========================= REVIEW FLOW =========================
async def request_review(self, team_id: str, memo_id: str, user_id: str) -> dict | None:
"""Member requests review for their draft. Status: draft → pending_review."""
if not await self._is_member(team_id, user_id):
return None
doc = await self.team_memos.find_one({
"_id": ObjectId(memo_id),
"team_id": team_id,
"space": "draft",
})
if not doc:
return None
# Only creator or owner can request review
is_owner = await self._is_owner(team_id, user_id)
if not is_owner and doc.get("creator_id") != user_id:
return None
await self.team_memos.update_one(
{"_id": doc["_id"]},
{"$set": {"status": "pending_review", "updated_at": utc_now()}}
)
updated = await self.team_memos.find_one({"_id": doc["_id"]})
profiles = await self.resolve_user_profiles([updated.get("creator_id", "")])
logger.info("📨 Review requested: %s by %s", memo_id, user_id)
return self._memo_to_response(updated, profiles=profiles)
async def submit_review(self, team_id: str, memo_id: str, user_id: str, action: str, reason: str = "") -> dict | None:
"""Owner approves or rejects a review. Only owner can review."""
if not await self._is_owner(team_id, user_id):
return None
doc = await self.team_memos.find_one({
"_id": ObjectId(memo_id),
"team_id": team_id,
})
if not doc:
return None
if action == "approve":
new_status = "approved"
elif action == "reject":
new_status = "draft"
else:
return None
update = {
"status": new_status,
"review_reason": reason,
"updated_at": utc_now(),
}
await self.team_memos.update_one({"_id": doc["_id"]}, {"$set": update})
updated = await self.team_memos.find_one({"_id": doc["_id"]})
profiles = await self.resolve_user_profiles([updated.get("creator_id", "")])
logger.info("📋 Review %s: %s by %s (reason: %s)", action, memo_id, user_id, reason[:50])
return self._memo_to_response(updated, profiles=profiles)
# ========================= COMMENTS =========================
async def create_comment(self, team_id: str, memo_id: str, user_id: str, content: str) -> dict | None:
"""Add comment to a team memo."""
if not await self._is_member(team_id, user_id):
return None
# Verify memo exists in this team
memo = await self.team_memos.find_one({
"_id": ObjectId(memo_id),
"team_id": team_id,
})
if not memo:
return None
now = utc_now()
doc = {
"team_id": team_id,
"memo_id": memo_id,
"user_id": user_id,
"content": content,
"created_at": now,
}
result = await self.comments.insert_one(doc)
doc["_id"] = result.inserted_id
profiles = await self.resolve_user_profiles([user_id])
name, avatar = self._get_profile(profiles, user_id)
return {
"id": str(doc["_id"]),
"memo_id": memo_id,
"user_id": user_id,
"user_name": name,
"user_avatar": avatar,
"content": content,
"created_at": str(now),
}
async def list_comments(self, team_id: str, memo_id: str, user_id: str) -> list[dict]:
"""List comments for a team memo."""
if not await self._is_member(team_id, user_id):
return []
docs = await self.comments.find({
"memo_id": memo_id,
"team_id": team_id,
}).sort("created_at", 1).to_list(length=100)
if not docs:
return []
# Resolve all commenter names
user_ids = list(set(d["user_id"] for d in docs))
profiles = await self.resolve_user_profiles(user_ids)
results = []
for doc in docs:
name, avatar = self._get_profile(profiles, doc["user_id"])
results.append({
"id": str(doc["_id"]),
"memo_id": doc["memo_id"],
"user_id": doc["user_id"],
"user_name": name,
"user_avatar": avatar,
"content": doc["content"],
"created_at": str(doc.get("created_at", "")),
})
return results
async def delete_comment(self, team_id: str, comment_id: str, user_id: str) -> bool:
"""Delete a comment. Only comment author or team owner."""
doc = await self.comments.find_one({"_id": ObjectId(comment_id)})
if not doc or doc.get("team_id") != team_id:
return False
is_owner = await self._is_owner(team_id, user_id)
if not is_owner and doc.get("user_id") != user_id:
return False
await self.comments.delete_one({"_id": doc["_id"]})
return True
# ========================= REACTIONS =========================
async def toggle_reaction(self, team_id: str, memo_id: str, user_id: str, emoji: str) -> dict | None:
"""Toggle reaction on a memo. Returns updated reaction counts."""
if emoji not in VALID_REACTIONS:
return None
if not await self._is_member(team_id, user_id):
return None
# Check memo exists
memo = await self.team_memos.find_one({
"_id": ObjectId(memo_id),
"team_id": team_id,
})
if not memo:
return None
# Check if reaction already exists
existing = await self.reactions.find_one({
"memo_id": memo_id,
"user_id": user_id,
"emoji": emoji,
})
if existing:
# Remove reaction
await self.reactions.delete_one({"_id": existing["_id"]})
else:
# Add reaction
await self.reactions.insert_one({
"team_id": team_id,
"memo_id": memo_id,
"user_id": user_id,
"emoji": emoji,
"created_at": utc_now(),
})
# Return updated counts
pipeline = [
{"$match": {"memo_id": memo_id}},
{"$group": {"_id": "$emoji", "count": {"$sum": 1}}},
]
counts = {}
async for item in self.reactions.aggregate(pipeline):
counts[item["_id"]] = item["count"]
# User's reactions for this memo
user_emojis = await self.reactions.find(
{"memo_id": memo_id, "user_id": user_id}
).to_list(length=20)
user_reaction_list = [r["emoji"] for r in user_emojis]
return {
"memo_id": memo_id,
"reaction_counts": counts,
"user_reactions": user_reaction_list,
}
# ========================= HELPERS =========================
async def _is_member(self, team_id: str, user_id: str) -> bool:
return await self.members.find_one({
"team_id": team_id, "user_id": user_id
}) is not None
async def _is_owner(self, team_id: str, user_id: str) -> bool:
return await self.members.find_one({
"team_id": team_id, "user_id": user_id, "role": "owner"
}) is not None
def _team_to_response(self, doc: dict, member_count: int = 0) -> dict:
created = doc.get("created_at")
return {
"id": str(doc["_id"]),
"name": doc.get("name", ""),
"description": doc.get("description", ""),
"owner_id": doc.get("owner_id", ""),
"invite_code": doc.get("invite_code", ""),
"members": [],
"member_count": member_count,
"created_at": str(created) if created else None,
}
def _memo_to_response(self, doc: dict, profiles: dict = None) -> dict:
profiles = profiles or {}
created = doc.get("created_at")
updated = doc.get("updated_at")
merged = doc.get("merged_at")
creator_id = doc.get("creator_id", "")
creator_name, creator_avatar = self._get_profile(profiles, creator_id)
merged_by = doc.get("merged_by")
merged_by_name = ""
if merged_by:
merged_by_name, _ = self._get_profile(profiles, merged_by)
return {
"id": str(doc["_id"]),
"content": doc.get("content", ""),
"tags": doc.get("tags", []),
"creator_id": creator_id,
"creator_name": creator_name,
"creator_avatar": creator_avatar,
"team_id": doc.get("team_id", ""),
"space": doc.get("space", "draft"),
"status": doc.get("status", "draft"),
"pinned": doc.get("pinned", False),
"merged_at": str(merged) if merged else None,
"merged_by": merged_by,
"merged_by_name": merged_by_name,
"comment_count": 0,
"reaction_counts": {},
"user_reactions": [],
"review_reason": doc.get("review_reason", ""),
"created_at": str(created) if created else None,
"updated_at": str(updated) if updated else None,
}
# Singleton
team_service = TeamService()
""" """
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;
/* ===================================================================
TeamWorkspace.css — Premium team collaboration workspace styling.
Glass/gradient dark aesthetic with micro-animations.
=================================================================== */
/* ====================== TEAM HEADER ====================== */
.team-header {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
border-radius: 12px;
background: linear-gradient(135deg, hsl(var(--card)) 0%, hsl(var(--accent)) 100%);
border: 1px solid hsl(var(--border));
margin-bottom: 16px;
position: relative;
overflow: hidden;
}
.team-header::before {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(135deg, transparent 0%, hsl(var(--primary) / 0.05) 100%);
pointer-events: none;
}
.team-header-title {
font-size: 15px;
font-weight: 700;
letter-spacing: -0.02em;
color: hsl(var(--foreground));
}
.team-header-desc {
font-size: 11px;
color: hsl(var(--muted-foreground));
}
/* ====================== MEMBERS PANEL ====================== */
.members-panel {
background: hsl(var(--card));
border: 1px solid hsl(var(--border));
border-radius: 12px;
padding: 12px 14px;
margin-bottom: 14px;
animation: slideDown 0.2s ease-out;
}
.members-panel .member-row {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 0;
transition: background 0.15s;
border-radius: 6px;
}
.members-panel .member-row:hover {
background: hsl(var(--accent) / 0.5);
}
.member-avatar {
width: 24px;
height: 24px;
border-radius: 50%;
border: 1px solid hsl(var(--border));
object-fit: cover;
flex-shrink: 0;
}
.member-avatar-fallback {
width: 24px;
height: 24px;
border-radius: 50%;
background: linear-gradient(135deg, hsl(var(--primary) / 0.3), hsl(var(--primary) / 0.1));
color: hsl(var(--primary));
display: flex;
align-items: center;
justify-content: center;
font-size: 9px;
font-weight: 700;
flex-shrink: 0;
border: 1px solid hsl(var(--primary) / 0.2);
}
/* ====================== EDITOR ====================== */
.team-editor {
background: hsl(var(--card));
border: 1px solid hsl(var(--border));
border-radius: 12px;
padding: 14px 16px 10px;
margin-bottom: 14px;
transition: border-color 0.2s, box-shadow 0.2s;
}
.team-editor:focus-within {
border-color: hsl(var(--primary) / 0.5);
box-shadow: 0 0 0 3px hsl(var(--primary) / 0.08);
}
.team-editor textarea {
width: 100%;
min-height: 48px;
background: transparent;
border: none;
outline: none;
resize: none;
font-size: 13px;
line-height: 1.6;
color: hsl(var(--foreground));
font-family: inherit;
}
.team-editor textarea::placeholder {
color: hsl(var(--muted-foreground) / 0.5);
}
/* ====================== SEARCH ====================== */
.team-search {
position: relative;
margin-bottom: 12px;
}
.team-search input {
width: 100%;
padding: 7px 12px 7px 32px;
font-size: 12px;
background: hsl(var(--card));
border: 1px solid hsl(var(--border));
border-radius: 8px;
color: hsl(var(--foreground));
outline: none;
transition: border-color 0.2s;
}
.team-search input:focus {
border-color: hsl(var(--primary) / 0.4);
}
.team-search .search-icon {
position: absolute;
left: 10px;
top: 50%;
transform: translateY(-50%);
width: 14px;
height: 14px;
color: hsl(var(--muted-foreground));
}
/* ====================== TABS ====================== */
.team-tabs {
display: flex;
gap: 2px;
margin-bottom: 14px;
background: hsl(var(--accent) / 0.3);
border-radius: 8px;
padding: 3px;
}
.team-tab {
flex: 1;
padding: 6px 12px;
font-size: 12px;
font-weight: 600;
text-align: center;
border-radius: 6px;
border: none;
cursor: pointer;
transition: all 0.2s ease;
color: hsl(var(--muted-foreground));
background: transparent;
}
.team-tab.active {
background: hsl(var(--card));
color: hsl(var(--foreground));
box-shadow: 0 1px 3px hsl(0 0% 0% / 0.1);
}
.team-tab:hover:not(.active) {
color: hsl(var(--foreground));
}
/* ====================== MEMO CARDS ====================== */
.team-memo-card {
position: relative;
background: hsl(var(--card));
border: 1px solid hsl(var(--border));
border-radius: 10px;
padding: 12px 14px;
margin-bottom: 8px;
transition: all 0.2s ease;
cursor: default;
}
.team-memo-card:hover {
border-color: hsl(var(--primary) / 0.25);
box-shadow: 0 2px 12px hsl(var(--primary) / 0.05);
}
.team-memo-card.pinned {
border-left: 3px solid hsl(var(--primary) / 0.6);
}
.team-memo-card.pending {
border-left: 3px solid hsl(220 80% 60% / 0.6);
}
.team-memo-card.approved {
border-left: 3px solid hsl(140 60% 50% / 0.6);
}
/* Card header */
.memo-card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
}
.memo-card-author {
display: flex;
align-items: center;
gap: 6px;
}
.memo-card-author-name {
font-size: 12px;
font-weight: 600;
color: hsl(var(--foreground));
}
.memo-card-time {
font-size: 10px;
color: hsl(var(--muted-foreground));
}
.memo-card-actions {
display: flex;
gap: 2px;
opacity: 0;
transition: opacity 0.15s;
}
.team-memo-card:hover .memo-card-actions {
opacity: 1;
}
.memo-action-btn {
width: 26px;
height: 26px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
border: none;
background: transparent;
cursor: pointer;
transition: background 0.15s, color 0.15s;
color: hsl(var(--muted-foreground));
}
.memo-action-btn:hover {
background: hsl(var(--accent));
color: hsl(var(--foreground));
}
.memo-action-btn.danger:hover {
color: hsl(0 70% 60%);
background: hsl(0 70% 60% / 0.1);
}
.memo-action-btn.success:hover {
color: hsl(140 60% 50%);
background: hsl(140 60% 50% / 0.1);
}
.memo-action-btn.info:hover {
color: hsl(220 80% 60%);
background: hsl(220 80% 60% / 0.1);
}
/* Content */
.memo-card-content {
font-size: 13px;
line-height: 1.65;
color: hsl(var(--foreground) / 0.9);
white-space: pre-wrap;
word-break: break-word;
margin-bottom: 6px;
}
/* Tags */
.memo-card-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-bottom: 4px;
}
.memo-tag {
font-size: 11px;
font-weight: 500;
padding: 1px 6px;
border-radius: 4px;
background: hsl(var(--primary) / 0.1);
color: hsl(var(--primary));
transition: background 0.15s;
cursor: pointer;
}
.memo-tag:hover {
background: hsl(var(--primary) / 0.2);
}
/* Status badge */
.status-badge {
display: inline-flex;
align-items: center;
gap: 3px;
padding: 2px 7px;
border-radius: 4px;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.01em;
}
.status-badge.status-draft {
background: hsl(0 0% 50% / 0.15);
color: hsl(0 0% 55%);
}
.status-badge.status-pending {
background: hsl(220 80% 60% / 0.15);
color: hsl(220 80% 65%);
}
.status-badge.status-approved {
background: hsl(140 60% 50% / 0.15);
color: hsl(140 60% 55%);
}
.status-badge.status-merged {
background: hsl(160 50% 45% / 0.15);
color: hsl(160 50% 50%);
}
/* ====================== REACTIONS ====================== */
.reaction-bar {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 4px;
}
.reaction-pill {
display: inline-flex;
align-items: center;
gap: 3px;
padding: 2px 8px;
border-radius: 999px;
font-size: 11px;
border: 1px solid hsl(var(--border));
background: transparent;
cursor: pointer;
transition: all 0.15s;
color: hsl(var(--muted-foreground));
}
.reaction-pill:hover {
border-color: hsl(var(--primary) / 0.4);
color: hsl(var(--foreground));
}
.reaction-pill.active {
border-color: hsl(var(--primary) / 0.5);
background: hsl(var(--primary) / 0.1);
color: hsl(var(--primary));
}
.reaction-picker {
position: absolute;
bottom: calc(100% + 4px);
left: 0;
display: none;
background: hsl(var(--card));
border: 1px solid hsl(var(--border));
border-radius: 10px;
padding: 4px;
gap: 2px;
box-shadow: 0 4px 16px hsl(0 0% 0% / 0.15);
z-index: 10;
}
.reaction-add:hover .reaction-picker {
display: flex;
}
.reaction-pick-btn {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
border: none;
background: transparent;
cursor: pointer;
transition: background 0.1s;
font-size: 13px;
}
.reaction-pick-btn:hover {
background: hsl(var(--accent));
}
/* ====================== COMMENTS ====================== */
.comment-thread {
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid hsl(var(--border) / 0.5);
animation: slideDown 0.15s ease-out;
}
.comment-item {
display: flex;
gap: 8px;
padding: 4px 0;
}
.comment-item .comment-delete {
opacity: 0;
transition: opacity 0.15s;
}
.comment-item:hover .comment-delete {
opacity: 1;
}
.comment-input-row {
display: flex;
align-items: center;
gap: 6px;
margin-top: 6px;
}
.comment-input-row input {
flex: 1;
padding: 5px 10px;
font-size: 12px;
background: hsl(var(--accent) / 0.4);
border: 1px solid hsl(var(--border));
border-radius: 6px;
color: hsl(var(--foreground));
outline: none;
transition: border-color 0.2s;
}
.comment-input-row input:focus {
border-color: hsl(var(--primary) / 0.4);
}
/* ====================== EDIT MODE ====================== */
.edit-textarea {
width: 100%;
min-height: 60px;
padding: 8px 10px;
font-size: 13px;
line-height: 1.6;
background: hsl(var(--accent) / 0.3);
border: 1px solid hsl(var(--primary) / 0.3);
border-radius: 8px;
color: hsl(var(--foreground));
outline: none;
resize: none;
font-family: inherit;
}
.edit-actions {
display: flex;
justify-content: flex-end;
gap: 6px;
margin-top: 6px;
}
.edit-btn {
padding: 4px 12px;
font-size: 11px;
font-weight: 600;
border-radius: 6px;
border: none;
cursor: pointer;
transition: all 0.15s;
}
.edit-btn.cancel {
background: hsl(var(--accent));
color: hsl(var(--muted-foreground));
border: 1px solid hsl(var(--border));
}
.edit-btn.cancel:hover {
background: hsl(var(--accent) / 0.8);
}
.edit-btn.save {
background: hsl(var(--primary));
color: hsl(var(--primary-foreground));
}
.edit-btn.save:hover {
opacity: 0.9;
}
/* ====================== SECTION LABELS ====================== */
.section-label {
padding: 12px 2px 4px;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: hsl(var(--muted-foreground) / 0.4);
user-select: none;
}
/* ====================== EMPTY STATE ====================== */
.team-empty {
padding: 48px 0;
text-align: center;
font-size: 13px;
color: hsl(var(--muted-foreground));
}
/* ====================== REVIEW DIALOG ====================== */
.review-dialog {
margin-top: 8px;
padding: 10px 12px;
border: 1px solid hsl(0 60% 50% / 0.3);
border-radius: 8px;
background: hsl(0 60% 50% / 0.05);
animation: slideDown 0.15s ease-out;
}
.review-dialog input {
width: 100%;
padding: 5px 10px;
font-size: 12px;
background: hsl(var(--background));
border: 1px solid hsl(var(--border));
border-radius: 6px;
color: hsl(var(--foreground));
outline: none;
margin: 6px 0;
}
/* ====================== ANIMATIONS ====================== */
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* ====================== INVITE CODE ====================== */
.invite-code {
font-family: 'SF Mono', 'Fira Code', 'JetBrains Mono', monospace;
font-size: 11px;
padding: 2px 8px;
background: hsl(var(--accent));
border-radius: 4px;
letter-spacing: 0.04em;
user-select: all;
}
/**
* Team Workspace — Premium team collaboration workspace.
* Features: real user names, comments, inline edit, review flow, reactions, search.
*/
import { useEffect, useState, useRef, useMemo, useCallback } from "react";
import { useParams, useNavigate } from "react-router-dom";
import {
ArrowLeftIcon,
BookmarkIcon,
CheckCircle2Icon,
CopyIcon,
TrashIcon,
UndoIcon,
UsersIcon,
MessageCircleIcon,
SendIcon,
PencilIcon,
XIcon,
SearchIcon,
SendHorizonal,
ShieldCheckIcon,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import {
getTeam,
listDrafts,
listMain,
createDraft,
mergeMemo,
unmergeMemo,
deleteTeamMemo,
pinMemo,
updateTeamMemo,
requestReview,
submitReview,
listComments,
createComment,
deleteComment,
toggleReaction,
} from "@/service/teamService";
import type { Team, TeamMemo, TeamComment } from "@/service/teamService";
import "./TeamWorkspace.css";
type Tab = "draft" | "main";
const STATUS_MAP: Record<string, { label: string; css: string; icon: string }> = {
draft: { label: "Nháp", css: "status-draft", icon: "📝" },
pending_review: { label: "Chờ duyệt", css: "status-pending", icon: "👀" },
approved: { label: "Đã duyệt", css: "status-approved", icon: "✅" },
merged: { label: "Đã merge", css: "status-merged", icon: "🎯" },
};
const REACTION_EMOJIS = ["👍", "💡", "❓", "✅", "🔥", "❤️"];
const TeamWorkspace = () => {
const { teamId } = useParams<{ teamId: string }>();
const navigate = useNavigate();
const editorRef = useRef<HTMLTextAreaElement>(null);
const [team, setTeam] = useState<Team | null>(null);
const [drafts, setDrafts] = useState<TeamMemo[]>([]);
const [mainMemos, setMainMemos] = useState<TeamMemo[]>([]);
const [tab, setTab] = useState<Tab>("draft");
const [content, setContent] = useState("");
const [loading, setLoading] = useState(true);
const [showMembers, setShowMembers] = useState(false);
const [copied, setCopied] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
// Edit state
const [editingId, setEditingId] = useState<string | null>(null);
const [editContent, setEditContent] = useState("");
// Comment state
const [openCommentId, setOpenCommentId] = useState<string | null>(null);
const [comments, setComments] = useState<TeamComment[]>([]);
const [commentText, setCommentText] = useState("");
const [loadingComments, setLoadingComments] = useState(false);
// Review
const [reviewMemoId, setReviewMemoId] = useState<string | null>(null);
const [reviewReason, setReviewReason] = useState("");
const isOwner = useMemo(() => {
return team?.members?.some((m) => m.role === "owner") ?? false;
}, [team]);
const fetchAll = useCallback(async () => {
if (!teamId) return;
try {
setLoading(true);
const [t, d, m] = await Promise.all([getTeam(teamId), listDrafts(teamId), listMain(teamId)]);
setTeam(t);
setDrafts(d);
setMainMemos(m);
} catch (e: any) {
console.error(e);
} finally {
setLoading(false);
}
}, [teamId]);
useEffect(() => { fetchAll(); }, [fetchAll]);
// ========================= ACTIONS =========================
const handleSave = async () => {
if (!content.trim() || !teamId) return;
const tags: string[] = [];
let m;
const re = /#(\w+)/g;
while ((m = re.exec(content)) !== null) tags.push(m[1]);
await createDraft(teamId, content.trim(), tags);
setContent("");
fetchAll();
editorRef.current?.focus();
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSave(); }
};
const onMerge = async (id: string) => { if (teamId) { await mergeMemo(teamId, id); fetchAll(); } };
const onUnmerge = async (id: string) => { if (teamId) { await unmergeMemo(teamId, id); fetchAll(); } };
const onDelete = async (id: string) => { if (teamId && confirm("Xóa note này?")) { await deleteTeamMemo(teamId, id); fetchAll(); } };
const onPin = async (id: string) => { if (teamId) { await pinMemo(teamId, id); fetchAll(); } };
const startEdit = (memo: TeamMemo) => { setEditingId(memo.id); setEditContent(memo.content); };
const cancelEdit = () => { setEditingId(null); setEditContent(""); };
const saveEdit = async () => {
if (!teamId || !editingId || !editContent.trim()) return;
const tags: string[] = [];
let m;
const re = /#(\w+)/g;
while ((m = re.exec(editContent)) !== null) tags.push(m[1]);
await updateTeamMemo(teamId, editingId, editContent.trim(), tags);
cancelEdit();
fetchAll();
};
const onRequestReview = async (id: string) => { if (teamId) { await requestReview(teamId, id); fetchAll(); } };
const onSubmitReview = async (id: string, action: "approve" | "reject") => {
if (teamId) { await submitReview(teamId, id, action, reviewReason); setReviewMemoId(null); setReviewReason(""); fetchAll(); }
};
const toggleComments = async (memoId: string) => {
if (openCommentId === memoId) { setOpenCommentId(null); return; }
setOpenCommentId(memoId);
setLoadingComments(true);
try { if (teamId) { setComments(await listComments(teamId, memoId)); } }
catch (e) { console.error(e); }
finally { setLoadingComments(false); }
};
const onSendComment = async () => {
if (!teamId || !openCommentId || !commentText.trim()) return;
const newComment = await createComment(teamId, openCommentId, commentText.trim());
setComments((prev) => [...prev, newComment]);
setCommentText("");
const updateMemos = (memos: TeamMemo[]) => memos.map((m) => m.id === openCommentId ? { ...m, comment_count: m.comment_count + 1 } : m);
setDrafts(updateMemos);
setMainMemos(updateMemos);
};
const onDeleteComment = async (commentId: string) => { if (teamId) { await deleteComment(teamId, commentId); setComments((prev) => prev.filter((c) => c.id !== commentId)); } };
const onToggleReaction = async (memoId: string, emoji: string) => {
if (!teamId) return;
const result = await toggleReaction(teamId, memoId, emoji);
const updateMemos = (memos: TeamMemo[]) => memos.map((m) => m.id === memoId ? { ...m, reaction_counts: result.reaction_counts, user_reactions: result.user_reactions } : m);
setDrafts(updateMemos);
setMainMemos(updateMemos);
};
const copyCode = () => {
if (team?.invite_code) { navigator.clipboard.writeText(team.invite_code); setCopied(true); setTimeout(() => setCopied(false), 2000); }
};
const fmtTime = (s?: string) => {
if (!s) return "";
const ms = Date.now() - new Date(s).getTime();
const min = Math.floor(ms / 60000);
if (min < 1) return "vừa xong";
if (min < 60) return `${min}m`;
const h = Math.floor(min / 60);
if (h < 24) return `${h}h`;
return `${Math.floor(h / 24)}d`;
};
const getInitials = (name: string) => name.split(" ").map((w) => w[0]).join("").toUpperCase().slice(0, 2) || "?";
const memos = tab === "draft" ? drafts : mainMemos;
const filteredMemos = useMemo(() => {
if (!searchQuery.trim()) return memos;
const q = searchQuery.toLowerCase();
return memos.filter((m) => m.content.toLowerCase().includes(q) || m.tags.some((t) => t.toLowerCase().includes(q)) || m.creator_name.toLowerCase().includes(q));
}, [memos, searchQuery]);
const groups = useMemo(() => {
const pinned = filteredMemos.filter((m) => m.pinned);
const rest = filteredMemos.filter((m) => !m.pinned);
const byTag: Record<string, TeamMemo[]> = {};
const noTag: TeamMemo[] = [];
rest.forEach((m) => {
if (m.tags.length > 0) { (byTag[m.tags[0]] ||= []).push(m); } else { noTag.push(m); }
});
return { pinned, byTag, noTag };
}, [filteredMemos]);
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin w-6 h-6 border-2 border-primary border-t-transparent rounded-full" />
</div>
);
}
// ========================= SUB COMPONENTS =========================
const Avatar = ({ src, name, size = 24 }: { src?: string; name: string; size?: number }) => {
if (src) return <img src={src} alt={name} className="member-avatar" style={{ width: size, height: size }} />;
return <div className="member-avatar-fallback" style={{ width: size, height: size, fontSize: Math.max(8, size * 0.38) }}>{getInitials(name)}</div>;
};
const StatusBadge = ({ status }: { status: string }) => {
const cfg = STATUS_MAP[status] || STATUS_MAP.draft;
return <span className={cn("status-badge", cfg.css)}>{cfg.icon} {cfg.label}</span>;
};
const renderMemo = (memo: TeamMemo) => {
const isEditing = editingId === memo.id;
return (
<article
key={memo.id}
className={cn(
"team-memo-card",
memo.pinned && "pinned",
memo.status === "pending_review" && "pending",
memo.status === "approved" && "approved",
)}
>
{/* Header */}
<div className="memo-card-header">
<div className="memo-card-author">
<Avatar src={memo.creator_avatar} name={memo.creator_name} size={22} />
<span className="memo-card-author-name">{memo.creator_name}</span>
<span className="memo-card-time">{fmtTime(memo.created_at)}</span>
{memo.status !== "draft" && <StatusBadge status={memo.status} />}
</div>
<div className="memo-card-actions">
{/* Pin */}
<button className={cn("memo-action-btn", memo.pinned && "!opacity-100")} onClick={() => onPin(memo.id)} title={memo.pinned ? "Bỏ ghim" : "Ghim"}>
<BookmarkIcon className={cn("w-3.5 h-3.5", memo.pinned && "fill-current text-primary")} />
</button>
{/* Edit (drafts only) */}
{tab === "draft" && (
<button className="memo-action-btn" onClick={() => startEdit(memo)} title="Sửa">
<PencilIcon className="w-3.5 h-3.5" />
</button>
)}
{/* Request review */}
{tab === "draft" && memo.status === "draft" && (
<button className="memo-action-btn info" onClick={() => onRequestReview(memo.id)} title="Gửi duyệt">
<SendHorizonal className="w-3.5 h-3.5" />
</button>
)}
{/* Approve/Reject (owner, pending) */}
{isOwner && memo.status === "pending_review" && (
<>
<button className="memo-action-btn success" style={{ opacity: 1 }} onClick={() => onSubmitReview(memo.id, "approve")} title="Duyệt">
<ShieldCheckIcon className="w-3.5 h-3.5" />
</button>
<button className="memo-action-btn danger" style={{ opacity: 1 }} onClick={() => setReviewMemoId(memo.id)} title="Từ chối">
<XIcon className="w-3.5 h-3.5" />
</button>
</>
)}
{/* Merge (owner) */}
{tab === "draft" && isOwner && (memo.status === "approved" || memo.status === "draft") && (
<button className="memo-action-btn success" onClick={() => onMerge(memo.id)} title="Merge → Chính">
<CheckCircle2Icon className="w-3.5 h-3.5" />
</button>
)}
{/* Unmerge (owner, main) */}
{tab === "main" && isOwner && (
<button className="memo-action-btn" onClick={() => onUnmerge(memo.id)} title="Đưa về Nháp">
<UndoIcon className="w-3.5 h-3.5" />
</button>
)}
{/* Delete */}
<button className="memo-action-btn danger" onClick={() => onDelete(memo.id)} title="Xóa">
<TrashIcon className="w-3.5 h-3.5" />
</button>
</div>
</div>
{/* Content */}
{isEditing ? (
<div>
<textarea className="edit-textarea" value={editContent} onChange={(e) => setEditContent(e.target.value)} autoFocus />
<div className="edit-actions">
<button className="edit-btn cancel" onClick={cancelEdit}>Hủy</button>
<button className="edit-btn save" onClick={saveEdit}>Lưu</button>
</div>
</div>
) : (
<div className="memo-card-content" onDoubleClick={() => tab === "draft" && startEdit(memo)}>
{memo.content}
</div>
)}
{/* Tags */}
{memo.tags.length > 0 && (
<div className="memo-card-tags">
{memo.tags.map((tag) => <span key={tag} className="memo-tag">#{tag}</span>)}
</div>
)}
{/* Merged info */}
{memo.merged_by_name && (
<div className="memo-card-time" style={{ marginTop: 2 }}>
Merged by <strong>{memo.merged_by_name}</strong> · {fmtTime(memo.merged_at)}
</div>
)}
{/* Review reason */}
{memo.review_reason && memo.status === "draft" && (
<div style={{ fontSize: 11, color: "hsl(40 80% 55%)", background: "hsl(40 80% 55% / 0.08)", borderRadius: 6, padding: "4px 8px", marginTop: 4 }}>
💬 Lý do từ chối: {memo.review_reason}
</div>
)}
{/* Reactions */}
<div className="reaction-bar">
{Object.entries(memo.reaction_counts || {}).map(([emoji, count]) => (
<button
key={emoji}
className={cn("reaction-pill", memo.user_reactions?.includes(emoji) && "active")}
onClick={(e) => { e.stopPropagation(); onToggleReaction(memo.id, emoji); }}
>
<span>{emoji}</span>
<span style={{ fontVariantNumeric: "tabular-nums" }}>{count}</span>
</button>
))}
<div className="reaction-add" style={{ position: "relative" }}>
<button className="reaction-pill" style={{ borderStyle: "dashed" }}>+</button>
<div className="reaction-picker">
{REACTION_EMOJIS.map((emoji) => (
<button key={emoji} className="reaction-pick-btn" onClick={(e) => { e.stopPropagation(); onToggleReaction(memo.id, emoji); }}>
{emoji}
</button>
))}
</div>
</div>
</div>
{/* Comment toggle */}
<button
onClick={() => toggleComments(memo.id)}
style={{ display: "flex", alignItems: "center", gap: 4, fontSize: 11, color: "hsl(var(--muted-foreground))", background: "none", border: "none", cursor: "pointer", marginTop: 4, padding: 0, transition: "color 0.15s" }}
>
<MessageCircleIcon className="w-3.5 h-3.5" />
{memo.comment_count > 0 ? `${memo.comment_count} comments` : "Comment"}
</button>
{/* Comment thread */}
{openCommentId === memo.id && (
<div className="comment-thread">
{loadingComments ? (
<div className="memo-card-time" style={{ padding: 4 }}>Đang tải...</div>
) : comments.length === 0 ? (
<div className="memo-card-time" style={{ padding: 4 }}>Chưa có comment nào</div>
) : (
comments.map((c) => (
<div key={c.id} className="comment-item">
<Avatar src={c.user_avatar} name={c.user_name} size={18} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
<span style={{ fontSize: 11, fontWeight: 600 }}>{c.user_name}</span>
<span className="memo-card-time">{fmtTime(c.created_at)}</span>
</div>
<p style={{ fontSize: 12, color: "hsl(var(--muted-foreground))", lineHeight: 1.5, margin: 0 }}>{c.content}</p>
</div>
<button className="comment-delete memo-action-btn danger" onClick={() => onDeleteComment(c.id)} style={{ width: 20, height: 20 }}>
<XIcon className="w-2.5 h-2.5" />
</button>
</div>
))
)}
<div className="comment-input-row">
<input
value={commentText}
onChange={(e) => setCommentText(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); onSendComment(); } }}
placeholder="Viết comment..."
/>
<button className="memo-action-btn info" onClick={onSendComment} style={{ opacity: commentText.trim() ? 1 : 0.3 }}>
<SendIcon className="w-3.5 h-3.5" />
</button>
</div>
</div>
)}
{/* Review reject dialog */}
{reviewMemoId === memo.id && (
<div className="review-dialog">
<div style={{ fontSize: 11, fontWeight: 600, color: "hsl(0 60% 60%)" }}>Từ chối note này?</div>
<input
value={reviewReason}
onChange={(e) => setReviewReason(e.target.value)}
placeholder="Lý do (tuỳ chọn)..."
autoFocus
/>
<div className="edit-actions">
<button className="edit-btn cancel" onClick={() => setReviewMemoId(null)}>Huỷ</button>
<button className="edit-btn save" style={{ background: "hsl(0 60% 55%)" }} onClick={() => onSubmitReview(memo.id, "reject")}>Từ chối</button>
</div>
</div>
)}
</article>
);
};
return (
<div className="w-full max-w-3xl mx-auto px-4 py-4">
{/* Team header */}
<div className="team-header">
<button onClick={() => navigate("/app/teams")} style={{ background: "none", border: "none", cursor: "pointer", padding: 4 }}>
<ArrowLeftIcon className="w-4 h-4 text-muted-foreground" />
</button>
<div style={{ flex: 1, minWidth: 0 }}>
<div className="team-header-title">{team?.name}</div>
{team?.description && <div className="team-header-desc">{team.description}</div>}
</div>
<button
onClick={() => setShowMembers(!showMembers)}
style={{ display: "flex", alignItems: "center", gap: 4, background: "none", border: "none", cursor: "pointer", fontSize: 12, color: "hsl(var(--muted-foreground))" }}
>
<UsersIcon className="w-4 h-4" />
<span>{team?.member_count}</span>
</button>
</div>
{/* Members panel */}
{showMembers && team && (
<div className="members-panel">
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 8 }}>
<span style={{ fontSize: 11, fontWeight: 700, color: "hsl(var(--muted-foreground))", textTransform: "uppercase", letterSpacing: "0.05em" }}>Members</span>
<button onClick={copyCode} style={{ display: "flex", alignItems: "center", gap: 4, background: "none", border: "none", cursor: "pointer", color: "hsl(var(--muted-foreground))" }}>
<CopyIcon className="w-3 h-3" />
<span className="invite-code">{copied ? "Copied!" : team.invite_code}</span>
</button>
</div>
{team.members?.map((m) => (
<div key={m.user_id} className="member-row">
<Avatar src={m.avatar_url} name={m.display_name || m.user_id} />
<span style={{ flex: 1, fontSize: 12, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{m.display_name || m.user_id}
</span>
<Badge variant="secondary" className="text-[9px] h-4 px-1.5">{m.role}</Badge>
</div>
))}
</div>
)}
{/* Editor */}
<div className="team-editor">
<textarea
ref={editorRef}
value={content}
onChange={(e) => setContent(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Ghi nháp nhanh vào team... (Enter để lưu)"
rows={2}
/>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginTop: 6 }}>
<span className="memo-card-time" style={{ fontVariantNumeric: "tabular-nums" }}>
{content.trim() ? `${content.trim().split(/\s+/).length}w · ${content.length}c` : ""}
</span>
<Button onClick={handleSave} disabled={!content.trim()} size="sm" className="h-7 text-xs px-4">
Save
</Button>
</div>
</div>
{/* Search */}
<div className="team-search">
<SearchIcon className="search-icon" />
<input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Tìm kiếm trong team..."
/>
</div>
{/* Tabs */}
<div className="team-tabs">
<button className={cn("team-tab", tab === "draft" && "active")} onClick={() => setTab("draft")}>
Nháp ({drafts.length})
</button>
<button className={cn("team-tab", tab === "main" && "active")} onClick={() => setTab("main")}>
Chính ({mainMemos.length})
</button>
</div>
{/* Memo list */}
{filteredMemos.length === 0 ? (
<div className="team-empty">
{searchQuery ? "Không tìm thấy kết quả" : tab === "draft" ? "Chưa có nháp nào — viết gì đó ở trên đi!" : "Chưa có gì được merge"}
</div>
) : (
<>
{groups.pinned.length > 0 && (
<>
<div className="section-label">📌 Ghim</div>
{groups.pinned.map(renderMemo)}
</>
)}
{Object.entries(groups.byTag).map(([tag, items]) => (
<div key={tag}>
<div className="section-label">#{tag}</div>
{items.map(renderMemo)}
</div>
))}
{groups.noTag.length > 0 && (
<>
{(groups.pinned.length > 0 || Object.keys(groups.byTag).length > 0) && <div className="section-label">Khác</div>}
{groups.noTag.map(renderMemo)}
</>
)}
</>
)}
</div>
);
};
export default TeamWorkspace;
/**
* Teams page — List all teams user belongs to + create/join team.
*/
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { PlusIcon, UsersIcon, LogInIcon, TrashIcon, LogOutIcon } from "lucide-react";
import { listTeams, createTeam, joinTeam, deleteTeam, leaveTeam } from "@/service/teamService";
import type { Team } from "@/service/teamService";
const Teams = () => {
const navigate = useNavigate();
const [teams, setTeams] = useState<Team[]>([]);
const [loading, setLoading] = useState(true);
const [showCreate, setShowCreate] = useState(false);
const [showJoin, setShowJoin] = useState(false);
const [newName, setNewName] = useState("");
const [newDesc, setNewDesc] = useState("");
const [inviteCode, setInviteCode] = useState("");
const [error, setError] = useState("");
const fetchTeams = async () => {
try {
setLoading(true);
const data = await listTeams();
setTeams(data);
} catch (e: any) {
setError(e.message);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchTeams();
}, []);
const handleCreate = async () => {
if (!newName.trim()) return;
try {
setError("");
await createTeam(newName.trim(), newDesc.trim());
setNewName("");
setNewDesc("");
setShowCreate(false);
fetchTeams();
} catch (e: any) {
setError(e.message);
}
};
const handleJoin = async () => {
if (!inviteCode.trim()) return;
try {
setError("");
await joinTeam(inviteCode.trim());
setInviteCode("");
setShowJoin(false);
fetchTeams();
} catch (e: any) {
setError(e.message);
}
};
const handleDelete = async (teamId: string) => {
if (!confirm("Xóa team này? Tất cả memos sẽ bị mất.")) return;
try {
await deleteTeam(teamId);
fetchTeams();
} catch (e: any) {
setError(e.message);
}
};
const handleLeave = async (teamId: string) => {
if (!confirm("Rời team này?")) return;
try {
await leaveTeam(teamId);
fetchTeams();
} catch (e: any) {
setError(e.message);
}
};
return (
<div className="max-w-2xl mx-auto px-4 py-8">
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold flex items-center gap-2">
<UsersIcon className="w-6 h-6" />
Teams
</h1>
<div className="flex gap-2">
<button
onClick={() => { setShowJoin(!showJoin); setShowCreate(false); }}
className="px-3 py-1.5 text-sm rounded-lg border border-border hover:bg-accent transition-colors flex items-center gap-1"
>
<LogInIcon className="w-4 h-4" />
Join
</button>
<button
onClick={() => { setShowCreate(!showCreate); setShowJoin(false); }}
className="px-3 py-1.5 text-sm rounded-lg bg-amber-500 text-white hover:bg-amber-600 transition-colors flex items-center gap-1"
>
<PlusIcon className="w-4 h-4" />
New Team
</button>
</div>
</div>
{error && (
<div className="mb-4 p-3 rounded-lg bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 text-sm">
{error}
</div>
)}
{/* Create Team Form */}
{showCreate && (
<div className="mb-6 p-4 rounded-xl border border-border bg-card">
<h3 className="font-semibold mb-3">Tạo Team Mới</h3>
<input
type="text"
placeholder="Tên team..."
value={newName}
onChange={(e) => setNewName(e.target.value)}
className="w-full px-3 py-2 mb-2 rounded-lg border border-border bg-background text-sm"
autoFocus
/>
<input
type="text"
placeholder="Mô tả (optional)..."
value={newDesc}
onChange={(e) => setNewDesc(e.target.value)}
className="w-full px-3 py-2 mb-3 rounded-lg border border-border bg-background text-sm"
/>
<div className="flex gap-2 justify-end">
<button onClick={() => setShowCreate(false)} className="px-3 py-1.5 text-sm rounded-lg border border-border hover:bg-accent">
Hủy
</button>
<button onClick={handleCreate} className="px-3 py-1.5 text-sm rounded-lg bg-amber-500 text-white hover:bg-amber-600">
Tạo
</button>
</div>
</div>
)}
{/* Join Team Form */}
{showJoin && (
<div className="mb-6 p-4 rounded-xl border border-border bg-card">
<h3 className="font-semibold mb-3">Join Team</h3>
<input
type="text"
placeholder="Paste invite code..."
value={inviteCode}
onChange={(e) => setInviteCode(e.target.value)}
className="w-full px-3 py-2 mb-3 rounded-lg border border-border bg-background text-sm"
autoFocus
/>
<div className="flex gap-2 justify-end">
<button onClick={() => setShowJoin(false)} className="px-3 py-1.5 text-sm rounded-lg border border-border hover:bg-accent">
Hủy
</button>
<button onClick={handleJoin} className="px-3 py-1.5 text-sm rounded-lg bg-amber-500 text-white hover:bg-amber-600">
Join
</button>
</div>
</div>
)}
{/* Team List */}
{loading ? (
<div className="text-center py-12 text-muted-foreground">Đang tải...</div>
) : teams.length === 0 ? (
<div className="text-center py-16">
<UsersIcon className="w-12 h-12 mx-auto mb-4 text-muted-foreground/30" />
<p className="text-muted-foreground mb-2">Chưa có team nào</p>
<p className="text-sm text-muted-foreground/60">Tạo team mới hoặc join bằng invite code</p>
</div>
) : (
<div className="space-y-3">
{teams.map((team) => (
<div
key={team.id}
className="p-4 rounded-xl border border-border bg-card hover:border-amber-400/50 transition-all cursor-pointer group"
onClick={() => navigate(`/app/teams/${team.id}`)}
>
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-base truncate group-hover:text-amber-500 transition-colors">
{team.name}
</h3>
{team.description && (
<p className="text-sm text-muted-foreground mt-0.5 truncate">{team.description}</p>
)}
<div className="flex items-center gap-3 mt-2 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<UsersIcon className="w-3.5 h-3.5" />
{team.member_count} members
</span>
<span className="font-mono text-[10px] bg-muted px-1.5 py-0.5 rounded">
{team.invite_code}
</span>
</div>
</div>
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity" onClick={(e) => e.stopPropagation()}>
{team.members?.some(m => m.role === "owner") ? (
<button
onClick={() => handleDelete(team.id)}
className="p-1.5 rounded-lg text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20"
title="Xóa team"
>
<TrashIcon className="w-4 h-4" />
</button>
) : (
<button
onClick={() => handleLeave(team.id)}
className="p-1.5 rounded-lg text-muted-foreground hover:bg-accent"
title="Rời team"
>
<LogOutIcon className="w-4 h-4" />
</button>
)}
</div>
</div>
</div>
))}
</div>
)}
</div>
);
};
export default Teams;
...@@ -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)
# CuCu Note — Team Draft → Main
## Quy trình A-Z
```
1. User tạo Team
└── Invite members (link/email)
2. Trong Team có 2 vùng:
┌─────────────┐ ┌─────────────┐
│ NHÁP │ │ CHÍNH │
│ (Draft) │ │ (Main) │
└─────────────┘ └─────────────┘
3. Flow:
Tạo note nháp → Team comment góp ý → Review → Merge vào Chính
```
## Trạng thái triển khai
### Backend ✅ DONE
- [x] Schema: `Team`, `TeamMember`, `TeamMemo` (Pydantic) — `backend/common/team/schemas.py`
- [x] Service: `TeamService` 17 methods — `backend/common/team/services.py`
- Team CRUD: `create_team`, `list_teams`, `get_team`, `update_team`, `delete_team`
- Members: `join_team`, `remove_member`, `leave_team`
- Memos: `create_draft`, `list_drafts`, `list_main`, `merge_to_main`, `unmerge_to_draft`
- Actions: `pin_memo`, `update_memo`, `delete_memo`
- Helpers: `_is_member`, `_is_owner`, `_team_to_response`, `_memo_to_response`
- [x] API Routes: 17 endpoints `/api/v1/teams/...``backend/api/team_routes.py`
- [x] MongoDB: 3 collections + indexes — `backend/common/mongo_client.py`
- `cuccu_teams`, `cuccu_team_members`, `cuccu_team_memos`
- [x] Server: router registered — `backend/server.py`
- [x] Test: full A-Z flow passed (create → join → draft → merge → unmerge → delete)
### Frontend ✅ DONE (v1)
- [x] API Service: `frontend/src/service/teamService.ts` — 15 API calls + types
- [x] Teams list page: `frontend/src/pages/Teams.tsx` — create, join, list, delete/leave
- [x] Team workspace: `frontend/src/pages/TeamWorkspace.tsx`
- Editor giống MemoEditor (cùng classes)
- Cards giống MemoView (cùng MEMO_CARD_BASE_CLASSES)
- Tab: Nháp / Chính
- Pin (ghim lên đầu)
- Group by tag (#idea, #plan, #blocker...)
- Actions hover: Pin, Merge, Unmerge, Delete (shadcn Tooltip)
- [x] Routes: `/app/teams` + `/app/teams/:teamId``frontend/src/router/index.tsx`
- [x] Sidebar: Teams section + dynamic team list — `frontend/src/components/Navigation.tsx`
### Chưa làm
- [ ] Guest mode (xem file `plan/guest_mode.md`)
- [ ] Comment trên team memos
- [ ] Export → Notion
- [ ] AI tóm tắt team notes
- [ ] Cleanup cronjob cho guest data
## API Endpoints
### Team APIs
| Method | Path | Mô tả | Status |
|--------|------|-------|--------|
| POST | `/api/v1/teams` | Tạo team | ✅ |
| GET | `/api/v1/teams` | List teams của user | ✅ |
| GET | `/api/v1/teams/:id` | Chi tiết team | ✅ |
| PATCH | `/api/v1/teams/:id` | Sửa team | ✅ |
| DELETE | `/api/v1/teams/:id` | Xóa team | ✅ |
### Team Member APIs
| Method | Path | Mô tả | Status |
|--------|------|-------|--------|
| POST | `/api/v1/teams/join` | Join bằng invite code | ✅ |
| GET | `/api/v1/teams/:id/members` | List members | ✅ |
| DELETE | `/api/v1/teams/:id/members/:uid` | Xóa member | ✅ |
| POST | `/api/v1/teams/:id/leave` | Rời team | ✅ |
### Team Memo APIs
| Method | Path | Mô tả | Status |
|--------|------|-------|--------|
| POST | `/api/v1/teams/:id/memos` | Tạo note nháp | ✅ |
| GET | `/api/v1/teams/:id/drafts` | List nháp | ✅ |
| GET | `/api/v1/teams/:id/main` | List bản chính | ✅ |
| PATCH | `/api/v1/teams/:id/memos/:mid` | Sửa memo | ✅ |
| DELETE | `/api/v1/teams/:id/memos/:mid` | Xóa memo | ✅ |
| POST | `/api/v1/teams/:id/memos/:mid/merge` | Merge nháp → chính | ✅ |
| POST | `/api/v1/teams/:id/memos/:mid/unmerge` | Unmerge chính → nháp | ✅ |
| POST | `/api/v1/teams/:id/memos/:mid/pin` | Pin/unpin memo | ✅ |
## Files đã sửa/tạo
### Backend (5 files)
```
backend/common/team/__init__.py — NEW
backend/common/team/schemas.py — NEW (Pydantic models)
backend/common/team/services.py — NEW (TeamService, 17 methods)
backend/api/team_routes.py — NEW (17 API endpoints)
backend/common/mongo_client.py — MODIFIED (3 collections + indexes)
backend/server.py — MODIFIED (register team_router)
```
### Frontend (5 files)
```
frontend/src/service/teamService.ts — NEW (API client + types)
frontend/src/pages/Teams.tsx — NEW (team list page)
frontend/src/pages/TeamWorkspace.tsx — NEW (workspace: editor, cards, tabs)
frontend/src/router/routes.ts — MODIFIED (TEAMS route)
frontend/src/router/index.tsx — MODIFIED (lazy routes)
frontend/src/components/Navigation.tsx — MODIFIED (sidebar team list + imports)
```
## Bugs biết
- Clerk CORS block khi truy cập bằng IP (160.191.50.138) thay vì localhost → cần thêm domain vào Clerk Dashboard
"""
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())
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