Commit 0a87a5d8 authored by Hoanganhvu123's avatar Hoanganhvu123

feat: Inbox notifications, anonymous name fixes, bookmarks UI, version history

parent 118c3180
---
description: Run Ralph Loop - autonomous AI iteration until tests pass
---
# Ralph Loop Workflow
Ralph Loop chạy AI agent liên tục đến khi test pass hoặc max iterations.
## Prerequisites
1. **Antigravity For Loop Extension**: Install từ https://github.com/ImL1s/antigravity_for_loop
2. **Enable CDP**: Run Antigravity với `--remote-debugging-port=9000`
## Quick Start (GUI - Recommended)
1. Click **"For Loop"** trong status bar (bottom)
2. Select **"Start Ralph Loop..."**
3. Enter task description
4. Select completion condition:
- **Tests Pass** - stop khi test exit 0
- **AI Self-Judgment** - stop khi AI output "DONE"
5. Set max iterations (default: 10)
6. Go!
## Keyboard Shortcuts
| Shortcut | Action |
|----------|--------|
| `Cmd+Alt+Shift+L` | Open Ralph menu |
| `Cmd+Alt+Shift+A` | Toggle auto-accept |
| `Cmd+Alt+Shift+C` | Copy continuation prompt |
## Manual Loop (Alternative)
Nếu extension không hoạt động:
// turbo-all
```
1. Review RALPH_TASK.md
2. Chat with AI: "Work on the unchecked items in RALPH_TASK.md"
3. Run tests: cd backend && pytest
4. If tests fail, continue iterating
5. If tests pass, check off completed items
6. Repeat until all done
```
## Create New Task
File: `RALPH_TASK.md`
```markdown
task: "Your task description here"
test_command: "npm test" # or "pytest", "cargo test", etc.
---
# Task: [Title]
## Success Criteria
- [ ] Criterion 1
- [ ] Criterion 2
```
## Windows Setup
```powershell
.\run_ralph_windows.ps1 -MaxIterations 50
```
## Tips
- **Always git commit** before starting a Ralph loop
- Set reasonable **maxIterations** (10-50)
- Use **AI Self-Judgment** for open-ended refactoring tasks
- Use **Tests Pass** for bug fixes with clear test coverage
VITE_CLERK_PUBLISHABLE_KEY=pk_test_Y29tbXVuYWwtc3VuYmVhbS0wLmNsZXJrLmFjY291bnRzLmRldiQ VITE_CLERK_PUBLISHABLE_KEY=pk_test_Y29tbXVuYWwtc3VuYmVhbS0wLmNsZXJrLmFjY291bnRzLmRldiQ
CLERK_SECRET_KEY=sk_test_ek7ozVR80Qi9UdvhGaTmlXovS16GDuBDlDrpH1rkyQ CLERK_SECRET_KEY=sk_test_ek7ozVR80Qi9UdvhGaTmlXovS16GDuBDlDrpH1rkyQ
MONGODB_URI = mongodb+srv://20010841:vuhoanganh1704@cluster0.h6qro.mongodb.net/?appName=Cluster0
\ No newline at end of file
...@@ -3,6 +3,7 @@ Activity service routes for Memos-style backend. ...@@ -3,6 +3,7 @@ Activity service routes for Memos-style backend.
""" """
from typing import List from typing import List
import re
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
...@@ -21,3 +22,30 @@ async def list_activities(activity_service=Depends(get_activity_service)): ...@@ -21,3 +22,30 @@ async def list_activities(activity_service=Depends(get_activity_service)):
raise HTTPException(status_code=500, detail=str(exc)) from exc raise HTTPException(status_code=500, detail=str(exc)) from exc
@router.get("/{name:path}", summary="Get activity by name", response_model=ActivityResponse)
async def get_activity(name: str, activity_service=Depends(get_activity_service)):
"""
Get a single activity by name.
Name format: activities/{id} or just the ID number.
"""
try:
# Extract activity ID from name (e.g., "activities/123456" -> 123456)
if name.startswith("activities/"):
name = name[len("activities/"):]
# Parse ID
match = re.match(r"^(\d+)$", name)
if not match:
raise HTTPException(status_code=400, detail=f"Invalid activity name format: {name}")
activity_id = int(match.group(1))
activity = await activity_service.get_activity(activity_id)
if not activity:
raise HTTPException(status_code=404, detail=f"Activity {activity_id} not found")
return activity
except HTTPException:
raise
except Exception as exc: # pragma: no cover
raise HTTPException(status_code=500, detail=str(exc)) from exc
...@@ -14,6 +14,7 @@ from common.memos_core.schemas import ( ...@@ -14,6 +14,7 @@ from common.memos_core.schemas import (
AuthSignUpResponse, AuthSignUpResponse,
) )
from common.memos_core.services import get_auth_service from common.memos_core.services import get_auth_service
from config import DISABLE_AUTH
router = APIRouter(prefix="/auth") router = APIRouter(prefix="/auth")
...@@ -73,13 +74,43 @@ async def get_me( ...@@ -73,13 +74,43 @@ async def get_me(
token = authorization.split(" ", 1)[1] token = authorization.split(" ", 1)[1]
me = await auth_service.get_me(token=token) me = await auth_service.get_me(token=token)
# Build username - prefer me.username, fallback to email prefix
username = getattr(me, "username", None) or (
me.email.split("@", 1)[0] if getattr(me, "email", None) else "user"
)
# Build display name from firstName + lastName
first_name = getattr(me, "first_name", None) or ""
last_name = getattr(me, "last_name", None) or ""
display_name = f"{first_name} {last_name}".strip() or username
avatar_url = getattr(me, "avatar_url", None) or ""
# Connect/proto expects: { "user": User } # Connect/proto expects: { "user": User }
return { return {
"user": { "user": {
"name": "users/1", "name": "users/1",
"role": 1, # HOST "role": 1, # HOST
"username": me.email.split("@", 1)[0] if getattr(me, "email", None) else "demo", "username": username,
"email": getattr(me, "email", "demo@example.com"), "email": getattr(me, "email", ""),
"displayName": display_name,
"avatarUrl": avatar_url,
"description": "",
"password": "",
"state": 1,
}
}
except Exception as exc: # pragma: no cover - placeholder
# In local/dev mode, don't break the app just because auth fails.
# Fall back to a demo user so that memo features still work.
if DISABLE_AUTH:
return {
"user": {
"name": "users/1",
"role": 1, # HOST
"username": "demo",
"email": "demo@example.com",
"displayName": "", "displayName": "",
"avatarUrl": "", "avatarUrl": "",
"description": "", "description": "",
...@@ -87,7 +118,7 @@ async def get_me( ...@@ -87,7 +118,7 @@ async def get_me(
"state": 1, "state": 1,
} }
} }
except Exception as exc: # pragma: no cover - placeholder
raise HTTPException(status_code=401, detail=str(exc)) from exc raise HTTPException(status_code=401, detail=str(exc)) from exc
...@@ -15,6 +15,7 @@ from common.memos_core.schemas import ( ...@@ -15,6 +15,7 @@ from common.memos_core.schemas import (
MemoCreate, MemoCreate,
MemoUpdate, MemoUpdate,
MemoResponse, MemoResponse,
MemoVersionResponse,
) )
from common.memos_core.services import get_memo_service, get_memo_relation_service from common.memos_core.services import get_memo_service, get_memo_relation_service
from common.memos_core.query_parser import parse_date_range from common.memos_core.query_parser import parse_date_range
...@@ -167,6 +168,20 @@ async def delete_memo( ...@@ -167,6 +168,20 @@ async def delete_memo(
raise HTTPException(status_code=400, detail=str(exc)) from exc raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.get("/{memo_id}/versions", summary="Get memo version history", response_model=MemoVersionResponse)
async def get_memo_versions(
request: Request,
memo_id: str,
memo_service=Depends(get_memo_service),
):
"""Get version history for a memo."""
try:
user_id = get_current_user_id(request)
return await memo_service.get_memo_versions(memo_id, user_id=user_id)
except Exception as exc: # pragma: no cover
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.post("/{memo_id}/comments", summary="Create comment", response_model=MemoResponse) @router.post("/{memo_id}/comments", summary="Create comment", response_model=MemoResponse)
async def create_memo_comment( async def create_memo_comment(
request: Request, request: Request,
...@@ -205,21 +220,30 @@ async def create_memo_comment( ...@@ -205,21 +220,30 @@ async def create_memo_comment(
# Use _id string as parent (standardized format) # Use _id string as parent (standardized format)
parent_id_str = str(parent_doc["_id"]) parent_id_str = str(parent_doc["_id"])
# 3. Handle anonymous users: build synthetic creator_id # 3. Handle users: prioritize anonymous_id/anonymous_name from payload if provided
if user_id is None: # This allows custom display names even when DISABLE_AUTH=true
# Anonymous user: use anonymous_id from payload or generate one if payload.anonymous_id or payload.anonymous_name:
# Use provided anonymous_id or generate one
anonymous_id = payload.anonymous_id or f"anonymous_{secrets.token_hex(4)}" anonymous_id = payload.anonymous_id or f"anonymous_{secrets.token_hex(4)}"
anonymous_name = payload.anonymous_name anonymous_name = payload.anonymous_name
# Build creator_id: anonymous_<sessionId> or anonymous_<sessionId>_<displayName> # Build creator_id: anonymous_<sessionId> or anonymous_<sessionId>_<displayName>
if anonymous_name: if anonymous_name:
# Sanitize display name (remove special chars that could break queries) # URL-safe base64 encode the display name to preserve Unicode characters
sanitized_name = re.sub(r'[^a-zA-Z0-9_-]', '', anonymous_name)[:20] # Limit length # This allows Vietnamese, Chinese, etc. names to be stored and displayed
synthetic_user_id = f"{anonymous_id}_{sanitized_name}" import base64
encoded_name = base64.urlsafe_b64encode(anonymous_name.encode('utf-8')).decode('ascii')
# Limit to 50 chars max (base64 expands size)
encoded_name = encoded_name[:50].rstrip('=')
synthetic_user_id = f"{anonymous_id}_{encoded_name}"
else: else:
synthetic_user_id = anonymous_id synthetic_user_id = anonymous_id
user_id = synthetic_user_id user_id = synthetic_user_id
elif user_id is None:
# No auth and no anonymous info provided - generate anonymous ID
user_id = f"anonymous_{secrets.token_hex(4)}"
# 4. Create comment memo (with parent = _id string) # 4. Create comment memo (with parent = _id string)
# Allow anonymous users to choose PRIVATE comments # Allow anonymous users to choose PRIVATE comments
...@@ -263,9 +287,43 @@ async def create_memo_comment( ...@@ -263,9 +287,43 @@ async def create_memo_comment(
relation_type="COMMENT" relation_type="COMMENT"
) )
# 4. TODO: Create Activity and Inbox notification # 5. Create Activity and Notification for memo owner
# (if comment is not PRIVATE and creator != original memo creator) # Only if: comment is PUBLIC and commenter is different from original memo creator
# This can be implemented later try:
parent_creator_id = parent_doc.get("creator_id")
# Only notify if:
# 1. Comment is PUBLIC (owner should see it)
# 2. Commenter is not the memo owner
if comment_visibility == "PUBLIC" and parent_creator_id and parent_creator_id != user_id:
from common.memos_core.services import ActivityService, NotificationService
activity_service = ActivityService()
notification_service = NotificationService()
# Create activity record
activity = await activity_service.create_activity(
activity_type="MEMO_COMMENT",
creator_id=user_id,
memo_id=comment.id, # The comment memo
related_memo_id=parent_id_str # The original memo
)
# Extract activity ID from name (activities/123456 -> 123456)
activity_id = int(activity.name.split("/")[-1])
# Create notification for memo owner
await notification_service.create_notification(
recipient_id=parent_creator_id,
sender_id=user_id,
activity_id=activity_id,
notification_type="MEMO_COMMENT"
)
except Exception as notify_exc:
# Don't fail the comment creation if notification fails
# Just log and continue
import logging
logging.warning(f"Failed to create notification: {notify_exc}")
return comment return comment
......
...@@ -61,10 +61,70 @@ async def list_user_settings_connect_compat(payload: dict = Body(default_factory ...@@ -61,10 +61,70 @@ async def list_user_settings_connect_compat(payload: dict = Body(default_factory
} }
@router.get("/{user_id}", summary="Get user by ID", response_model=UserResponse) @router.get("/{user_id}/stats", summary="Get user stats (tags, activity)")
async def get_user(user_id: str, user_service=Depends(get_user_service)): async def get_user_stats(user_id: str, user_service=Depends(get_user_service)):
"""
Get user statistics including:
- tagCount: aggregated tag counts from all user's memos
- memoDisplayTimestamps: list of memo created_at timestamps for activity calendar
"""
try: try:
return await user_service.get_user(user_id) stats = await user_service.get_user_stats(user_id)
return {
"tagCount": stats.tag_count,
"memoDisplayTimestamps": stats.memo_display_timestamps,
}
except Exception as exc: # pragma: no cover
raise HTTPException(status_code=500, detail=str(exc)) from exc
@router.get("/{user_id}", summary="Get user by ID")
async def get_user(user_id: str, request: Request, user_service=Depends(get_user_service)):
"""
Get user by ID. Returns a User object matching the proto format.
For the current user (user_id==1 or me), returns data from auth session.
"""
try:
# Get user data from auth middleware - request.state.user contains Clerk JWT payload
user_payload = getattr(request.state, "user", None)
# Default values for when no auth is available (DISABLE_AUTH mode)
username = "user"
display_name = ""
avatar_url = ""
email = ""
if user_payload and isinstance(user_payload, dict):
# Extract from Clerk JWT claims
username = user_payload.get("username") or user_payload.get("preferred_username") or ""
first_name = user_payload.get("first_name") or user_payload.get("given_name") or ""
last_name = user_payload.get("last_name") or user_payload.get("family_name") or ""
avatar_url = user_payload.get("image_url") or user_payload.get("picture") or ""
email = user_payload.get("email") or ""
# Build display name
display_name = f"{first_name} {last_name}".strip()
# Fallback username from email if not set
if not username and email:
username = email.split("@")[0]
# Ensure we always have valid strings (never 'undefined')
username = username if username else "user"
display_name = display_name if display_name else username
# Return proto-compatible User format
return {
"name": f"users/{user_id}",
"role": 1, # HOST
"username": username,
"email": email,
"displayName": display_name,
"avatarUrl": avatar_url,
"description": "",
"password": "",
"state": 1, # ACTIVE
}
except Exception as exc: # pragma: no cover except Exception as exc: # pragma: no cover
raise HTTPException(status_code=404, detail=str(exc)) from exc raise HTTPException(status_code=404, detail=str(exc)) from exc
...@@ -180,3 +240,105 @@ async def delete_user_openai_key( ...@@ -180,3 +240,105 @@ async def delete_user_openai_key(
raise HTTPException(status_code=500, detail=str(exc)) from exc raise HTTPException(status_code=500, detail=str(exc)) from exc
# ============================================================================
# User Notifications (Inbox) Routes
# ============================================================================
from common.memos_core.services import get_notification_service
from common.memos_core.schemas import NotificationResponse
@router.post("/notifications:list", summary="List user notifications (Connect RPC style)")
async def list_user_notifications(
request: Request,
notification_service=Depends(get_notification_service),
):
"""
List all notifications for the current user.
Connect RPC style: POST /users/notifications:list
Returns notifications for the Inbox page.
"""
try:
# Get current user ID from auth middleware
user_id = getattr(request.state, "user_id", None) or "1"
notifications = await notification_service.list_notifications(user_id)
# Return in Connect RPC format matching frontend expectations
return {
"notifications": [n.model_dump() for n in notifications]
}
except Exception as exc: # pragma: no cover
raise HTTPException(status_code=500, detail=str(exc)) from exc
@router.patch("/notifications/{name:path}", summary="Update notification status")
async def update_user_notification(
name: str,
payload: dict = Body(default_factory=dict),
notification_service=Depends(get_notification_service),
):
"""
Update a notification (e.g., mark as READ or ARCHIVED).
Name format: notifications/{id} or just the ID number.
"""
import re
try:
# Extract notification ID from name
if name.startswith("notifications/"):
name = name[len("notifications/"):]
match = re.match(r"^(\d+)$", name)
if not match:
raise HTTPException(status_code=400, detail=f"Invalid notification name: {name}")
notification_id = int(match.group(1))
# Get status from payload (Connect RPC format: { notification: { status: "..." } })
notification_data = payload.get("notification", payload)
new_status = notification_data.get("status", "READ")
notification = await notification_service.update_notification(notification_id, new_status)
if not notification:
raise HTTPException(status_code=404, detail=f"Notification {notification_id} not found")
return notification.model_dump()
except HTTPException:
raise
except Exception as exc: # pragma: no cover
raise HTTPException(status_code=500, detail=str(exc)) from exc
@router.delete("/notifications/{name:path}", summary="Delete notification")
async def delete_user_notification(
name: str,
notification_service=Depends(get_notification_service),
):
"""
Delete a notification.
Name format: notifications/{id} or just the ID number.
"""
import re
try:
# Extract notification ID from name
if name.startswith("notifications/"):
name = name[len("notifications/"):]
match = re.match(r"^(\d+)$", name)
if not match:
raise HTTPException(status_code=400, detail=f"Invalid notification name: {name}")
notification_id = int(match.group(1))
deleted = await notification_service.delete_notification(notification_id)
if not deleted:
raise HTTPException(status_code=404, detail=f"Notification {notification_id} not found")
return {"success": True}
except HTTPException:
raise
except Exception as exc: # pragma: no cover
raise HTTPException(status_code=500, detail=str(exc)) from exc
...@@ -55,6 +55,12 @@ class AuthSignUpResponse(BaseModel): ...@@ -55,6 +55,12 @@ class AuthSignUpResponse(BaseModel):
class AuthMeResponse(BaseModel): class AuthMeResponse(BaseModel):
id: str id: str
email: EmailStr email: EmailStr
username: Optional[str] = None
first_name: Optional[str] = Field(default=None, alias="firstName")
last_name: Optional[str] = Field(default=None, alias="lastName")
avatar_url: Optional[str] = Field(default=None, alias="avatarUrl")
model_config = ConfigDict(populate_by_name=True)
class UserBase(BaseModel): class UserBase(BaseModel):
...@@ -73,6 +79,22 @@ class UserUpdate(BaseModel): ...@@ -73,6 +79,22 @@ class UserUpdate(BaseModel):
class UserResponse(UserBase): class UserResponse(UserBase):
id: str id: str
nickname: Optional[str] = None nickname: Optional[str] = None
username: Optional[str] = None
display_name: Optional[str] = Field(default=None, alias="displayName")
avatar_url: Optional[str] = Field(default=None, alias="avatarUrl")
name: Optional[str] = None # Resource name: users/{id}
role: int = 1 # 1 = HOST
state: int = 1 # 1 = ACTIVE
model_config = ConfigDict(populate_by_name=True)
class UserStatsResponse(BaseModel):
"""User statistics including tag counts and memo timestamps."""
tag_count: dict[str, int] = Field(default_factory=dict, alias="tagCount")
memo_display_timestamps: List[str] = Field(default_factory=list, alias="memoDisplayTimestamps")
model_config = ConfigDict(populate_by_name=True)
class MemoBase(BaseModel): class MemoBase(BaseModel):
...@@ -95,10 +117,28 @@ class MemoUpdate(BaseModel): ...@@ -95,10 +117,28 @@ class MemoUpdate(BaseModel):
row_status: Optional[str] = None row_status: Optional[str] = None
class MemoVersion(BaseModel):
"""Represents a historical version of a memo's content."""
version: int = 1
content: str
created_at: datetime
created_by: Optional[str] = None # User ID who made the edit
model_config = ConfigDict(populate_by_name=True)
class MemoVersionResponse(BaseModel):
"""Response schema for memo version history."""
versions: List[MemoVersion] = []
total: int = 0
class MemoResponse(MemoBase): class MemoResponse(MemoBase):
id: str id: str
uid: str = "" uid: str = ""
creator_id: str creator_id: str
creator: str = "" # Resource name: users/{creator_id}
name: str = "" # Resource name: memos/{uid}
pinned: bool = False pinned: bool = False
row_status: str = "NORMAL" row_status: str = "NORMAL"
create_time: Optional[str] = None create_time: Optional[str] = None
...@@ -106,6 +146,7 @@ class MemoResponse(MemoBase): ...@@ -106,6 +146,7 @@ class MemoResponse(MemoBase):
display_time: Optional[str] = None display_time: Optional[str] = None
parent: Optional[str] = None parent: Optional[str] = None
comment_count: int = 0 comment_count: int = 0
versions: List[MemoVersion] = [] # Version history
model_config = ConfigDict(populate_by_name=True) model_config = ConfigDict(populate_by_name=True)
...@@ -159,10 +200,48 @@ class ListShortcutsResponse(BaseModel): ...@@ -159,10 +200,48 @@ class ListShortcutsResponse(BaseModel):
shortcuts: List[ShortcutResponse] = [] shortcuts: List[ShortcutResponse] = []
class ActivityMemoCommentPayload(BaseModel):
"""Payload for memo comment activities"""
memo: str # Comment memo name (memos/{uid})
related_memo: str # Parent memo name that was commented on
class ActivityPayload(BaseModel):
"""Activity payload with different types"""
memo_comment: Optional[ActivityMemoCommentPayload] = None
class ActivityResponse(BaseModel): class ActivityResponse(BaseModel):
id: int """Activity record - e.g., when a comment is created"""
type: str name: str # activities/{id}
description: Optional[str] = None creator: str # users/{id}
type: str # MEMO_COMMENT, etc.
level: str = "INFO" # INFO, WARNING, ERROR
create_time: Optional[str] = None
payload: Optional[ActivityPayload] = None
class NotificationStatus:
"""Notification status constants"""
UNREAD = "UNREAD"
READ = "READ"
ARCHIVED = "ARCHIVED"
class NotificationType:
"""Notification type constants"""
MEMO_COMMENT = "MEMO_COMMENT"
class NotificationResponse(BaseModel):
"""User notification - shows in Inbox"""
name: str # notifications/{id}
sender: str # users/{creator_id} or anonymous_{id}
activity_id: int # Reference to activity
type: str # MEMO_COMMENT
status: str = "UNREAD" # UNREAD, READ, ARCHIVED
create_time: Optional[str] = None
class IdentityProviderCreate(BaseModel): class IdentityProviderCreate(BaseModel):
......
...@@ -37,22 +37,55 @@ class AuthService: ...@@ -37,22 +37,55 @@ class AuthService:
return schemas.AuthSignUpResponse(user_id="1") return schemas.AuthSignUpResponse(user_id="1")
async def get_me(self, token: str | None = None) -> schemas.AuthMeResponse: async def get_me(self, token: str | None = None) -> schemas.AuthMeResponse:
"""
Resolve current user for the memo frontend.
Priority:
1. If DISABLE_AUTH=true -> always return a stub user for local dev (no login required)
2. If token is provided and Clerk is configured -> verify Clerk JWT and map to user
3. If Clerk is not configured but token exists -> accept token and return demo user (dev mode)
"""
import logging
from config import DISABLE_AUTH, CLERK_JWKS_URL, CLERK_ISSUER
# 1) Local dev mode: allow full features without auth
if DISABLE_AUTH:
logging.warning("⚠️ DISABLE_AUTH=true -> returning stub user for memo frontend")
return schemas.AuthMeResponse(id="1", email="demo@example.com")
# 2) Require token in normal mode
if not token: if not token:
raise ValueError("Missing authentication token") raise ValueError("Missing authentication token")
# Verify Clerk JWT token # 3) Verify Clerk JWT when configured
import logging
try: try:
from config import CLERK_JWKS_URL, CLERK_ISSUER
# Only verify if Clerk is configured # Only verify if Clerk is configured
if CLERK_JWKS_URL and CLERK_ISSUER: if CLERK_JWKS_URL and CLERK_ISSUER:
from common.clerk_auth import verify_clerk_jwt from common.clerk_auth import verify_clerk_jwt
payload = verify_clerk_jwt(token) payload = verify_clerk_jwt(token)
# Extract user info from Clerk token # Extract user info from Clerk token
user_id = payload.get("sub") or payload.get("user_id") or "1" user_id = payload.get("sub") or payload.get("user_id") or "1"
email = payload.get("email") or "demo@example.com" email = payload.get("email") or "user@example.com"
logging.info(f"✅ Clerk token verified for user: {user_id}") # Clerk provides these fields in the JWT
return schemas.AuthMeResponse(id=user_id, email=email) username = payload.get("username") or payload.get("preferred_username")
first_name = payload.get("first_name") or payload.get("given_name")
last_name = payload.get("last_name") or payload.get("family_name")
avatar_url = payload.get("image_url") or payload.get("picture")
# Fallback username from email if not provided
if not username and email:
username = email.split("@")[0]
logging.info(f"✅ Clerk token verified for user: {user_id}, username: {username}")
return schemas.AuthMeResponse(
id=user_id,
email=email,
username=username,
first_name=first_name,
last_name=last_name,
avatar_url=avatar_url
)
else: else:
# Clerk not configured - accept any token (dev mode) # Clerk not configured - accept any token (dev mode)
logging.warning("⚠️ Clerk not configured, accepting token without verification") logging.warning("⚠️ Clerk not configured, accepting token without verification")
...@@ -77,6 +110,57 @@ class UserService: ...@@ -77,6 +110,57 @@ class UserService:
async def update_user(self, user_id: str, payload: schemas.UserUpdate) -> schemas.UserResponse: async def update_user(self, user_id: str, payload: schemas.UserUpdate) -> schemas.UserResponse:
return schemas.UserResponse(id=user_id, email=payload.email or "demo@example.com", nickname=payload.nickname) return schemas.UserResponse(id=user_id, email=payload.email or "demo@example.com", nickname=payload.nickname)
async def get_user_stats(self, user_id: str) -> schemas.UserStatsResponse:
"""
Get user statistics including tag counts and memo timestamps.
Tags are parsed from memo content using #tag pattern.
"""
import re
from .mongodb import mongodb_client
# Query memos - try matching by creator_id
# Handle both simple IDs (like "1") and Clerk user IDs (like "user_...")
query: dict = {"row_status": {"$ne": "ARCHIVED"}}
# If user_id looks like a Clerk ID or we want to match specific user
if user_id and user_id != "1":
query["creator_id"] = user_id
# For simple "1", try both formats or get all memos for demo
# In production, map "1" to actual Clerk user ID
cursor = mongodb_client.memos.find(query, {"content": 1, "created_at": 1, "payload": 1})
docs = await cursor.to_list(length=1000)
tag_count: dict[str, int] = {}
memo_timestamps: list[str] = []
for doc in docs:
# Collect timestamps
created_at = doc.get("created_at")
if created_at:
if isinstance(created_at, datetime):
memo_timestamps.append(created_at.isoformat())
else:
memo_timestamps.append(str(created_at))
# Parse tags from content (#tag pattern)
content = doc.get("content", "")
if content:
matches = re.findall(r'#([^\s#]+)', content)
for tag in matches:
tag_count[tag] = tag_count.get(tag, 0) + 1
# Also check payload.tags if available
payload_tags = doc.get("payload", {}).get("tags", [])
for tag in payload_tags:
if tag and tag not in tag_count:
tag_count[tag] = tag_count.get(tag, 0) + 1
return schemas.UserStatsResponse(
tag_count=tag_count,
memo_display_timestamps=memo_timestamps,
)
class MemoService: class MemoService:
"""Full-featured Memo service with MongoDB backend.""" """Full-featured Memo service with MongoDB backend."""
...@@ -137,9 +221,22 @@ class MemoService: ...@@ -137,9 +221,22 @@ class MemoService:
# Exclude comments by default (only show parent memos) # Exclude comments by default (only show parent memos)
query["parent"] = {"$exists": False} query["parent"] = {"$exists": False}
# Tag filter # Tag filter - search in content using regex for #tag pattern
if tag: if tag:
query["payload.tags"] = tag import re
# Escape special regex characters in tag
safe_tag = re.escape(tag)
# Use $and to add tag filter without breaking existing $or
tag_filter = {
"$or": [
{"payload.tags": tag}, # Explicit tags array
{"content": {"$regex": f"#?{safe_tag}(\\s|$|[^a-zA-Z0-9_])", "$options": "i"}}, # Inline #tag in content
]
}
if "$and" in query:
query["$and"].append(tag_filter)
else:
query["$and"] = [tag_filter]
# Date Range filter # Date Range filter
date_filter: dict[str, Any] = {} date_filter: dict[str, Any] = {}
...@@ -273,6 +370,37 @@ class MemoService: ...@@ -273,6 +370,37 @@ class MemoService:
count = await mongodb_client.memos.count_documents(query) count = await mongodb_client.memos.count_documents(query)
return count return count
async def get_memo_versions(
self,
memo_id: str,
user_id: str | None = None,
) -> schemas.MemoVersionResponse:
"""Get version history for a memo."""
doc = None
if ObjectId.is_valid(memo_id):
doc = await mongodb_client.memos.find_one({"_id": ObjectId(memo_id)})
if not doc:
doc = await mongodb_client.memos.find_one({"uid": memo_id})
if not doc:
raise ValueError(f"Memo {memo_id} not found")
versions_data = doc.get("versions", [])
versions = [
schemas.MemoVersion(
version=v.get("version", i + 1),
content=v.get("content", ""),
created_at=v.get("created_at", utc_now()),
created_by=v.get("created_by"),
)
for i, v in enumerate(versions_data)
]
return schemas.MemoVersionResponse(
versions=versions,
total=len(versions),
)
async def update_memo( async def update_memo(
self, self,
memo_id: str, memo_id: str,
...@@ -306,6 +434,17 @@ class MemoService: ...@@ -306,6 +434,17 @@ class MemoService:
if payload.row_status is not None: if payload.row_status is not None:
update_fields["row_status"] = payload.row_status update_fields["row_status"] = payload.row_status
# Save version history when content changes
if payload.content is not None and payload.content != doc.get("content", ""):
existing_versions = doc.get("versions", [])
new_version = {
"version": len(existing_versions) + 1,
"content": doc.get("content", ""),
"created_at": doc.get("updated_at") or doc.get("created_at") or utc_now(),
"created_by": memo_creator,
}
update_fields["versions"] = existing_versions + [new_version]
await mongodb_client.memos.update_one( await mongodb_client.memos.update_one(
{"_id": doc["_id"]}, {"_id": doc["_id"]},
{"$set": update_fields}, {"$set": update_fields},
...@@ -390,15 +529,20 @@ class MemoService: ...@@ -390,15 +529,20 @@ class MemoService:
else: else:
updated_at = updated_at.strftime("%Y-%m-%dT%H:%M:%SZ") updated_at = updated_at.strftime("%Y-%m-%dT%H:%M:%SZ")
creator_id = doc.get("creator_id", "anonymous")
uid = doc.get("uid", "")
return schemas.MemoResponse( return schemas.MemoResponse(
id=str(doc["_id"]), id=str(doc["_id"]),
uid=doc.get("uid", ""), uid=uid,
content=doc.get("content", ""), content=doc.get("content", ""),
visibility=doc.get("visibility", "PRIVATE"), visibility=doc.get("visibility", "PRIVATE"),
tags=payload.get("tags", []), tags=payload.get("tags", []),
pinned=doc.get("pinned", False), pinned=doc.get("pinned", False),
row_status=doc.get("row_status", "NORMAL"), row_status=doc.get("row_status", "NORMAL"),
creator_id=doc.get("creator_id", "anonymous"), creator_id=creator_id,
creator=f"users/{creator_id}", # Resource format for frontend
name=f"memos/{uid}", # Resource format for frontend
create_time=created_at, create_time=created_at,
update_time=updated_at, update_time=updated_at,
display_time=created_at, display_time=created_at,
...@@ -655,8 +799,147 @@ class ShortcutService: ...@@ -655,8 +799,147 @@ class ShortcutService:
class ActivityService: class ActivityService:
"""Service for managing activity records (e.g., memo comments)"""
async def list_activities(self) -> List[schemas.ActivityResponse]: async def list_activities(self) -> List[schemas.ActivityResponse]:
return [] """List all activities"""
activities = []
cursor = mongodb_client.activities.find({}).sort("created_at", -1).limit(100)
async for doc in cursor:
activities.append(self._doc_to_response(doc))
return activities
async def get_activity(self, activity_id: int) -> Optional[schemas.ActivityResponse]:
"""Get a single activity by ID"""
doc = await mongodb_client.activities.find_one({"_id": activity_id})
if not doc:
return None
return self._doc_to_response(doc)
async def create_activity(
self,
activity_type: str,
creator_id: str,
memo_id: str,
related_memo_id: str
) -> schemas.ActivityResponse:
"""Create a new activity record"""
from datetime import datetime, timezone
import random
# Generate a numeric ID (simulating auto-increment)
activity_id = random.randint(100000, 999999)
now = datetime.now(timezone.utc)
doc = {
"_id": activity_id,
"type": activity_type,
"creator_id": creator_id,
"memo_id": memo_id,
"related_memo_id": related_memo_id,
"level": "INFO",
"created_at": now,
}
await mongodb_client.activities.insert_one(doc)
return self._doc_to_response(doc)
def _doc_to_response(self, doc: dict) -> schemas.ActivityResponse:
"""Convert MongoDB document to ActivityResponse"""
created_at = doc.get("created_at")
create_time_str = created_at.isoformat() if created_at else None
payload = None
if doc.get("memo_id") and doc.get("related_memo_id"):
payload = schemas.ActivityPayload(
memo_comment=schemas.ActivityMemoCommentPayload(
memo=f"memos/{doc['memo_id']}",
related_memo=f"memos/{doc['related_memo_id']}"
)
)
return schemas.ActivityResponse(
name=f"activities/{doc['_id']}",
creator=f"users/{doc.get('creator_id', 'unknown')}",
type=doc.get("type", "MEMO_COMMENT"),
level=doc.get("level", "INFO"),
create_time=create_time_str,
payload=payload
)
class NotificationService:
"""Service for managing user notifications (Inbox)"""
async def list_notifications(self, user_id: str) -> List[schemas.NotificationResponse]:
"""List all notifications for a user"""
notifications = []
# Find notifications where recipient matches user_id
cursor = mongodb_client.notifications.find(
{"recipient_id": user_id}
).sort("created_at", -1).limit(100)
async for doc in cursor:
notifications.append(self._doc_to_response(doc))
return notifications
async def create_notification(
self,
recipient_id: str,
sender_id: str,
activity_id: int,
notification_type: str = "MEMO_COMMENT"
) -> schemas.NotificationResponse:
"""Create a notification for a user"""
from datetime import datetime, timezone
import random
notification_id = random.randint(100000, 999999)
now = datetime.now(timezone.utc)
doc = {
"_id": notification_id,
"recipient_id": recipient_id,
"sender_id": sender_id,
"activity_id": activity_id,
"type": notification_type,
"status": "UNREAD",
"created_at": now,
}
await mongodb_client.notifications.insert_one(doc)
return self._doc_to_response(doc)
async def update_notification(
self,
notification_id: int,
status: str
) -> Optional[schemas.NotificationResponse]:
"""Update notification status (READ, ARCHIVED)"""
result = await mongodb_client.notifications.find_one_and_update(
{"_id": notification_id},
{"$set": {"status": status}},
return_document=True
)
if not result:
return None
return self._doc_to_response(result)
async def delete_notification(self, notification_id: int) -> bool:
"""Delete a notification"""
result = await mongodb_client.notifications.delete_one({"_id": notification_id})
return result.deleted_count > 0
def _doc_to_response(self, doc: dict) -> schemas.NotificationResponse:
"""Convert MongoDB document to NotificationResponse"""
created_at = doc.get("created_at")
create_time_str = created_at.isoformat() if created_at else None
return schemas.NotificationResponse(
name=f"notifications/{doc['_id']}",
sender=doc.get("sender_id", "unknown"),
activity_id=doc.get("activity_id", 0),
type=doc.get("type", "MEMO_COMMENT"),
status=doc.get("status", "UNREAD"),
create_time=create_time_str
)
class IdentityProviderService: class IdentityProviderService:
...@@ -1067,6 +1350,10 @@ def get_activity_service() -> ActivityService: ...@@ -1067,6 +1350,10 @@ def get_activity_service() -> ActivityService:
return ActivityService() return ActivityService()
def get_notification_service() -> NotificationService:
return NotificationService()
def get_idp_service() -> IdentityProviderService: def get_idp_service() -> IdentityProviderService:
return IdentityProviderService() return IdentityProviderService()
......
...@@ -88,16 +88,6 @@ class CanifaAuthMiddleware(BaseHTTPMiddleware): ...@@ -88,16 +88,6 @@ class CanifaAuthMiddleware(BaseHTTPMiddleware):
auth_header = request.headers.get("Authorization") auth_header = request.headers.get("Authorization")
device_id = request.headers.get("device_id", "") device_id = request.headers.get("device_id", "")
# ========== DEV MODE: Bypass auth ==========
dev_user_id = request.headers.get("X-Dev-User-Id")
if dev_user_id:
logger.warning(f"⚠️ DEV MODE: Using X-Dev-User-Id={dev_user_id}")
request.state.user = {"customer_id": dev_user_id}
request.state.user_id = dev_user_id
request.state.is_authenticated = True
request.state.device_id = device_id or dev_user_id
return await call_next(request)
# --- 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 request.state.user = None
......
...@@ -7,8 +7,53 @@ import os ...@@ -7,8 +7,53 @@ import os
from dotenv import load_dotenv from dotenv import load_dotenv
# Load environment variables from .env file # Base dir of backend (this file)
load_dotenv() BASE_DIR = os.path.dirname(__file__)
# 1) Load environment variables from `.env` in backend folder (ổn định, không phụ thuộc cwd)
BACKEND_DOTENV_PATH = os.path.join(BASE_DIR, ".env")
load_dotenv(dotenv_path=BACKEND_DOTENV_PATH, override=False)
# 1b) Also load project-root `.env` (nếu bạn đặt key ở root giống hình)
ROOT_DOTENV_PATH = os.path.join(BASE_DIR, "..", ".env")
load_dotenv(dotenv_path=ROOT_DOTENV_PATH, override=False)
# 2) Fallback: cũng load env từ file `env.txt` (KEY=VALUE per line) trong thư mục backend
ENV_TXT_PATH = os.path.join(BASE_DIR, "env.txt")
def _load_env_from_env_txt() -> None:
"""
Đọc thêm biến môi trường từ `env.txt` nếu tồn tại.
- Format: mỗi dòng `KEY=VALUE`
- Bỏ qua dòng trống / comment
- Chỉ set nếu biến đó CHƯA có trong os.environ
"""
if not os.path.exists(ENV_TXT_PATH):
return
try:
with open(ENV_TXT_PATH, encoding="utf-8") as f:
for raw_line in f:
line = raw_line.strip()
if not line or line.startswith("#"):
continue
if "=" not in line:
continue
key, value = line.split("=", 1)
key = key.strip()
value = value.strip()
if key and key not in os.environ:
os.environ[key] = value
except Exception:
# Không fail startup chỉ vì file dev-env phụ; dùng như fallback local thôi.
pass
# Gọi ngay sau khi load_dotenv để các os.getenv phía dưới đều nhìn thấy
_load_env_from_env_txt()
# Export all config variables for type checking # Export all config variables for type checking
__all__ = [ __all__ = [
...@@ -117,8 +162,9 @@ LANGSMITH_PROJECT = None ...@@ -117,8 +162,9 @@ LANGSMITH_PROJECT = None
# ====================== CLERK AUTHENTICATION ====================== # ====================== CLERK AUTHENTICATION ======================
CLERK_SECRET_KEY: str | None = os.getenv("CLERK_SECRET_KEY") CLERK_SECRET_KEY: str | None = os.getenv("CLERK_SECRET_KEY")
CLERK_JWKS_URL: str | None = os.getenv("CLERK_JWKS_URL") # Hardcode Clerk domain để test nhanh (có thể override bằng env)
CLERK_ISSUER: str | None = os.getenv("CLERK_ISSUER") CLERK_JWKS_URL: str | None = os.getenv("CLERK_JWKS_URL") or "https://communal-sunbeam-0.clerk.accounts.dev/.well-known/jwks.json"
CLERK_ISSUER: str | None = os.getenv("CLERK_ISSUER") or "https://communal-sunbeam-0.clerk.accounts.dev"
# ====================== DATABASE CONNECTION ====================== # ====================== DATABASE CONNECTION ======================
# Redis Cache Configuration # Redis Cache Configuration
......
VITE_CLERK_PUBLISHABLE_KEY=pk_test_Y29tbXVuYWwtc3VuYmVhbS0wLmNsZXJrLmFjY291bnRzLmRldiQ
CLERK_SECRET_KEY=sk_test_ek7ozVR80Qi9UdvhGaTmlXovS16GDuBDlDrpH1rkyQ
MONGODB_URI=mongodb+srv://20010841:vuhoanganh1704@cluster0.h6qro.mongodb.net/?appName=Cluster0
\ No newline at end of file
aiofiles==25.1.0 # Core FastAPI
aiosqlite==0.20.0 fastapi==0.124.4
aiomysql==0.3.2 uvicorn==0.38.0
starlette==0.50.0
pydantic==2.12.5
pydantic_core==2.41.5
# Database - MongoDB
motor==3.7.0 motor==3.7.0
# Database - PostgreSQL (for LangGraph checkpoints)
psycopg==3.3.2
psycopg-binary==3.3.2
psycopg-pool==3.3.0
# Redis for caching
redis[hiredis]==5.2.1
# Auth & Security
PyJWT==2.10.1
cryptography==46.0.3
email-validator==2.2.0 email-validator==2.2.0
annotated-doc==0.0.4
annotated-types==0.7.0 # Async
aiofiles==25.1.0
anyio==4.12.0 anyio==4.12.0
backoff==2.2.1
bidict==0.23.1 # HTTP Clients
blinker==1.9.0
brotli==1.2.0
cachetools==6.2.4
certifi==2025.11.12
cffi==2.0.0
cfgv==3.5.0
charset-normalizer==3.4.4
click==8.3.1
colorama==0.4.6
ConfigArgParse==1.7.1
cryptography==46.0.3
dis==1.0.1
distlib==0.4.0
distro==1.9.0
dotenv==0.9.9
faiss-cpu==1.13.1
fastapi==0.124.4
filelock==3.20.1
filetype==1.2.0
Flask==3.1.2
flask-cors==6.0.2
Flask-Login==0.6.3
gevent==25.9.1
geventhttpclient==2.3.7
google-auth==2.45.0
google-genai==1.56.0
googleapis-common-protos==1.72.0
greenlet==3.3.0
h11==0.16.0
httpcore==1.0.9
httpx==0.28.1 httpx==0.28.1
identify==2.6.15 httpcore==1.0.9
idna==3.11 requests==2.32.4
importlib_metadata==8.7.0
iniconfig==2.3.0 # AI/LLM - LangChain & LangGraph
itsdangerous==2.2.0
Jinja2==3.1.6
jiter==0.12.0
jsonpatch==1.33
jsonpointer==3.0.0
langchain==1.2.0 langchain==1.2.0
langchain-core==1.2.3 langchain-core==1.2.3
langchain-google-genai==4.1.2 langchain-google-genai==4.1.2
langchain-openai==1.1.6 langchain-openai==1.1.6
langfuse==3.11.0
langgraph==1.0.5 langgraph==1.0.5
langgraph-checkpoint==3.0.1 langgraph-checkpoint==3.0.1
langgraph-checkpoint-postgres==3.0.2 langgraph-checkpoint-postgres==3.0.2
langgraph-prebuilt==1.0.5 langgraph-prebuilt==1.0.5
langgraph-sdk==0.3.0 langgraph-sdk==0.3.0
langsmith==0.5.0 langsmith==0.5.0
loguru==0.7.3 langfuse==3.11.0
MarkupSafe==3.0.3
msgpack==1.1.2 # AI/LLM - OpenAI & Google
nodeenv==1.10.0
numpy==2.4.0
openai==2.13.0 openai==2.13.0
google-genai==1.56.0
google-auth==2.45.0
# Tokenization
tiktoken==0.12.0
# Observability
opentelemetry-api==1.39.1 opentelemetry-api==1.39.1
opentelemetry-exporter-otlp-proto-common==1.39.1 opentelemetry-exporter-otlp-proto-common==1.39.1
opentelemetry-exporter-otlp-proto-http==1.39.1 opentelemetry-exporter-otlp-proto-http==1.39.1
opentelemetry-proto==1.39.1 opentelemetry-proto==1.39.1
opentelemetry-sdk==1.39.1 opentelemetry-sdk==1.39.1
opentelemetry-semantic-conventions==0.60b1 opentelemetry-semantic-conventions==0.60b1
orjson==3.11.5
ormsgpack==1.12.1 # Utilities
packaging==25.0
pillow==12.0.0
platformdirs==4.5.1
playwright==1.57.0
playwright-stealth==2.0.0
pluggy==1.6.0
pre_commit==4.5.1
protobuf==6.33.2
psutil==7.1.3
psycopg==3.3.2
psycopg-binary==3.3.2
psycopg-pool==3.3.0
pyasn1==0.6.1
pyasn1_modules==0.4.2
pycparser==2.23
pydantic==2.12.5
pydantic_core==2.41.5
pyee==13.0.0
Pygments==2.19.2
PyMySQL==1.1.2
pyscn==1.5.5
pytest==9.0.2
PyJWT==2.10.1
python-dotenv==1.2.1 python-dotenv==1.2.1
python-multipart==0.0.20 python-multipart==0.0.20
python-engineio==4.12.3 loguru==0.7.3
python-socketio==5.15.1 orjson==3.11.5
PyYAML==6.0.3 PyYAML==6.0.3
pyzmq==27.1.0 click==8.3.1
redis[hiredis]==5.2.1
regex==2025.11.3
requests==2.32.4
requests-toolbelt==1.0.0
rsa==4.9.1
ruff==0.14.10
simple-websocket==1.1.0
sniffio==1.3.1
starlette==0.50.0
tenacity==9.1.2
tiktoken==0.12.0
tqdm==4.67.1 tqdm==4.67.1
typing-inspection==0.4.2 tenacity==9.1.2
typing_extensions==4.15.0 backoff==2.2.1
tzdata==2025.3 regex==2025.11.3
Unidecode==1.4.0 Unidecode==1.4.0
urllib3==2.6.2 pillow==12.0.0
uuid_utils==0.12.0
uv==0.9.18 # WebSocket
uvicorn==0.38.0
virtualenv==20.35.4
websocket-client==1.9.0
websockets==15.0.1 websockets==15.0.1
Werkzeug==3.1.4 websocket-client==1.9.0
win32_setctime==1.2.0 python-engineio==4.12.3
wrapt==1.17.3 python-socketio==5.15.1
wsproto==1.3.2
xxhash==3.6.0 # BSON for ObjectId
zipp==3.23.0 # (included via motor)
zope.event==6.1
zope.interface==8.1.1 # Common dependencies (transitive but pinned)
zstandard==0.25.0 certifi==2025.11.12
charset-normalizer==3.4.4
idna==3.11
urllib3==2.6.2
typing_extensions==4.15.0
annotated-types==0.7.0
sniffio==1.3.1
h11==0.16.0
protobuf==6.33.2
packaging==25.0
colorama==0.4.6
numpy==2.4.0
jiter==0.12.0
MarkupSafe==3.0.3
Jinja2==3.1.6
cachetools==6.2.4
# Testing
pytest==9.0.2
# Production server
gunicorn==23.0.0 gunicorn==23.0.0
"""
Simple script to test MongoDB connection using the same config style as the backend.
Ưu tiên lấy `MONGODB_URI` từ:
- Biến môi trường / file `.env` (đúng chuẩn backend đang dùng)
- Fallback: file `env.txt` trong thư mục `backend` (đang có sẵn của bạn)
"""
from __future__ import annotations
import asyncio
import os
from typing import Optional
from motor.motor_asyncio import AsyncIOMotorClient
from dotenv import load_dotenv
BASE_DIR = os.path.dirname(__file__)
ENV_TXT_PATH = os.path.join(BASE_DIR, "env.txt")
DOTENV_PATH = os.path.join(BASE_DIR, ".env")
def load_env_from_env_txt() -> None:
"""
Load các biến môi trường từ file `env.txt` (KEY=VALUE per line).
Chỉ set nếu biến đó chưa có trong `os.environ`,
để ưu tiên biến môi trường / file `.env` nếu có.
"""
if not os.path.exists(ENV_TXT_PATH):
return
with open(ENV_TXT_PATH, encoding="utf-8") as f:
for raw_line in f:
line = raw_line.strip()
if not line or line.startswith("#"):
continue
if "=" not in line:
continue
key, value = line.split("=", 1)
key = key.strip()
value = value.strip()
if key and key not in os.environ:
os.environ[key] = value
def get_mongodb_uri() -> Optional[str]:
"""Lấy MONGODB_URI từ env (.env, env.txt hoặc biến môi trường)."""
# 1. Load từ .env nếu có
if os.path.exists(DOTENV_PATH):
load_dotenv(DOTENV_PATH, override=False)
else:
# Vẫn thử load mặc định (nếu có .env ở cwd)
load_dotenv(override=False)
# 2. Load thêm từ env.txt (chỉ set nếu chưa có)
load_env_from_env_txt()
return os.getenv("MONGODB_URI")
async def main() -> None:
uri = get_mongodb_uri()
if not uri:
print(
"❌ Không tìm thấy MONGODB_URI.\n"
"- Thêm MONGODB_URI vào file `.env` trong thư mục `backend`, hoặc\n"
"- Đảm bảo có dòng `MONGODB_URI=...` trong `backend/env.txt`."
)
return
print(f"🔍 Đang thử kết nối MongoDB với URI (đã ẩn password, chỉ in prefix): {uri[:40]}...")
client = AsyncIOMotorClient(uri, serverSelectionTimeoutMS=5000)
try:
await client.admin.command("ping")
print("✅ Kết nối MongoDB thành công! `ping` OK.")
except Exception as e: # pragma: no cover - pure debug script
print("❌ Kết nối MongoDB thất bại:")
print(repr(e))
finally:
client.close()
print("🔌 Đã đóng kết nối MongoDB.")
if __name__ == "__main__":
asyncio.run(main())
"""
API Integration tests for OpenNotion backend.
Validates all API endpoints work correctly end-to-end.
"""
import pytest
from httpx import AsyncClient
class TestMemosApiIntegration:
"""Integration tests for memos API."""
@pytest.mark.asyncio
async def test_list_memos_returns_valid_structure(self, async_client: AsyncClient):
"""Test that list memos returns proper structure."""
response = await async_client.get("/api/v1/memos")
assert response.status_code == 200
data = response.json()
assert isinstance(data, list)
if len(data) > 0:
memo = data[0]
assert "id" in memo
assert "content" in memo
assert "creator_id" in memo
@pytest.mark.asyncio
async def test_get_memo_by_id(self, async_client: AsyncClient):
"""Test getting a single memo by ID."""
# First list memos
list_response = await async_client.get("/api/v1/memos")
assert list_response.status_code == 200
memos = list_response.json()
if len(memos) > 0:
memo_id = memos[0]["id"]
# Get single memo
response = await async_client.get(f"/api/v1/memos/{memo_id}")
assert response.status_code == 200
memo = response.json()
assert memo["id"] == memo_id
@pytest.mark.asyncio
async def test_list_memos_with_tag_filter(self, async_client: AsyncClient):
"""Test filtering memos by tag."""
response = await async_client.get("/api/v1/memos?tag=test")
assert response.status_code == 200
data = response.json()
assert isinstance(data, list)
class TestUserStatsApiIntegration:
"""Integration tests for user stats API."""
@pytest.mark.asyncio
async def test_user_stats_returns_valid_structure(self, async_client: AsyncClient):
"""Test that user stats returns proper structure."""
response = await async_client.get("/api/v1/users/1/stats")
assert response.status_code == 200
data = response.json()
assert "tagCount" in data or "tag_count" in data
assert "memoDisplayTimestamps" in data or "memo_display_timestamps" in data
@pytest.mark.asyncio
async def test_user_stats_tag_count_is_dict(self, async_client: AsyncClient):
"""Test that tag count is a dictionary."""
response = await async_client.get("/api/v1/users/1/stats")
assert response.status_code == 200
data = response.json()
tag_count = data.get("tagCount") or data.get("tag_count", {})
assert isinstance(tag_count, dict)
class TestVersionsApiIntegration:
"""Integration tests for versions API."""
@pytest.mark.asyncio
async def test_versions_endpoint_exists(self, async_client: AsyncClient):
"""Test that versions endpoint is accessible."""
# First get a memo
memos_response = await async_client.get("/api/v1/memos")
if memos_response.status_code == 200 and len(memos_response.json()) > 0:
memo_id = memos_response.json()[0]["id"]
response = await async_client.get(f"/api/v1/memos/{memo_id}/versions")
assert response.status_code == 200
data = response.json()
assert "versions" in data
assert "total" in data
@pytest.mark.asyncio
async def test_versions_returns_empty_for_unedited_memo(self, async_client: AsyncClient):
"""Test that versions is empty for unedited memo."""
memos_response = await async_client.get("/api/v1/memos")
if memos_response.status_code == 200 and len(memos_response.json()) > 0:
memo_id = memos_response.json()[0]["id"]
response = await async_client.get(f"/api/v1/memos/{memo_id}/versions")
assert response.status_code == 200
data = response.json()
# Versions may be empty if never edited
assert isinstance(data["versions"], list)
class TestCommentsApiIntegration:
"""Integration tests for comments API."""
@pytest.mark.asyncio
async def test_list_comments_endpoint(self, async_client: AsyncClient):
"""Test that comments endpoint works."""
memos_response = await async_client.get("/api/v1/memos")
if memos_response.status_code == 200 and len(memos_response.json()) > 0:
memo_id = memos_response.json()[0]["id"]
response = await async_client.get(f"/api/v1/memos/{memo_id}/comments")
# Should return 200 or valid error
assert response.status_code in [200, 404]
class TestHealthCheck:
"""Health check and basic functionality tests."""
@pytest.mark.asyncio
async def test_api_is_accessible(self, async_client: AsyncClient):
"""Test that API is accessible."""
response = await async_client.get("/api/v1/memos")
assert response.status_code == 200
@pytest.mark.asyncio
async def test_cors_headers(self, async_client: AsyncClient):
"""Test CORS headers are present."""
response = await async_client.options("/api/v1/memos")
# CORS middleware should handle OPTIONS
assert response.status_code in [200, 204, 405]
"""
Simple script / pytest test to verify MongoDB connection.
Usage as script (recommended for quick check):
cd backend
# Ensure MONGODB_URI is set in environment or .env
python -m tests.test_mongodb_connection
It will:
- Read MONGODB_URI from environment (preferred)
- If missing, fallback to a hardcoded URI for local testing
- Try to run admin.command(\"ping\") against MongoDB
"""
from __future__ import annotations
import asyncio
import os
import pytest
from motor.motor_asyncio import AsyncIOMotorClient
# Fallback URI for local testing ONLY.
# Prefer setting MONGODB_URI in backend/.env or your shell instead of relying on this.
HARDCODED_TEST_URI = (
"mongodb+srv://20010841:vuhoanganh1704@cluster0.h6qro.mongodb.net/"
"?appName=Cluster0"
)
async def _ping_mongodb(uri: str) -> None:
print(f"🔌 Testing MongoDB connection with URI: {uri!r}")
client = AsyncIOMotorClient(uri, serverSelectionTimeoutMS=5000)
try:
await client.admin.command("ping")
print("✅ MongoDB ping succeeded")
finally:
client.close()
@pytest.mark.asyncio
async def test_mongodb_connection() -> None:
"""Pytest-compatible test to verify MongoDB connection."""
uri = os.getenv("MONGODB_URI") or HARDCODED_TEST_URI
await _ping_mongodb(uri)
if __name__ == "__main__":
# Allow running as a standalone script: python -m tests.test_mongodb_connection
uri = os.getenv("MONGODB_URI") or HARDCODED_TEST_URI
asyncio.run(_ping_mongodb(uri))
"""
Performance tests for the OpenNotion backend.
Tests API response times, concurrent load, and database query performance.
"""
import asyncio
import time
from typing import List
import pytest
from httpx import AsyncClient
# Performance thresholds (in seconds)
RESPONSE_TIME_THRESHOLD = 0.5 # 500ms
BULK_OPERATION_THRESHOLD = 2.0 # 2s for bulk operations
class TestApiPerformance:
"""Performance tests for API endpoints."""
@pytest.mark.asyncio
async def test_list_memos_response_time(self, async_client: AsyncClient):
"""Test that listing memos responds within threshold."""
start = time.perf_counter()
response = await async_client.get("/api/v1/memos")
elapsed = time.perf_counter() - start
assert response.status_code == 200
assert elapsed < RESPONSE_TIME_THRESHOLD, f"List memos took {elapsed:.3f}s (threshold: {RESPONSE_TIME_THRESHOLD}s)"
@pytest.mark.asyncio
async def test_get_user_stats_response_time(self, async_client: AsyncClient):
"""Test that user stats API responds within threshold."""
start = time.perf_counter()
response = await async_client.get("/api/v1/users/1/stats")
elapsed = time.perf_counter() - start
assert response.status_code == 200
assert elapsed < RESPONSE_TIME_THRESHOLD, f"User stats took {elapsed:.3f}s (threshold: {RESPONSE_TIME_THRESHOLD}s)"
@pytest.mark.asyncio
async def test_create_memo_response_time(self, async_client: AsyncClient):
"""Test memo creation performance."""
memo_data = {
"content": "Performance test memo #perf #test",
"visibility": "PUBLIC",
}
start = time.perf_counter()
response = await async_client.post("/api/v1/memos", json=memo_data)
elapsed = time.perf_counter() - start
# May fail due to auth, but we're testing response time
assert elapsed < RESPONSE_TIME_THRESHOLD, f"Create memo took {elapsed:.3f}s"
@pytest.mark.asyncio
async def test_concurrent_requests(self, async_client: AsyncClient):
"""Test handling of concurrent requests."""
num_requests = 10
async def make_request():
return await async_client.get("/api/v1/memos")
start = time.perf_counter()
tasks = [make_request() for _ in range(num_requests)]
responses = await asyncio.gather(*tasks)
elapsed = time.perf_counter() - start
# All should succeed
success_count = sum(1 for r in responses if r.status_code == 200)
assert success_count == num_requests, f"Only {success_count}/{num_requests} succeeded"
assert elapsed < BULK_OPERATION_THRESHOLD, f"Concurrent requests took {elapsed:.3f}s"
class TestDatabasePerformance:
"""Performance tests for database operations."""
@pytest.mark.asyncio
async def test_memo_query_with_filters(self, async_client: AsyncClient):
"""Test memo listing with tag filter performance."""
start = time.perf_counter()
response = await async_client.get("/api/v1/memos?tag=test")
elapsed = time.perf_counter() - start
assert response.status_code == 200
assert elapsed < RESPONSE_TIME_THRESHOLD, f"Filtered query took {elapsed:.3f}s"
@pytest.mark.asyncio
async def test_memo_query_with_date_range(self, async_client: AsyncClient):
"""Test memo listing with date range filter."""
start = time.perf_counter()
response = await async_client.get(
"/api/v1/memos?start_date=2026-01-01&end_date=2026-12-31"
)
elapsed = time.perf_counter() - start
assert response.status_code == 200
assert elapsed < RESPONSE_TIME_THRESHOLD, f"Date range query took {elapsed:.3f}s"
class TestVersionsApiPerformance:
"""Performance tests for version history API."""
@pytest.mark.asyncio
async def test_get_versions_response_time(self, async_client: AsyncClient):
"""Test version history API performance."""
# First get a memo ID
memos_response = await async_client.get("/api/v1/memos")
if memos_response.status_code == 200 and len(memos_response.json()) > 0:
memo_id = memos_response.json()[0].get("id")
start = time.perf_counter()
response = await async_client.get(f"/api/v1/memos/{memo_id}/versions")
elapsed = time.perf_counter() - start
assert response.status_code == 200
assert elapsed < RESPONSE_TIME_THRESHOLD, f"Versions API took {elapsed:.3f}s"
class TestMemoryAndResources:
"""Test for memory leaks and resource management."""
@pytest.mark.asyncio
async def test_repeated_requests_no_memory_leak(self, async_client: AsyncClient):
"""Test that repeated requests don't cause memory issues."""
import psutil
import os
process = psutil.Process(os.getpid())
initial_memory = process.memory_info().rss / 1024 / 1024 # MB
# Make many requests
for _ in range(50):
await async_client.get("/api/v1/memos")
final_memory = process.memory_info().rss / 1024 / 1024 # MB
memory_increase = final_memory - initial_memory
# Memory shouldn't increase by more than 50MB
assert memory_increase < 50, f"Memory increased by {memory_increase:.1f}MB"
class TestLoadStress:
"""Load and stress tests."""
@pytest.mark.asyncio
async def test_sustained_load(self, async_client: AsyncClient):
"""Test handling sustained load over time."""
num_requests = 30
request_times: List[float] = []
for _ in range(num_requests):
start = time.perf_counter()
response = await async_client.get("/api/v1/memos")
elapsed = time.perf_counter() - start
request_times.append(elapsed)
assert response.status_code == 200
avg_time = sum(request_times) / len(request_times)
max_time = max(request_times)
assert avg_time < RESPONSE_TIME_THRESHOLD, f"Average response time {avg_time:.3f}s exceeds threshold"
assert max_time < RESPONSE_TIME_THRESHOLD * 2, f"Max response time {max_time:.3f}s is too high"
...@@ -32,10 +32,6 @@ export const MonthCalendar = memo((props: MonthCalendarProps) => { ...@@ -32,10 +32,6 @@ export const MonthCalendar = memo((props: MonthCalendarProps) => {
return ( return (
<div className={cn("flex flex-col gap-2 relative group", className)}> <div className={cn("flex flex-col gap-2 relative group", className)}>
{/* Festive Decorations for Calendar */}
<div className="absolute -top-3 -left-3 text-lg pointer-events-none select-none opacity-40 group-hover:opacity-100 transition-opacity duration-300 animate-pulse">🌸</div>
<div className="absolute -bottom-3 -right-3 text-lg pointer-events-none select-none opacity-40 group-hover:opacity-100 transition-opacity duration-300 animate-pulse delay-500">🌼</div>
<div className={cn("grid grid-cols-7", sizeConfig.gap, "text-muted-foreground mb-1", size === "small" ? "text-[10px]" : "text-xs")}> <div className={cn("grid grid-cols-7", sizeConfig.gap, "text-muted-foreground mb-1", size === "small" ? "text-[10px]" : "text-xs")}>
{rotatedWeekDays.map((label, index) => ( {rotatedWeekDays.map((label, index) => (
<div key={index} className="flex h-4 items-center justify-center text-muted-foreground/60 font-medium"> <div key={index} className="flex h-4 items-center justify-center text-muted-foreground/60 font-medium">
......
...@@ -135,7 +135,7 @@ function CreateShortcutDialog({ open, onOpenChange, shortcut: initialShortcut, o ...@@ -135,7 +135,7 @@ function CreateShortcutDialog({ open, onOpenChange, shortcut: initialShortcut, o
filter, filter,
}, },
}); });
toast.success("Create shortcut successfully"); toast.success("Create workspace successfully");
} else { } else {
await shortcutServiceClient.updateShortcut({ await shortcutServiceClient.updateShortcut({
shortcut: { shortcut: {
...@@ -147,7 +147,7 @@ function CreateShortcutDialog({ open, onOpenChange, shortcut: initialShortcut, o ...@@ -147,7 +147,7 @@ function CreateShortcutDialog({ open, onOpenChange, shortcut: initialShortcut, o
}, },
updateMask: create(FieldMaskSchema, { paths: ["title", "filter"] }), updateMask: create(FieldMaskSchema, { paths: ["title", "filter"] }),
}); });
toast.success("Update shortcut successfully"); toast.success("Update workspace successfully");
} }
await refetchSettings(); await refetchSettings();
requestState.setFinish(); requestState.setFinish();
...@@ -155,7 +155,7 @@ function CreateShortcutDialog({ open, onOpenChange, shortcut: initialShortcut, o ...@@ -155,7 +155,7 @@ function CreateShortcutDialog({ open, onOpenChange, shortcut: initialShortcut, o
onOpenChange(false); onOpenChange(false);
} catch (error: unknown) { } catch (error: unknown) {
await handleError(error, toast.error, { await handleError(error, toast.error, {
context: isCreating ? "Create shortcut" : "Update shortcut", context: isCreating ? "Create workspace" : "Update workspace",
onError: () => requestState.setError(), onError: () => requestState.setError(),
}); });
} }
......
...@@ -42,31 +42,8 @@ const FestiveCorner = () => { ...@@ -42,31 +42,8 @@ const FestiveCorner = () => {
</div> </div>
{/* Two original side envelopes */} {/* Two original side envelopes */}
<Envelope side="left" delay="-2s" message="Vạn Sự Như Ý! 🧧" />
<Envelope side="right" message="Chúc Mừng Năm Mới! ✨" /> <Envelope side="right" message="Chúc Mừng Năm Mới! ✨" />
{/* Central decoration branch */}
<div className="fixed top-0 left-64 md:left-[480px] z-[10] pointer-events-none select-none hidden md:block">
<div className="relative">
<div className="absolute top-0 left-0 animate-[sway_6s_ease-in-out_infinite] origin-top-left">
<div className="text-5xl filter drop-shadow-lg opacity-90">🌸</div>
<div className="absolute top-4 left-6 text-3xl opacity-80">🌸</div>
<div className="absolute top-8 left-4 animate-[sway_4s_ease-in-out_infinite] delay-500 origin-top">
<div className="w-[1px] h-10 bg-red-600/30 mx-auto" />
<div className="group pointer-events-auto cursor-pointer relative">
<div className="w-12 h-18 bg-red-600 rounded-sm shadow-lg flex flex-col items-center justify-center border border-red-500 transition-transform hover:scale-110">
<div className="text-yellow-400 font-bold text-sm">🧧</div>
<div className="text-[8px] text-yellow-200 font-bold mt-1">2026</div>
</div>
<div className="absolute top-full mt-1 left-0 bg-white/90 backdrop-blur px-2 py-0.5 rounded-full text-[8px] font-bold text-red-600 shadow-lg opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap border border-red-50 italic">
Lộc xuân tràn đầy! 🌸
</div>
</div>
</div>
</div>
</div>
</div>
{/* Scattered Blossoms and Icons around the screen */} {/* Scattered Blossoms and Icons around the screen */}
<Blossom top="20%" left="5%" delay="-1s" size="text-2xl" /> <Blossom top="20%" left="5%" delay="-1s" size="text-2xl" />
<Blossom top="70%" left="2%" delay="-3s" size="text-xl" /> <Blossom top="70%" left="2%" delay="-3s" size="text-xl" />
......
import { useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { timestampDate } from "@bufbuild/protobuf/wkt";
import { BookmarkIcon, ChevronDownIcon, ChevronRightIcon } from "lucide-react";
import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
import { useTranslate } from "@/utils/i18n";
import { cn } from "@/lib/utils";
import { useInfiniteMemos } from "@/hooks/useMemoQueries";
import useCurrentUser from "@/hooks/useCurrentUser";
import { State } from "@/types/proto/api/v1/common_pb";
import { DEFAULT_LIST_MEMOS_PAGE_SIZE } from "@/helpers/consts";
interface BookmarksSectionProps {
className?: string;
}
// Max items to show when expanded
const MAX_VISIBLE = 5;
const BookmarksSection = ({ className }: BookmarksSectionProps) => {
const t = useTranslate();
const user = useCurrentUser();
// Default COLLAPSED
const [isOpen, setIsOpen] = useState(false);
const [showAll, setShowAll] = useState(false);
// Build simple filter for pinned memos - NO context filters
const pinnedFilter = useMemo(() => {
if (user?.name) {
const userId = user.name.split("/")[1];
return `creator_id == ${userId} && pinned`;
}
return "pinned";
}, [user?.name]);
const { data: pinnedData, isLoading } = useInfiniteMemos({
state: State.NORMAL,
orderBy: "display_time desc",
filter: pinnedFilter,
pageSize: DEFAULT_LIST_MEMOS_PAGE_SIZE,
});
const pinnedMemos = useMemo(() => {
return pinnedData?.pages.flatMap((page) => page.memos) || [];
}, [pinnedData]);
if (isLoading || pinnedMemos.length === 0) {
return null;
}
// Get preview (first line, max 30 chars)
const getPreview = (content: string): string => {
const firstLine = content.split("\n")[0].trim();
const cleanLine = firstLine.replace(/^#+\s*/, "");
return cleanLine.length > 30 ? `${cleanLine.slice(0, 30)}...` : cleanLine || "(empty)";
};
// Format relative time
const formatTime = (memo: Memo): string => {
if (!memo.displayTime) return "";
const date = timestampDate(memo.displayTime);
if (!date) return "";
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
const diffDays = Math.floor(diffHours / 24);
if (diffHours < 1) return "now";
if (diffHours < 24) return `${diffHours}h`;
return `${diffDays}d`;
};
const visibleMemos = showAll ? pinnedMemos : pinnedMemos.slice(0, MAX_VISIBLE);
const hiddenCount = pinnedMemos.length - MAX_VISIBLE;
return (
<div className={cn("w-full", className)}>
{/* Collapsible Header */}
<button
onClick={() => setIsOpen(!isOpen)}
className={cn(
"w-full flex items-center justify-between py-2 px-1",
"hover:bg-muted/40 rounded-md transition-colors",
"group cursor-pointer"
)}
>
<div className="flex items-center gap-1.5">
{isOpen ? (
<ChevronDownIcon className="w-4 h-4 text-muted-foreground" />
) : (
<ChevronRightIcon className="w-4 h-4 text-muted-foreground" />
)}
<BookmarkIcon className="w-4 h-4 text-primary/70" />
<span className="text-sm font-medium text-foreground/80">
{t("common.bookmarks" as any) || "Bookmarks"}
</span>
</div>
<span className="text-xs text-muted-foreground bg-muted/50 px-1.5 py-0.5 rounded-full">
{pinnedMemos.length}
</span>
</button>
{/* Bookmark List - Only show when expanded */}
{isOpen && (
<div className="flex flex-col gap-0.5 mt-1 pl-5">
{visibleMemos.map((memo) => {
const memoPath = memo.name.startsWith("memos/") ? memo.name : `memos/${memo.name}`;
const preview = getPreview(memo.content || "");
const time = formatTime(memo);
return (
<Link
key={memo.name}
to={`/${memoPath}`}
className={cn(
"flex items-center justify-between",
"px-2 py-1.5 rounded-md",
"hover:bg-muted/60 transition-colors",
"group"
)}
>
<span className="text-sm text-foreground/70 group-hover:text-foreground truncate flex-1 mr-2">
{preview}
</span>
{time && (
<span className="text-[10px] text-muted-foreground shrink-0">
{time}
</span>
)}
</Link>
);
})}
{/* Show More / Show Less */}
{hiddenCount > 0 && (
<button
onClick={(e) => {
e.stopPropagation();
setShowAll(!showAll);
}}
className={cn(
"flex items-center gap-1 px-2 py-1.5",
"text-xs text-muted-foreground hover:text-foreground",
"transition-colors rounded-md hover:bg-muted/40"
)}
>
{showAll ? (
<>
<ChevronDownIcon className="w-3 h-3" />
<span>{t("common.hide-older" as any) || "Show less"}</span>
</>
) : (
<>
<ChevronRightIcon className="w-3 h-3" />
<span>+{hiddenCount} {t("common.more" as any) || "more"}</span>
</>
)}
</button>
)}
</div>
)}
</div>
);
};
export default BookmarksSection;
...@@ -3,6 +3,7 @@ import useCurrentUser from "@/hooks/useCurrentUser"; ...@@ -3,6 +3,7 @@ import useCurrentUser from "@/hooks/useCurrentUser";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { StatisticsData } from "@/types/statistics"; import type { StatisticsData } from "@/types/statistics";
import StatisticsView from "../StatisticsView"; import StatisticsView from "../StatisticsView";
import BookmarksSection from "./BookmarksSection";
import ShortcutsSection from "./ShortcutsSection"; import ShortcutsSection from "./ShortcutsSection";
import TagsSection from "./TagsSection"; import TagsSection from "./TagsSection";
...@@ -11,6 +12,7 @@ export type MemoExplorerContext = "home" | "explore" | "archived" | "profile"; ...@@ -11,6 +12,7 @@ export type MemoExplorerContext = "home" | "explore" | "archived" | "profile";
export interface MemoExplorerFeatures { export interface MemoExplorerFeatures {
search?: boolean; search?: boolean;
statistics?: boolean; statistics?: boolean;
bookmarks?: boolean;
shortcuts?: boolean; shortcuts?: boolean;
tags?: boolean; tags?: boolean;
} }
...@@ -29,21 +31,24 @@ const getDefaultFeatures = (context: MemoExplorerContext): MemoExplorerFeatures ...@@ -29,21 +31,24 @@ const getDefaultFeatures = (context: MemoExplorerContext): MemoExplorerFeatures
return { return {
search: true, search: true,
statistics: true, statistics: true,
shortcuts: false, // Global explore doesn't use shortcuts bookmarks: false, // No bookmarks in explore
shortcuts: true,
tags: true, tags: true,
}; };
case "archived": case "archived":
return { return {
search: true, search: true,
statistics: true, statistics: true,
shortcuts: false, // Archived doesn't typically use shortcuts bookmarks: false, // No bookmarks in archived
shortcuts: true,
tags: true, tags: true,
}; };
case "profile": case "profile":
return { return {
search: true, search: true,
statistics: true, statistics: true,
shortcuts: false, // Profile view doesn't use shortcuts bookmarks: false, // No bookmarks in profile view
shortcuts: true,
tags: true, tags: true,
}; };
case "home": case "home":
...@@ -51,6 +56,7 @@ const getDefaultFeatures = (context: MemoExplorerContext): MemoExplorerFeatures ...@@ -51,6 +56,7 @@ const getDefaultFeatures = (context: MemoExplorerContext): MemoExplorerFeatures
return { return {
search: true, search: true,
statistics: true, statistics: true,
bookmarks: true, // Bookmarks in home
shortcuts: true, shortcuts: true,
tags: true, tags: true,
}; };
...@@ -77,7 +83,8 @@ const MemoExplorer = (props: Props) => { ...@@ -77,7 +83,8 @@ const MemoExplorer = (props: Props) => {
{features.search && <SearchBar />} {features.search && <SearchBar />}
<div className="mt-1 px-1 w-full"> <div className="mt-1 px-1 w-full">
{features.statistics && <StatisticsView statisticsData={statisticsData} />} {features.statistics && <StatisticsView statisticsData={statisticsData} />}
{features.shortcuts && currentUser && <ShortcutsSection />} {features.bookmarks && <BookmarksSection className="mt-2" />}
{features.shortcuts && <ShortcutsSection />}
{features.tags && <TagsSection readonly={context === "explore"} tagCount={tagCount} />} {features.tags && <TagsSection readonly={context === "explore"} tagCount={tagCount} />}
</div> </div>
</aside> </aside>
......
...@@ -75,6 +75,7 @@ function ShortcutsSection() { ...@@ -75,6 +75,7 @@ function ShortcutsSection() {
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
</div> </div>
{shortcuts.length > 0 ? (
<div className="w-full flex flex-row justify-start items-center relative flex-wrap gap-x-2 gap-y-1"> <div className="w-full flex flex-row justify-start items-center relative flex-wrap gap-x-2 gap-y-1">
{shortcuts.map((shortcut) => { {shortcuts.map((shortcut) => {
const shortcutId = getShortcutId(shortcut.name); const shortcutId = getShortcutId(shortcut.name);
...@@ -113,6 +114,11 @@ function ShortcutsSection() { ...@@ -113,6 +114,11 @@ function ShortcutsSection() {
); );
})} })}
</div> </div>
) : (
<div className="w-full text-sm text-muted-foreground/60 italic px-1 py-1">
{t("common.no-shortcuts") || "No workspaces yet. Click + to create one."}
</div>
)}
<CreateShortcutDialog <CreateShortcutDialog
open={isCreateShortcutDialogOpen} open={isCreateShortcutDialogOpen}
onOpenChange={setIsCreateShortcutDialogOpen} onOpenChange={setIsCreateShortcutDialogOpen}
......
import { useState } from "react";
import { Clock, ChevronDown, ChevronUp, History } from "lucide-react";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
dayjs.extend(relativeTime);
interface MemoVersion {
version: number;
content: string;
createdAt: string;
createdBy?: string;
}
interface MemoVersionHistoryProps {
versions: MemoVersion[];
currentContent?: string;
}
/**
* MemoVersionHistory - Component to display version history of a memo
* Shows a collapsible list of previous versions with diffs
*/
export function MemoVersionHistory({ versions, currentContent }: MemoVersionHistoryProps) {
const [isExpanded, setIsExpanded] = useState(false);
const [selectedVersion, setSelectedVersion] = useState<number | null>(null);
if (!versions || versions.length === 0) {
return null; // Don't render if no versions
}
const sortedVersions = [...versions].sort((a, b) => b.version - a.version);
return (
<div className="memo-version-history border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
{/* Header - Toggle Button */}
<button
onClick={() => setIsExpanded(!isExpanded)}
className="w-full flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
>
<div className="flex items-center gap-2 text-gray-600 dark:text-gray-300">
<History size={16} />
<span className="text-sm font-medium">Version History</span>
<span className="text-xs bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-300 px-2 py-0.5 rounded-full">
{versions.length}
</span>
</div>
{isExpanded ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
</button>
{/* Versions List */}
{isExpanded && (
<div className="divide-y divide-gray-200 dark:divide-gray-700">
{sortedVersions.map((version) => (
<div
key={version.version}
className={`p-3 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors ${selectedVersion === version.version ? "bg-blue-50 dark:bg-blue-900/20" : ""
}`}
onClick={() => setSelectedVersion(
selectedVersion === version.version ? null : version.version
)}
>
{/* Version Header */}
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
v{version.version}
</span>
{version.createdBy && (
<span className="text-xs text-gray-500 dark:text-gray-400">
by {version.createdBy}
</span>
)}
</div>
<div className="flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400">
<Clock size={12} />
{dayjs(version.createdAt).fromNow()}
</div>
</div>
{/* Version Content Preview */}
<div className="text-sm text-gray-600 dark:text-gray-400 line-clamp-2">
{version.content.substring(0, 150)}
{version.content.length > 150 && "..."}
</div>
{/* Expanded Content */}
{selectedVersion === version.version && (
<div className="mt-3 p-3 bg-gray-100 dark:bg-gray-900 rounded border border-gray-200 dark:border-gray-700">
<div className="text-xs text-gray-500 dark:text-gray-400 mb-2">
Full content at this version:
</div>
<div className="text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap">
{version.content}
</div>
</div>
)}
</div>
))}
</div>
)}
</div>
);
}
export default MemoVersionHistory;
export { MemoVersionHistory } from "./MemoVersionHistory";
...@@ -22,6 +22,7 @@ const MemoView: React.FC<MemoViewProps> = (props: MemoViewProps) => { ...@@ -22,6 +22,7 @@ const MemoView: React.FC<MemoViewProps> = (props: MemoViewProps) => {
const isAnonymousCreator = memoData.creator.startsWith("users/anonymous_"); const isAnonymousCreator = memoData.creator.startsWith("users/anonymous_");
const creator = useUser(memoData.creator, { enabled: !isAnonymousCreator }).data; const creator = useUser(memoData.creator, { enabled: !isAnonymousCreator }).data;
const isArchived = memoData.state === State.ARCHIVED; const isArchived = memoData.state === State.ARCHIVED;
// In production, only the memo owner (or superuser) can edit/pin.
const readonly = memoData.creator !== currentUser?.name && !isSuperUser(currentUser); const readonly = memoData.creator !== currentUser?.name && !isSuperUser(currentUser);
const parentPage = parentPageProp || "/"; const parentPage = parentPageProp || "/";
......
import { timestampDate } from "@bufbuild/protobuf/wkt"; // Legacy password-based sign-in form has been removed.
import { LoaderIcon } from "lucide-react"; // Auth is now handled entirely by Clerk components (`<SignIn />`, `<SignUp />`).
import { useState } from "react"; // This file is kept as a placeholder to avoid import breakages;
import { toast } from "react-hot-toast"; // you can safely delete it once all references are removed.
import { setAccessToken } from "@/auth-state";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { authServiceClient } from "@/service";
import { useAuth } from "@/contexts/AuthContext";
import { useInstance } from "@/contexts/InstanceContext";
import useLoading from "@/hooks/useLoading";
import useNavigateTo from "@/hooks/useNavigateTo";
import { handleError } from "@/lib/error";
import { useTranslate } from "@/utils/i18n";
function PasswordSignInForm() { const PasswordSignInForm = () => null;
const t = useTranslate();
const navigateTo = useNavigateTo();
const { profile } = useInstance();
const { initialize } = useAuth();
const actionBtnLoadingState = useLoading(false);
const [username, setUsername] = useState(profile.mode === "demo" ? "demo" : "");
const [password, setPassword] = useState(profile.mode === "demo" ? "secret" : "");
const handleUsernameInputChanged = (e: React.ChangeEvent<HTMLInputElement>) => {
const text = e.target.value as string;
setUsername(text);
};
const handlePasswordInputChanged = (e: React.ChangeEvent<HTMLInputElement>) => {
const text = e.target.value as string;
setPassword(text);
};
const handleFormSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
handleSignInButtonClick();
};
const handleSignInButtonClick = async () => {
if (username === "" || password === "") {
return;
}
if (actionBtnLoadingState.isLoading) {
return;
}
try {
actionBtnLoadingState.setLoading();
const response = await authServiceClient.signIn({
credentials: {
case: "passwordCredentials",
value: { username, password },
},
});
// Store access token from login response
if (response.accessToken) {
setAccessToken(response.accessToken, response.accessTokenExpiresAt ? timestampDate(response.accessTokenExpiresAt) : undefined);
}
await initialize();
navigateTo("/");
} catch (error: unknown) {
handleError(error, toast.error, {
fallbackMessage: "Failed to sign in.",
});
}
actionBtnLoadingState.setFinish();
};
return (
<form className="w-full mt-2" onSubmit={handleFormSubmit}>
<div className="flex flex-col justify-start items-start w-full gap-4">
<div className="w-full flex flex-col justify-start items-start">
<span className="leading-8 text-muted-foreground">{t("common.username")}</span>
<Input
className="w-full bg-background h-10"
type="text"
readOnly={actionBtnLoadingState.isLoading}
placeholder={t("common.username")}
value={username}
autoComplete="username"
autoCapitalize="off"
spellCheck={false}
onChange={handleUsernameInputChanged}
required
/>
</div>
<div className="w-full flex flex-col justify-start items-start">
<span className="leading-8 text-muted-foreground">{t("common.password")}</span>
<Input
className="w-full bg-background h-10"
type="password"
readOnly={actionBtnLoadingState.isLoading}
placeholder={t("common.password")}
value={password}
autoComplete="current-password"
autoCapitalize="off"
spellCheck={false}
onChange={handlePasswordInputChanged}
required
/>
</div>
</div>
<div className="flex flex-row justify-end items-center w-full mt-6">
<Button type="submit" className="w-full h-10" disabled={actionBtnLoadingState.isLoading} onClick={handleSignInButtonClick}>
{t("common.sign-in")}
{actionBtnLoadingState.isLoading && <LoaderIcon className="w-5 h-auto ml-2 animate-spin opacity-60" />}
</Button>
</div>
</form>
);
}
export default PasswordSignInForm; export default PasswordSignInForm;
import { useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { timestampDate } from "@bufbuild/protobuf/wkt";
import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
import { useTranslate } from "@/utils/i18n";
import { cn } from "@/lib/utils";
import { BookmarkIcon, ChevronDownIcon, ChevronRightIcon, SparklesIcon } from "lucide-react";
import { useInfiniteMemos } from "@/hooks/useMemoQueries";
import { useMemoFilters } from "@/hooks";
import { State } from "@/types/proto/api/v1/common_pb";
import { DEFAULT_LIST_MEMOS_PAGE_SIZE } from "@/helpers/consts";
import i18n from "@/i18n";
interface PinnedSectionProps {
creatorName?: string;
className?: string;
}
// Number of days to consider a bookmark as "recent"
const RECENT_DAYS = 3;
const PinnedSection = ({ creatorName, className }: PinnedSectionProps) => {
const t = useTranslate();
const [showOlder, setShowOlder] = useState(false);
// Build filter for pinned memos only
const baseFilter = useMemoFilters({
creatorName,
includeShortcuts: true,
includePinned: false, // Don't include pinned filter from context
});
// Add pinned filter
const pinnedFilter = useMemo(() => {
return baseFilter ? `${baseFilter} && pinned` : "pinned";
}, [baseFilter]);
// Fetch only pinned memos
const { data: pinnedData, isLoading } = useInfiniteMemos({
state: State.NORMAL,
orderBy: "display_time desc",
filter: pinnedFilter,
pageSize: DEFAULT_LIST_MEMOS_PAGE_SIZE,
});
// Flatten pinned memos
const pinnedMemos = useMemo(() => {
return pinnedData?.pages.flatMap((page) => page.memos) || [];
}, [pinnedData]);
// Split memos into recent and older
const { recentMemos, olderMemos } = useMemo(() => {
const now = new Date();
const recentThreshold = new Date(now.getTime() - RECENT_DAYS * 24 * 60 * 60 * 1000);
const recent: Memo[] = [];
const older: Memo[] = [];
pinnedMemos.forEach((memo) => {
if (!memo.displayTime) {
older.push(memo);
return;
}
const memoDate = timestampDate(memo.displayTime);
if (memoDate && memoDate >= recentThreshold) {
recent.push(memo);
} else {
older.push(memo);
}
});
return { recentMemos: recent, olderMemos: older };
}, [pinnedMemos]);
if (isLoading || pinnedMemos.length === 0) {
return null;
}
// Extract preview text (first line, max 50 chars)
const getPreview = (content: string): string => {
const firstLine = content.split("\n")[0].trim();
// Remove markdown headers
const cleanLine = firstLine.replace(/^#+\s*/, "");
return cleanLine.length > 50 ? `${cleanLine.slice(0, 50)}...` : cleanLine;
};
// Format relative time
const formatTime = (memo: Memo): string => {
if (!memo.displayTime) return "";
const date = timestampDate(memo.displayTime);
if (!date) return "";
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
const diffDays = Math.floor(diffHours / 24);
if (diffHours < 1) return t("common.just-now" as any) || "Just now";
if (diffHours < 24) return `${diffHours}h`;
if (diffDays < 7) return `${diffDays}d`;
return date.toLocaleDateString(i18n.language, { month: "short", day: "numeric" });
};
// Render a single bookmark card
const renderBookmarkCard = (memo: Memo, isRecent: boolean = false) => {
const preview = getPreview(memo.content || "");
const timeStr = formatTime(memo);
const memoPath = memo.name.startsWith("memos/") ? memo.name : `memos/${memo.name}`;
return (
<Link
key={memo.name}
to={`/${memoPath}`}
className={cn(
"flex flex-col gap-1 px-3 py-2",
"rounded-lg border transition-all duration-200",
"shrink-0 min-w-[160px] max-w-[220px]",
"group relative overflow-hidden",
isRecent ? [
// Recent bookmarks - more prominent style
"bg-gradient-to-br from-primary/5 to-primary/10",
"border-primary/20 hover:border-primary/40",
"hover:shadow-md hover:shadow-primary/10",
"hover:-translate-y-0.5",
] : [
// Older bookmarks - subtle style
"bg-muted/30 border-border/50",
"hover:bg-muted/50 hover:border-border",
"hover:shadow-sm",
]
)}
>
{/* Recent indicator */}
{isRecent && (
<div className="absolute top-1 right-1">
<SparklesIcon className="w-3 h-3 text-primary/60" />
</div>
)}
{/* Content preview */}
<span className={cn(
"truncate font-medium leading-tight text-sm",
isRecent ? "text-foreground" : "text-foreground/80"
)}>
{preview || "(empty)"}
</span>
{/* Time */}
{timeStr && (
<span className={cn(
"text-[10px]",
isRecent ? "text-primary/70" : "text-muted-foreground"
)}>
{timeStr}
</span>
)}
</Link>
);
};
return (
<div className={cn("w-full", className)}>
{/* Bookmarks Section */}
<div className="rounded-xl bg-gradient-to-r from-muted/40 via-muted/30 to-muted/40 border border-border/50 p-3">
{/* Header */}
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<div className="p-1.5 rounded-lg bg-primary/10">
<BookmarkIcon className="w-4 h-4 text-primary" />
</div>
<span className="text-sm font-semibold text-foreground">
{t("common.bookmarks" as any) || "Bookmarks"}
</span>
<span className="text-xs text-muted-foreground px-1.5 py-0.5 rounded-full bg-muted/50">
{pinnedMemos.length}
</span>
</div>
</div>
{/* Recent Bookmarks */}
{recentMemos.length > 0 && (
<div className="mb-2">
<div className="flex items-center gap-1.5 mb-2">
<SparklesIcon className="w-3 h-3 text-primary/70" />
<span className="text-xs font-medium text-primary/80">
{t("common.recent" as any) || "Recent"}
</span>
</div>
<div className="flex flex-row items-stretch gap-2 overflow-x-auto pb-1 scrollbar-thin scrollbar-thumb-muted scrollbar-track-transparent">
{recentMemos.map((memo) => renderBookmarkCard(memo, true))}
</div>
</div>
)}
{/* Older Bookmarks - Collapsible */}
{olderMemos.length > 0 && (
<div>
<button
onClick={() => setShowOlder(!showOlder)}
className={cn(
"flex items-center gap-1.5 text-xs text-muted-foreground",
"hover:text-foreground transition-colors py-1 px-1 -ml-1 rounded",
"hover:bg-muted/50"
)}
>
{showOlder ? (
<ChevronDownIcon className="w-3 h-3" />
) : (
<ChevronRightIcon className="w-3 h-3" />
)}
<span>
{showOlder
? (t("common.hide-older" as any) || "Hide older")
: `${olderMemos.length} ${t("common.older-bookmarks" as any) || "older bookmarks"}`
}
</span>
</button>
{showOlder && (
<div className="flex flex-row items-stretch gap-2 overflow-x-auto pt-2 pb-1 scrollbar-thin scrollbar-thumb-muted scrollbar-track-transparent">
{olderMemos.map((memo) => renderBookmarkCard(memo, false))}
</div>
)}
</div>
)}
{/* If all bookmarks are recent, just show them */}
{recentMemos.length === 0 && olderMemos.length > 0 && !showOlder && (
<div className="flex flex-row items-stretch gap-2 overflow-x-auto pb-1 scrollbar-thin scrollbar-thumb-muted scrollbar-track-transparent">
{olderMemos.slice(0, 5).map((memo) => renderBookmarkCard(memo, false))}
{olderMemos.length > 5 && (
<button
onClick={() => setShowOlder(true)}
className={cn(
"flex items-center justify-center px-4 py-2",
"rounded-lg border border-dashed border-muted-foreground/30",
"text-xs text-muted-foreground hover:text-foreground",
"hover:border-muted-foreground/50 hover:bg-muted/30",
"transition-all duration-200 shrink-0"
)}
>
+{olderMemos.length - 5} {t("common.more") || "more"}
</button>
)}
</div>
)}
</div>
</div>
);
};
export default PinnedSection;
export { default } from "./PinnedSection";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { createContext, type ReactNode, useCallback, useContext, useMemo, useState } from "react"; import { createContext, type ReactNode, useCallback, useContext, useMemo, useState } from "react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { clearAccessToken } from "@/auth-state"; import { clearAccessToken, getAccessToken } from "@/auth-state";
import { authServiceClient, shortcutServiceClient, userServiceClient } from "@/service"; import { authServiceClient, shortcutServiceClient, userServiceClient } from "@/service";
import { userKeys } from "@/hooks/useUserQueries"; import { userKeys } from "@/hooks/useUserQueries";
import { getClerkSessionToken } from "@/utils/clerk";
import type { Shortcut } from "@/types/proto/api/v1/shortcut_service_pb"; import type { Shortcut } from "@/types/proto/api/v1/shortcut_service_pb";
import type { User, UserSetting_GeneralSetting, UserSetting_WebhooksSetting } from "@/types/proto/api/v1/user_service_pb"; import type { User, UserSetting_GeneralSetting, UserSetting_WebhooksSetting } from "@/types/proto/api/v1/user_service_pb";
...@@ -54,9 +55,12 @@ export function AuthProvider({ children }: { children: ReactNode }) { ...@@ -54,9 +55,12 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const initialize = useCallback(async () => { const initialize = useCallback(async () => {
setState((prev) => ({ ...prev, isLoading: true })); setState((prev) => ({ ...prev, isLoading: true }));
try { try {
// Check if we have a token/session before making API call // Check if we have a token/session before making API call.
// This prevents unnecessary 401 errors when user is not logged in // - Prefer Clerk session (SSO)
const hasToken = localStorage.getItem("accessToken") || sessionStorage.getItem("accessToken"); // - Fallback to legacy memo access token store
const clerkToken = await getClerkSessionToken();
const legacyToken = getAccessToken();
const hasToken = !!(clerkToken || legacyToken);
if (!hasToken) { if (!hasToken) {
// No token means user is not logged in - skip API call // No token means user is not logged in - skip API call
......
...@@ -19,10 +19,12 @@ export interface UseFilteredMemoStatsOptions { ...@@ -19,10 +19,12 @@ export interface UseFilteredMemoStatsOptions {
export const useFilteredMemoStats = (options: UseFilteredMemoStatsOptions = {}): FilteredMemoStats => { export const useFilteredMemoStats = (options: UseFilteredMemoStatsOptions = {}): FilteredMemoStats => {
const { userName } = options; const { userName } = options;
// Fetch user stats if userName is provided // Always fetch user stats - use provided userName or fallback to default 'users/1'
const { data: userStats, isLoading: isLoadingUserStats } = useUserStats(userName); // This ensures Tags section always shows data from backend API
const effectiveUserName = userName || "users/1";
const { data: userStats, isLoading: isLoadingUserStats } = useUserStats(effectiveUserName);
// Fetch memos for fallback computation (or when userName is not provided) // Fetch memos for additional fallback computation (activity calendar etc)
const { data: memosResponse, isLoading: isLoadingMemos } = useMemos({}); const { data: memosResponse, isLoading: isLoadingMemos } = useMemos({});
const data = useMemo(() => { const data = useMemo(() => {
...@@ -30,8 +32,8 @@ export const useFilteredMemoStats = (options: UseFilteredMemoStatsOptions = {}): ...@@ -30,8 +32,8 @@ export const useFilteredMemoStats = (options: UseFilteredMemoStatsOptions = {}):
let activityStats: Record<string, number> = {}; let activityStats: Record<string, number> = {};
let tagCount: Record<string, number> = {}; let tagCount: Record<string, number> = {};
// Try to use backend user stats if userName is provided and available // Prioritize backend user stats (always called now with effectiveUserName)
if (userName && userStats) { if (userStats) {
// Use activity timestamps from user stats // Use activity timestamps from user stats
if (userStats.memoDisplayTimestamps && userStats.memoDisplayTimestamps.length > 0) { if (userStats.memoDisplayTimestamps && userStats.memoDisplayTimestamps.length > 0) {
activityStats = countBy( activityStats = countBy(
...@@ -58,10 +60,20 @@ export const useFilteredMemoStats = (options: UseFilteredMemoStatsOptions = {}): ...@@ -58,10 +60,20 @@ export const useFilteredMemoStats = (options: UseFilteredMemoStatsOptions = {}):
displayTimeList.push(displayTime); displayTimeList.push(displayTime);
} }
// Count tags // Count tags
// 1) Prefer explicit tags field from backend if available
if (memo.tags && memo.tags.length > 0) { if (memo.tags && memo.tags.length > 0) {
for (const tag of memo.tags) { for (const tag of memo.tags) {
tagCount[tag] = (tagCount[tag] || 0) + 1; tagCount[tag] = (tagCount[tag] || 0) + 1;
} }
} else if (memo.content && memo.content.length > 0) {
// 2) Fallback for current CuCu Mongo backend:
// parse inline tags from markdown content (e.g. "#work", "#chao_em").
const matches = memo.content.match(/#([^\s#]+)/g) || [];
for (const raw of matches) {
const tag = raw.slice(1); // remove leading '#'
if (!tag) continue;
tagCount[tag] = (tagCount[tag] || 0) + 1;
}
} }
} }
......
...@@ -3,6 +3,8 @@ import { FieldMaskSchema } from "@bufbuild/protobuf/wkt"; ...@@ -3,6 +3,8 @@ import { FieldMaskSchema } from "@bufbuild/protobuf/wkt";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { authServiceClient, shortcutServiceClient, userServiceClient } from "@/service"; import { authServiceClient, shortcutServiceClient, userServiceClient } from "@/service";
import { buildUserSettingName } from "@/helpers/resource-names"; import { buildUserSettingName } from "@/helpers/resource-names";
import { getAccessToken } from "@/auth-state";
import { getClerkSessionToken } from "@/utils/clerk";
import { User, UserSetting, UserSetting_GeneralSetting, UserSetting_Key, UserSettingSchema } from "@/types/proto/api/v1/user_service_pb"; import { User, UserSetting, UserSetting_GeneralSetting, UserSetting_Key, UserSettingSchema } from "@/types/proto/api/v1/user_service_pb";
// Query keys factory // Query keys factory
...@@ -18,24 +20,37 @@ export const userKeys = { ...@@ -18,24 +20,37 @@ export const userKeys = {
byNames: (names: string[]) => [...userKeys.all, "byNames", ...names.sort()] as const, byNames: (names: string[]) => [...userKeys.all, "byNames", ...names.sort()] as const,
}; };
// NOTE: This hook is currently UNUSED in favor of the AuthContext-based
// useCurrentUser hook (src/hooks/useCurrentUser.ts). This is kept for potential
// future migration to React Query for auth state.
export function useCurrentUserQuery() { export function useCurrentUserQuery() {
return useQuery({ return useQuery({
queryKey: userKeys.currentUser(), queryKey: userKeys.currentUser(),
queryFn: async () => { queryFn: async () => {
try { try {
// Avoid hitting /auth/me when we clearly have no token/session yet.
// This prevents unnecessary 401s during initial load as Clerk boots up.
const [clerkToken, legacyToken] = await Promise.all([
getClerkSessionToken(),
Promise.resolve(getAccessToken()),
]);
if (!clerkToken && !legacyToken) {
// No session at all -> treat as not logged in without calling backend.
return null;
}
const { user } = await authServiceClient.getCurrentUser({}); const { user } = await authServiceClient.getCurrentUser({});
return user; return user;
} catch (error) { } catch (error) {
// 401 is expected when not logged in - return undefined instead of throwing // 401 is expected when not logged in - treat as "no user" instead of error
const errorMessage = error instanceof Error ? error.message : String(error); const errorMessage = error instanceof Error ? error.message : String(error);
if (errorMessage.includes("401") || if (
errorMessage.includes("401") ||
errorMessage.includes("403") || errorMessage.includes("403") ||
errorMessage.includes("Unauthorized") || errorMessage.includes("Unauthorized") ||
errorMessage.includes("Missing authentication token")) { errorMessage.includes("Missing authentication token")
return undefined; ) {
// React Query v5 không cho phép queryFn trả về undefined,
// nên dùng null để biểu diễn "chưa đăng nhập".
return null;
} }
throw error; throw error;
} }
......
...@@ -69,6 +69,11 @@ ...@@ -69,6 +69,11 @@
"password": "Password", "password": "Password",
"pin": "Pin", "pin": "Pin",
"pinned": "Pinned", "pinned": "Pinned",
"bookmarks": "Bookmarks",
"recent": "Recent",
"older-bookmarks": "older bookmarks",
"hide-older": "Hide older",
"just-now": "Just now",
"preview": "Preview", "preview": "Preview",
"profile": "Profile", "profile": "Profile",
"properties": "Properties", "properties": "Properties",
...@@ -86,8 +91,8 @@ ...@@ -86,8 +91,8 @@
"select": "Select", "select": "Select",
"settings": "Settings", "settings": "Settings",
"share": "Share", "share": "Share",
"shortcut-filter": "Shortcut filter", "shortcut-filter": "Workspace filter",
"shortcuts": "Shortcuts", "shortcuts": "Workspaces",
"sign-in": "Sign in", "sign-in": "Sign in",
"sign-in-with": "Sign in with {{provider}}", "sign-in-with": "Sign in with {{provider}}",
"sign-out": "Sign out", "sign-out": "Sign out",
......
...@@ -49,6 +49,11 @@ ...@@ -49,6 +49,11 @@
"password": "Mật khẩu", "password": "Mật khẩu",
"pin": "Ghim", "pin": "Ghim",
"pinned": "Đã ghim", "pinned": "Đã ghim",
"bookmarks": "Đánh dấu",
"recent": "Gần đây",
"older-bookmarks": "đánh dấu cũ hơn",
"hide-older": "Ẩn cũ hơn",
"just-now": "Vừa xong",
"preview": "Xem trước", "preview": "Xem trước",
"profile": "Hồ sơ", "profile": "Hồ sơ",
"remember-me": "Ghi nhớ tôi", "remember-me": "Ghi nhớ tôi",
......
import { timestampDate } from "@bufbuild/protobuf/wkt";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useSearchParams } from "react-router-dom"; import { useSearchParams } from "react-router-dom";
import { setAccessToken } from "@/auth-state";
import Spinner from "@/components/Spinner"; import Spinner from "@/components/Spinner";
import { authServiceClient } from "@/service";
import { useAuth } from "@/contexts/AuthContext";
import { absolutifyLink } from "@/helpers/utils";
import useNavigateTo from "@/hooks/useNavigateTo";
import { handleError } from "@/lib/error";
import { validateOAuthState } from "@/utils/oauth";
interface State { interface State {
loading: boolean; loading: boolean;
...@@ -16,8 +8,6 @@ interface State { ...@@ -16,8 +8,6 @@ interface State {
} }
const AuthCallback = () => { const AuthCallback = () => {
const navigateTo = useNavigateTo();
const { initialize } = useAuth();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const [state, setState] = useState<State>({ const [state, setState] = useState<State>({
loading: true, loading: true,
...@@ -47,67 +37,12 @@ const AuthCallback = () => { ...@@ -47,67 +37,12 @@ const AuthCallback = () => {
return; return;
} }
const code = searchParams.get("code"); // Legacy OAuth callback for memos has been deprecated.
const state = searchParams.get("state"); // Clerk now handles all OAuth flows internally.
if (!code || !state) {
setState({
loading: false,
errorMessage: "Failed to authorize. Missing authorization code or state parameter.",
});
return;
}
// Validate OAuth state (CSRF protection) and retrieve PKCE code_verifier
const validatedState = validateOAuthState(state);
if (!validatedState) {
setState({
loading: false,
errorMessage: "Failed to authorize. Invalid or expired state parameter. This may indicate a CSRF attack attempt.",
});
return;
}
const { identityProviderId, returnUrl, codeVerifier } = validatedState;
const redirectUri = absolutifyLink("/auth/callback");
(async () => {
try {
const response = await authServiceClient.signIn({
credentials: {
case: "ssoCredentials",
value: {
idpId: identityProviderId,
code,
redirectUri,
codeVerifier: codeVerifier || "", // Pass PKCE code_verifier for token exchange
},
},
});
// Store access token from login response
if (response.accessToken) {
setAccessToken(response.accessToken, response.accessTokenExpiresAt ? timestampDate(response.accessTokenExpiresAt) : undefined);
}
setState({ setState({
loading: false, loading: false,
errorMessage: "", errorMessage: "This OAuth callback endpoint is no longer used. Authentication is handled by Clerk.",
}); });
await initialize();
// Redirect to return URL if specified, otherwise home
navigateTo(returnUrl || "/");
} catch (error: unknown) {
handleError(error, () => {}, {
fallbackMessage: "Failed to authenticate.",
onError: (err) => {
const message = err instanceof Error ? err.message : "Failed to authenticate.";
setState({
loading: false,
errorMessage: message,
});
},
});
}
})();
}, [searchParams, navigateTo]); }, [searchParams, navigateTo]);
return ( return (
......
import { useEffect } from "react"; import { useEffect } from "react";
import { useSearchParams } from "react-router-dom"; import { useSearchParams } from "react-router-dom";
import { MemoRenderContext } from "@/components/MasonryView"; import { MemoRenderContext } from "@/components/MasonryView";
import MemoView from "@/components/MemoView"; import MemoView from "@/components/MemoView";
...@@ -12,12 +12,10 @@ import { Memo } from "@/types/proto/api/v1/memo_service_pb"; ...@@ -12,12 +12,10 @@ import { Memo } from "@/types/proto/api/v1/memo_service_pb";
const Home = () => { const Home = () => {
const user = useCurrentUser(); const user = useCurrentUser();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const { addFilter, getFiltersByFactor } = useMemoFilterContext(); const { addFilter } = useMemoFilterContext();
// Auto-set today's date filter if no filter is present // Auto-set today's date filter if no filter is present
useEffect(() => { useEffect(() => {
if (!user) return;
// Check if there's already a filter in URL // Check if there's already a filter in URL
const hasFilterInUrl = searchParams.has("filter"); const hasFilterInUrl = searchParams.has("filter");
...@@ -33,7 +31,7 @@ const Home = () => { ...@@ -33,7 +31,7 @@ const Home = () => {
// Add displayTime filter for today // Add displayTime filter for today
addFilter({ factor: "displayTime", value: todayStr }); addFilter({ factor: "displayTime", value: todayStr });
} }
}, [user, searchParams, addFilter]); }, [searchParams, addFilter]);
// Build filter using unified hook // Build filter using unified hook
const memoFilter = useMemoFilters({ const memoFilter = useMemoFilters({
...@@ -48,25 +46,14 @@ const Home = () => { ...@@ -48,25 +46,14 @@ const Home = () => {
state: State.NORMAL, state: State.NORMAL,
}); });
if (!user) {
return (
<div className="w-full min-h-full flex flex-col justify-start items-center p-4">
<div className="w-full max-w-3xl flex flex-col justify-start items-center">
<div className="w-full h-32 bg-muted/20 animate-pulse rounded-xl mb-4" />
<div className="w-full h-32 bg-muted/20 animate-pulse rounded-xl mb-4" />
<div className="w-full h-32 bg-muted/20 animate-pulse rounded-xl mb-4" />
</div>
</div>
);
}
return ( return (
<div className="w-full min-h-full bg-background text-foreground"> <div className="w-full min-h-full bg-background text-foreground">
{/* Regular Memos List - Bookmarks are now in sidebar */}
<PagedMemoList <PagedMemoList
renderer={(memo: Memo, context?: MemoRenderContext) => ( renderer={(memo: Memo, context?: MemoRenderContext) => (
<MemoView key={`${memo.name}-${memo.displayTime}`} memo={memo} showVisibility showPinned compact={context?.compact} /> <MemoView key={`${memo.name}-${memo.displayTime}`} memo={memo} showVisibility showPinned compact={context?.compact} />
)} )}
listSort={listSort} listSort={(memos) => memos.filter((m) => !m.pinned)} // Exclude pinned from regular list
orderBy={orderBy} orderBy={orderBy}
filter={memoFilter} filter={memoFilter}
/> />
...@@ -75,3 +62,4 @@ const Home = () => { ...@@ -75,3 +62,4 @@ const Home = () => {
}; };
export default Home; export default Home;
...@@ -150,7 +150,35 @@ const MemoDetail = () => { ...@@ -150,7 +150,35 @@ const MemoDetail = () => {
{comments.length === 0 ? ( {comments.length === 0 ? (
// Empty state: Show button for logged-in users or anonymous comment UI // Empty state: Show button for logged-in users or anonymous comment UI
currentUser ? ( currentUser ? (
showCreateCommentButton && ( showCommentEditor ? (
<div className="w-full flex flex-col gap-3 py-6">
<div className="flex flex-col gap-2">
<label htmlFor="comment-name-empty" className="text-sm text-muted-foreground">
{t("memo.comment.your-name") || "Your name (optional)"}
</label>
<Input
id="comment-name-empty"
placeholder={t("memo.comment.name-placeholder") || "Anonymous"}
value={anonymousDisplayName}
onChange={(e) => setAnonymousDisplayName(e.target.value)}
className="max-w-xs"
/>
</div>
<MemoEditor
cacheKey={`${memo.name}-${memo.updateTime}-comment-empty`}
placeholder={t("editor.add-your-comment-here")}
parentMemoName={memo.name}
anonymousId={anonymousId || undefined}
anonymousName={anonymousDisplayName || undefined}
autoFocus
onConfirm={handleCommentCreated}
onCancel={() => {
setShowCommentEditor(false);
setAnonymousDisplayName("");
}}
/>
</div>
) : (
<div className="w-full flex flex-row justify-center items-center py-6"> <div className="w-full flex flex-row justify-center items-center py-6">
<Button variant="ghost" onClick={handleShowCommentEditor}> <Button variant="ghost" onClick={handleShowCommentEditor}>
<span className="text-muted-foreground">{t("memo.comment.write-a-comment")}</span> <span className="text-muted-foreground">{t("memo.comment.write-a-comment")}</span>
...@@ -280,6 +308,36 @@ const MemoDetail = () => { ...@@ -280,6 +308,36 @@ const MemoDetail = () => {
</div> </div>
)} )}
</div> </div>
) : showCommentEditor ? (
<div className="w-full mt-4 pt-4 border-t border-border">
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-2">
<label htmlFor="comment-name" className="text-sm text-muted-foreground">
{t("memo.comment.your-name") || "Your name (optional)"}
</label>
<Input
id="comment-name"
placeholder={t("memo.comment.name-placeholder") || "Anonymous"}
value={anonymousDisplayName}
onChange={(e) => setAnonymousDisplayName(e.target.value)}
className="max-w-xs"
/>
</div>
<MemoEditor
cacheKey={`${memo.name}-${memo.updateTime}-comment`}
placeholder={t("editor.add-your-comment-here")}
parentMemoName={memo.name}
anonymousId={anonymousId || undefined}
anonymousName={anonymousDisplayName || undefined}
autoFocus
onConfirm={handleCommentCreated}
onCancel={() => {
setShowCommentEditor(false);
setAnonymousDisplayName("");
}}
/>
</div>
</div>
) : showCreateCommentButton ? ( ) : showCreateCommentButton ? (
<div className="w-full mt-4 pt-4 border-t border-border"> <div className="w-full mt-4 pt-4 border-t border-border">
<div className="flex flex-row justify-center items-center py-2"> <div className="flex flex-row justify-center items-center py-2">
......
import { create } from "@bufbuild/protobuf"; // Legacy username/password signup page has been removed.
import { timestampDate } from "@bufbuild/protobuf/wkt"; // New user registration is handled by Clerk `<SignUp />` on `/auth?mode=signup`.
import { LoaderIcon } from "lucide-react";
import { useState } from "react";
import { toast } from "react-hot-toast";
import { Link } from "react-router-dom";
import { setAccessToken } from "@/auth-state";
import AuthFooter from "@/components/AuthFooter";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { authServiceClient, userServiceClient } from "@/service";
import { useInstance } from "@/contexts/InstanceContext";
import useLoading from "@/hooks/useLoading";
import { handleError } from "@/lib/error";
import { User_Role, UserSchema } from "@/types/proto/api/v1/user_service_pb";
import { useTranslate } from "@/utils/i18n";
const SignUp = () => { const SignUp = () => null;
const t = useTranslate();
const actionBtnLoadingState = useLoading(false);
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const { generalSetting: instanceGeneralSetting, profile } = useInstance();
const handleUsernameInputChanged = (e: React.ChangeEvent<HTMLInputElement>) => {
const text = e.target.value as string;
setUsername(text);
};
const handlePasswordInputChanged = (e: React.ChangeEvent<HTMLInputElement>) => {
const text = e.target.value as string;
setPassword(text);
};
const handleFormSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
handleSignUpButtonClick();
};
const handleSignUpButtonClick = async () => {
if (username === "" || password === "") {
return;
}
if (actionBtnLoadingState.isLoading) {
return;
}
try {
actionBtnLoadingState.setLoading();
const user = create(UserSchema, {
username,
password,
role: User_Role.USER,
});
await userServiceClient.createUser({ user });
const response = await authServiceClient.signIn({
credentials: {
case: "passwordCredentials",
value: { username, password },
},
});
// Store access token from login response
if (response.accessToken) {
setAccessToken(response.accessToken, response.accessTokenExpiresAt ? timestampDate(response.accessTokenExpiresAt) : undefined);
}
window.location.href = "/";
} catch (error: unknown) {
handleError(error, toast.error, {
fallbackMessage: "Sign up failed",
});
}
actionBtnLoadingState.setFinish();
};
return (
<div className="py-4 sm:py-8 w-80 max-w-full min-h-svh mx-auto flex flex-col justify-start items-center">
<div className="w-full py-4 grow flex flex-col justify-center items-center">
<div className="w-full flex flex-row justify-center items-center mb-6">
<img className="h-14 w-auto rounded-full shadow" src={instanceGeneralSetting.customProfile?.logoUrl || "/unnamed.jpg"} alt="" />
<p className="ml-2 text-5xl text-foreground opacity-80">{instanceGeneralSetting.customProfile?.title || "Memos"}</p>
</div>
{!instanceGeneralSetting.disallowUserRegistration ? (
<>
<p className="w-full text-2xl mt-2 text-muted-foreground">{t("auth.create-your-account")}</p>
<form className="w-full mt-2" onSubmit={handleFormSubmit}>
<div className="flex flex-col justify-start items-start w-full gap-4">
<div className="w-full flex flex-col justify-start items-start">
<span className="leading-8 text-muted-foreground">{t("common.username")}</span>
<Input
className="w-full bg-background h-10"
type="text"
readOnly={actionBtnLoadingState.isLoading}
placeholder={t("common.username")}
value={username}
autoComplete="username"
autoCapitalize="off"
spellCheck={false}
onChange={handleUsernameInputChanged}
required
/>
</div>
<div className="w-full flex flex-col justify-start items-start">
<span className="leading-8 text-muted-foreground">{t("common.password")}</span>
<Input
className="w-full bg-background h-10"
type="password"
readOnly={actionBtnLoadingState.isLoading}
placeholder={t("common.password")}
value={password}
autoComplete="new-password"
autoCapitalize="off"
spellCheck={false}
onChange={handlePasswordInputChanged}
required
/>
</div>
</div>
<div className="flex flex-row justify-end items-center w-full mt-6">
<Button type="submit" className="w-full h-10" disabled={actionBtnLoadingState.isLoading} onClick={handleSignUpButtonClick}>
{t("common.sign-up")}
{actionBtnLoadingState.isLoading && <LoaderIcon className="w-5 h-auto ml-2 animate-spin opacity-60" />}
</Button>
</div>
</form>
</>
) : (
<p className="w-full text-2xl mt-2 text-muted-foreground">Sign up is not allowed.</p>
)}
{!profile.owner ? (
<p className="w-full mt-4 text-sm font-medium text-muted-foreground">{t("auth.host-tip")}</p>
) : (
<p className="w-full mt-4 text-sm">
<span className="text-muted-foreground">{t("auth.sign-in-tip")}</span>
<Link to="/auth" className="cursor-pointer ml-2 text-primary hover:underline" viewTransition>
{t("common.sign-in")}
</Link>
</p>
)}
</div>
<AuthFooter />
</div>
);
};
export default SignUp; export default SignUp;
import { getAccessToken } from "@/auth-state";
import { redirectOnAuthFailure } from "@/utils/auth-redirect"; import { redirectOnAuthFailure } from "@/utils/auth-redirect";
import { getClerkSessionToken } from "@/utils/clerk"; import { getClerkSessionToken } from "@/utils/clerk";
import type { RequestOptions } from "./types"; import type { RequestOptions } from "./types";
...@@ -31,9 +30,8 @@ const parseBody = async (response: Response): Promise<unknown> => { ...@@ -31,9 +30,8 @@ const parseBody = async (response: Response): Promise<unknown> => {
}; };
export const fetchJson = async <T>(path: string, options: RequestOptions = {}): Promise<T> => { export const fetchJson = async <T>(path: string, options: RequestOptions = {}): Promise<T> => {
// Prefer Clerk token if available; fallback to legacy token store. // Use Clerk session token for all authenticated requests.
const clerkToken = await getClerkSessionToken(); const token = await getClerkSessionToken();
const token = clerkToken || getAccessToken();
const headers = new Headers(options.headers); const headers = new Headers(options.headers);
if (token) { if (token) {
headers.set("Authorization", `Bearer ${token}`); headers.set("Authorization", `Bearer ${token}`);
......
import type { GetCurrentUserResponse, RefreshTokenResponse, SignInResponse } from "@/types/proto/api/v1/auth_service_pb"; import type { GetCurrentUserResponse } from "@/types/proto/api/v1/auth_service_pb";
import { fetchJson } from "./apiClient"; import { fetchJson } from "./apiClient";
// Clerk is the source of truth for authentication.
// This client is now only used to fetch the "current user" view
// backed by Clerk JWT on the backend.
export const authServiceClient = { export const authServiceClient = {
async getCurrentUser(_request?: unknown): Promise<GetCurrentUserResponse> { async getCurrentUser(_request?: unknown): Promise<GetCurrentUserResponse> {
void _request; void _request;
const data = await fetchJson<GetCurrentUserResponse>("/auth/me", { method: "GET" }); const data = await fetchJson<GetCurrentUserResponse>("/auth/me", { method: "GET" });
return data; return data;
}, },
async signIn(request: unknown): Promise<SignInResponse> {
const body = typeof request === "object" && request ? request : {};
const data = await fetchJson<{ accessToken?: string }>("/auth/signin", { method: "POST", body });
return {
accessToken: data.accessToken || "",
};
},
async signOut(_request?: unknown): Promise<Record<string, never>> {
void _request;
return {};
},
async refreshToken(_request?: unknown): Promise<RefreshTokenResponse> {
void _request;
return { accessToken: "" };
},
}; };
...@@ -100,20 +100,29 @@ export const memoFromApi = (raw: ApiMemo): Memo => { ...@@ -100,20 +100,29 @@ export const memoFromApi = (raw: ApiMemo): Memo => {
export const userFromApi = (raw: ApiUser): User => { export const userFromApi = (raw: ApiUser): User => {
const email = raw.email || `user${raw.id}@example.com`; const email = raw.email || `user${raw.id}@example.com`;
const username = email.includes("@") ? email.split("@")[0] : `user${raw.id}`; // Prefer raw.username from API, fallback to email prefix
const username = raw.username || (email.includes("@") ? email.split("@")[0] : `user${raw.id}`);
// Prefer raw.displayName from API, fallback to raw.nickname for backwards compat
const displayName = raw.displayName || raw.nickname || "";
// Prefer raw.avatarUrl from API
const avatarUrl = raw.avatarUrl || "";
// Prefer raw.name from API (resource name format)
const name = raw.name || `users/${raw.id}`;
return { return {
name: `users/${raw.id}`, name,
role: User_Role.USER, role: User_Role.USER,
username, username,
email, email,
displayName: raw.nickname || "", displayName,
avatarUrl: "", avatarUrl,
description: "", description: "",
password: "", password: "",
state: State.NORMAL, state: State.NORMAL,
}; } as User;
}; };
export const attachmentFromApi = (raw: ApiAttachment): Attachment => { export const attachmentFromApi = (raw: ApiAttachment): Attachment => {
return { return {
name: `attachments/${raw.id}`, name: `attachments/${raw.id}`,
......
...@@ -46,8 +46,14 @@ export type ApiUser = { ...@@ -46,8 +46,14 @@ export type ApiUser = {
id: string; id: string;
email?: string | null; email?: string | null;
nickname?: string | null; nickname?: string | null;
// New fields from backend for proper user display
username?: string | null;
displayName?: string | null;
avatarUrl?: string | null;
name?: string | null; // Resource name: users/{id}
}; };
export type RequestOptions = { export type RequestOptions = {
method?: string; method?: string;
body?: unknown; body?: unknown;
......
...@@ -79,19 +79,29 @@ export const userServiceClient = { ...@@ -79,19 +79,29 @@ export const userServiceClient = {
return; return;
}, },
async getUserStats(request: { name: string }): Promise<UserStats> { async getUserStats(request: { name: string }): Promise<UserStats> {
const userId = parseResourceId(request.name);
const data = await fetchJson<{ tagCount: Record<string, number>; memoDisplayTimestamps: string[] }>(
`/users/${userId}/stats`,
{ method: "GET" },
);
// Convert ISO timestamps to protobuf Timestamp format
const timestamps = (data.memoDisplayTimestamps || []).map((ts) => {
const date = new Date(ts);
return { seconds: BigInt(Math.floor(date.getTime() / 1000)), nanos: 0 };
});
return { return {
name: request.name, name: request.name,
memoDisplayTimestamps: [], memoDisplayTimestamps: timestamps,
memoTypeStats: { memoTypeStats: {
linkCount: 0, linkCount: 0,
codeCount: 0, codeCount: 0,
todoCount: 0, todoCount: 0,
undoCount: 0, undoCount: 0,
}, },
tagCount: {}, tagCount: data.tagCount || {},
pinnedMemos: [], pinnedMemos: [],
totalMemoCount: 0, totalMemoCount: 0,
}; } as unknown as UserStats;
}, },
async listAllUserStats(_request?: unknown): Promise<{ stats: UserStats[] }> { async listAllUserStats(_request?: unknown): Promise<{ stats: UserStats[] }> {
void _request; void _request;
......
...@@ -8,28 +8,47 @@ export const isSuperUser = (user: User | undefined) => { ...@@ -8,28 +8,47 @@ export const isSuperUser = (user: User | undefined) => {
* Formats an anonymous creator_id into a display-friendly label. * Formats an anonymous creator_id into a display-friendly label.
* Handles patterns like: * Handles patterns like:
* - "anonymous_abc123" -> "Anonymous #abc123" * - "anonymous_abc123" -> "Anonymous #abc123"
* - "anonymous_abc123_John" -> "Anonymous (John)" * - "anonymous_abc123_VGVzdA" -> "Anonymous (Test)" - base64 encoded name
* - "anonymous_abc123_John_Doe" -> "Anonymous (John_Doe)" * - Legacy: "anonymous_abc123_John" -> "Anonymous (John)" - plain text name
*/ */
export function formatAnonymousName(creatorId: string): string { export function formatAnonymousName(creatorId: string): string {
if (!creatorId.startsWith("anonymous_")) { if (!creatorId.startsWith("anonymous_")) {
return creatorId; // Not an anonymous ID, return as-is return creatorId; // Not an anonymous ID, return as-is
} }
// Extract parts: anonymous_<sessionId>_<optionalName> // Extract parts: anonymous_<sessionId>_<optionalEncodedName>
const parts = creatorId.split("_"); const parts = creatorId.split("_");
if (parts.length < 2) { if (parts.length < 2) {
return "Anonymous"; return "Anonymous";
} }
const sessionId = parts[1]; // The random ID part const sessionId = parts[1]; // The random ID part
const displayName = parts.slice(2).join("_"); // Everything after sessionId const encodedName = parts.slice(2).join("_"); // Everything after sessionId
if (encodedName) {
// Try to decode base64-encoded name (new format for Unicode support)
try {
// URL-safe base64 decode: replace - with +, _ with /
const base64 = encodedName.replace(/-/g, '+').replace(/_/g, '/');
// Pad if needed
const padded = base64 + '='.repeat((4 - base64.length % 4) % 4);
const decoded = atob(padded);
// Convert from UTF-8 bytes to string
const bytes = Uint8Array.from(decoded, c => c.charCodeAt(0));
const displayName = new TextDecoder('utf-8').decode(bytes);
if (displayName && displayName.trim()) {
return `Anonymous (${displayName.trim()})`;
}
} catch {
// Fallback: treat as plain text (legacy format)
if (/^[a-zA-Z0-9_-]+$/.test(encodedName)) {
return `Anonymous (${encodedName})`;
}
}
}
if (displayName) {
return `Anonymous (${displayName})`;
} else {
// Show last 4 chars of sessionId for identification // Show last 4 chars of sessionId for identification
const shortId = sessionId.length > 4 ? sessionId.slice(-4) : sessionId; const shortId = sessionId.length > 4 ? sessionId.slice(-4) : sessionId;
return `Anonymous #${shortId}`; return `Anonymous #${shortId}`;
}
} }
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