Conversation
|
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
Code Review: PR #59 — Message Rating FeaturePR: #59 OverviewAdds like/dislike rating on AI assistant messages across all 3 DB backends (PostgreSQL, SQLite, MongoDB). Includes admin dashboard with statistics/export, ArchitectureRating flow: Admin flow: The architecture follows project conventions (model → repo → service → schema → route) — good. Issues by SeverityCritical1. # Before: raised AuthenticationError
# After: returns None
async def get_current_user_ws(...) -> User | None:Any existing WS endpoint using this dependency now silently receives Suggestion: Either keep the exception (handle it with try/except in 2. {%- else %}
# Admin ratings router - JWT not enabled
router = None # type: ignore
{%- endif %}Meanwhile in {%- if cookiecutter.use_jwt %}
from app.api.routes.v1 import admin_ratings, auth, users
...
v1_router.include_router(admin_ratings.router, ...)
{%- endif %}This is technically safe because the import is guarded by High3. Rate endpoint — awkward @router.post(
"/{conversation_id}/messages/{message_id}/rate",
status_code=status.HTTP_201_CREATED,
)
async def rate_message(...) -> Any:
rating, is_new = await rating_service.rate_message(...)
if is_new:
return rating
else:
return Response(
content=rating.model_dump_json(),
status_code=status.HTTP_200_OK,
media_type="application/json",
)Problems:
Suggestion: Use PUT (idempotent upsert) with from fastapi.responses import JSONResponse
@router.put(
"/{conversation_id}/messages/{message_id}/rate",
response_model=MessageRatingRead,
)
async def rate_message(...):
rating, is_new = await rating_service.rate_message(...)
status_code = 201 if is_new else 200
return JSONResponse(
content=MessageRatingRead.model_validate(rating).model_dump(mode="json"),
status_code=status_code,
)Or even simpler — just always return 200 since the client doesn't care about the distinction: @router.put("/{conversation_id}/messages/{message_id}/rate", response_model=MessageRatingRead)
async def rate_message(...) -> MessageRatingRead:
rating, _ = await rating_service.rate_message(...)
return rating4. Missing @router.delete(
"/{conversation_id}/messages/{message_id}/rate",
status_code=status.HTTP_204_NO_CONTENT,
)
async def remove_rating(...) -> None:This will fail with newer FastAPI versions (same bug we already fixed in other endpoints in this session). Needs 5. @router.get("/export")
async def export_ratings(
format: str = Query("json", ...), # shadows builtin format()Rename to 6. Export hardcoded limit of 10,000 items, _ = await rating_service.list_ratings(
skip=0,
limit=10000, # Large limit for export
...
)For production with high traffic, 10k ratings loaded into memory at once could cause issues. Consider streaming with cursor-based pagination or at least making the limit configurable. 7. for msg in items:
msg.user_rating = user_ratings.get(msg.id) # type: ignore[attr-defined]
msg.rating_count = rating_counts.get(msg.id) # type: ignore[attr-defined]Setting arbitrary attributes on SQLAlchemy model instances is fragile. If the model uses Medium8. # pyproject.toml
dependencies = [
...
"requests>=2.33.0", # <-- not used anywhere in the diff
]Remove it or move to dev dependencies if needed for tests. 9. Duplicate CSV export code — 3x copy-paste The CSV generation logic in def _generate_csv_response(items: list[MessageRatingWithDetails]) -> StreamingResponse:
output = StringIO()
writer = csv.writer(output)
writer.writerow(["ID", "Message ID", ...])
for item in items:
writer.writerow([str(item.id), ...])
output.seek(0)
return StreamingResponse(output, media_type="text/csv", ...)10. MongoDB return MessageRating.parse_obj(result) # Pydantic v1 methodUse 11. if with_comments_only:
query = query.where(MessageRating.comment.isnot(None))This doesn't filter out empty strings ( query = query.where(MessageRating.comment.isnot(None)).where(MessageRating.comment != "")12. Rating value not constrained at DB level rating: Mapped[int] = mapped_column(Integer, nullable=False) # 1 or -1Comment says 1 or -1 but nothing prevents inserting 0, 5, or -100. Add a __table_args__ = (
UniqueConstraint("message_id", "user_id", name="uq_message_user_rating"),
CheckConstraint("rating IN (1, -1)", name="ck_rating_value"),
)13.
Either add the column to SQL models or remove the parameter from SQL repo functions. 14. Unused # SQLite model
from sqlalchemy import Boolean, ForeignKey, Integer, String, Text, UniqueConstraint
# Boolean is never used15. Switching from Low16. The validator runs on every 17. Missing newline at end of ratings_by_day: list[dict[str, Any]]
\ No newline at end of file18. "Content-Disposition": f'...ratings_export_{datetime.now().strftime(...)}.csv"'Use 19. Admin ratings page — missing pagination The 20. # MongoDB model
rating: Literal[1, -1]Good — uses Security
Positive Observations
Summary
Recommendation: Fix critical and high issues before merge. Medium items can be addressed in follow-up. |
Issues1. @router.post(
"/{conversation_id}/messages/{message_id}/rate",
response_model=MessageRatingRead,
status_code=status.HTTP_200_OK, # ← fixed to 200
)
async def rate_message(...) -> Any:
from fastapi.responses import JSONResponse # ← import inside function
rating, is_new = await rating_service.rate_message(...)
if is_new:
return rating # ← response_model applies
else:
return JSONResponse( # ← bypasses response_model
content=rating.model_dump(mode="json"),
status_code=status.HTTP_200_OK, # ← same as default!
)Both branches now return Suggested fix — simplest correct version: @router.post(
"/{conversation_id}/messages/{message_id}/rate",
response_model=MessageRatingRead,
)
async def rate_message(...) -> MessageRatingRead:
rating, _ = await rating_service.rate_message(...)
return ratingIf you must distinguish 201 vs 200 (e.g. for clients that care), use from fastapi.responses import JSONResponse # top-level import
@router.post("...", response_model=MessageRatingRead)
async def rate_message(...) -> Any:
rating, is_new = await rating_service.rate_message(...)
return JSONResponse(
content=MessageRatingRead.model_validate(rating).model_dump(mode="json"),
status_code=201 if is_new else 200,
)2. # In message_rating.py service (list_ratings / export_all_ratings):
user_name=item.user.full_name if item.user else None, # ← .full_name
# In conversation.py export (export_all):
"user_name": getattr(user, "name", None), # ← .name (with safe fallback)The User model has either The 3. Monkey-patching ORM models with # conversation.py — list_messages enrichment (PostgreSQL, SQLite)
for msg in items:
msg.user_rating = user_ratings.get(msg.id) # type: ignore[attr-defined]
msg.rating_count = rating_counts.get(msg.id) # type: ignore[attr-defined]This is still present in both PostgreSQL and SQLite variants. Setting arbitrary attributes on SQLAlchemy ORM instances works (SQLAlchemy models don't use
Better approach: Return result = []
for msg in items:
msg_schema = MessageRead.model_validate(msg)
msg_schema.user_rating = user_ratings.get(msg.id)
msg_schema.rating_count = rating_counts.get(msg.id)
result.append(msg_schema)
return result, totalOr add 4. MongoDB # list_ratings query (fixed in this PR):
query_dict["comment"] = {"$nin": [None, ""]} # ← correct, filters empty strings
# get_rating_summary pipeline (NOT fixed):
"with_comments": {
"$sum": {"$cond": [{"$ne": ["$comment", None]}, 1, 0]} # ← misses ""
},The Fix: "with_comments": {
"$sum": {"$cond": [{"$and": [
{"$ne": ["$comment", None]},
{"$ne": ["$comment", ""]}
]}, 1, 0]}
},5. # deps.py — get_current_user_ws (PostgreSQL + SQLite variants)
db.expunge(user)
return userThis correctly fixes the "instance not bound to a Session" error after the context manager exits. However, if any WS handler accesses a lazy-loaded relationship on the returned The current Consider eager-loading all needed attributes before expunge: # Ensure eager-load of commonly-accessed fields
await db.refresh(user) # loads all mapped columns
db.expunge(user)
return userOr use 6. The switch from 7. This import appears inside the function body in 8. Export chunk iteration — SQLite # admin_ratings.py SQLite export endpoint:
async def export_ratings(...) -> Any: # async def
chunks = rating_service.export_all_ratings(...) # sync generator
return _generate_export_response(list(chunks), export_format)The 9. Admin ratings frontend pagination The |
|
1. In user_name=item.user.full_name if item.user else None,And in "user_name": user.full_name if user else None,Problem: it's unclear whether the Risk: Runtime Fix: Verify the 2. Token proxy endpoint exposes access token as JSON // frontend/src/app/api/auth/token/route.ts
export async function GET(request: NextRequest) {
const accessToken = request.cookies.get("access_token")?.value;
// ... validates with backend ...
return NextResponse.json({ access_token: accessToken });
}This endpoint converts an httpOnly cookie into a JSON response readable by JavaScript. This effectively negates the purpose of the httpOnly cookie. Any XSS on the frontend can now extract the token by calling Suggestion: Instead of exposing the token:
Note: This may be a deliberate tradeoff — WebSocket API requires a token in URL/header, and httpOnly cookies are not accessible from JS. But it should be explicitly documented. 3. // types/chat.ts
export interface ChatMessage {
// ...
conversationId: string; // required, not optional
}// use-chat.ts
conversationId: effectiveConversationId, // can be "" (empty string)A new message in a new conversation doesn't have a
Minor issue, but causes unnecessary complexity in 4. // use-chat.ts — fallback when currentMessageId is null
updateMessagesWhere(
(msg) => msg.role === "assistant" && msg.id.length > 20 && !msg.id.includes("-"),
(msg) => ({ ...msg, id: message_id })
);The heuristic
Fix: Use a dedicated flag (e.g., 5. Admin conversations page loads ALL conversations at once // admin/conversations/page.tsx
const data = await apiClient.get<ConversationExport>("/v1/admin/conversations");The frontend calls Suggestion: Add a dedicated admin list endpoint with pagination (without messages), instead of reusing the export endpoint. 6. Export proxy in Next.js — // frontend/src/app/api/v1/admin/ratings/export/route.ts
const data = await backendFetch(url, { ... });
// ...
if (export_format === "csv") {
return new NextResponse(data as string, { ... });
}
7. Empty // admin/ratings/page.tsx
fetch(`/api/v1/admin/ratings?...&rating_filter=${
filter === "all" ? "" : filter === "positive" ? "1" : "-1"
}&with_comments_only=${commentsOnly}`)When Fix: Don't add the parameter to the URL when filter is "all": const params = new URLSearchParams();
if (filter !== "all") params.set("rating_filter", filter === "positive" ? "1" : "-1");8. Admin ratings page imports: import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid } from "recharts";I don't see 9. Enrichment logic duplication in Message enrichment with ratings (user_rating, rating_count) is duplicated across PostgreSQL, SQLite, and MongoDB variants of 10. // chat-container.tsx
conversationId: msg.conversation_id, // <-- where does this field come from?
11. Export via // admin/ratings/page.tsx
window.open(`/api/v1/admin/ratings/export?${params.toString()}`, "_blank");
|
R3-1
full_name: str | None = Field(default=None, max_length=255)R3-2Added R3-3Changed R3-4Replaced R3-5Added this functionality in latest commit. R3-6Added R3-7Main fetch uses R3-8Already present in R3-9Architectural decision due to Jinja templating. Will not change since it's like this by design. R3-10
R3-11Commet added in |
Critical4.1 LIKE Injection in admin conversation search (PostgreSQL/SQLite)File: if search:
query = query.where(
(Conversation.title.ilike(f"%{search}%"))
| Conversation.id.cast(String).ilike(f"{search}%")
)SQLAlchemy properly parameterizes the value (no SQL injection), but LIKE-specific wildcards
The same pattern appears in the count query below it. Fix: safe_search = search.replace("%", r"\%").replace("_", r"\_")
query = query.where(
(Conversation.title.ilike(f"%{safe_search}%"))
| Conversation.id.cast(String).ilike(f"{safe_search}%")
)Severity note: This is admin-only, so the blast radius is limited. Downgrade to High if admin users are trusted. 4.2 MongoDB regex injection in admin conversation searchFile: match_filter["$or"] = [
{"title": {"$regex": search, "$options": "i"}},
{"_id_str": {"$regex": f"^{search}", "$options": "i"}},
]Raw user input goes directly into a MongoDB
Same issue in the count pipeline below. Fix: import re
safe_search = re.escape(search)
match_filter["$or"] = [
{"title": {"$regex": safe_search, "$options": "i"}},
{"_id_str": {"$regex": f"^{safe_search}", "$options": "i"}},
]High4.3 No conversation ownership validation on rate/remove endpointsFiles: The This means User A can rate messages in User B's private conversation if they know (or guess) the conversation_id and message_id (both UUIDs, so hard to guess — but not impossible with the admin conversations API leaking IDs, or via browser history). Present in all 3 database backends. Fix: Add ownership check in service: async def _validate_conversation_ownership(
self, conversation_id: UUID, user_id: UUID
) -> None:
conv = await conversation_repo.get_conversation(self.db, conversation_id)
if not conv:
raise NotFoundError(...)
if conv.user_id != user_id:
raise NotFoundError(message="Conversation not found")Or add the check at the route level before calling the service. 4.4 Token proxy origin check is bypassableFile: const origin = request.headers.get("origin");
const host = request.headers.get("host");
if (origin && host && !origin.includes(host)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
Fix: if (origin && host) {
try {
const originUrl = new URL(origin);
if (originUrl.host !== host) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
} catch {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
}Medium4.5
|
# Conflicts:
# CHANGELOG.md
# template/{{cookiecutter.project_slug}}/frontend/src/app/[locale]/(dashboard)/admin/conversations/page.tsx
# template/{{cookiecutter.project_slug}}/frontend/src/types/conversation.ts
# uv.lock
Comments are stored raw; HTML escaping is done at render time (React auto-escapes; CSV export escapes against formula injection). The test now asserts the positive shape of sanitization (strip + control-char removal) and that html.escape is deliberately absent.
The MongoDB service layer (list_messages) passes include_tool_calls through to the repo, matching the SQL variants. Mongo messages embed tool calls in the document, so the flag is a no-op, but it needs to be in the signature to satisfy the caller (ty error).
Changes
Summary
Add a functionality of message rating in the template to further enhance its functionality.
Business Context
Strengthening open source offering.
Changes
Verification
Linked Issues
#56