refactor: remove read tracking from mentions (prep for notification center)

This commit is contained in:
CREDO23 2026-01-16 19:15:10 +02:00
parent 25eb240539
commit 80e19a52cb
11 changed files with 7 additions and 227 deletions

View file

@ -37,10 +37,5 @@ def upgrade() -> None:
def downgrade() -> None: def downgrade() -> None:
"""Remove author_id column from new_chat_messages table.""" """Remove author_id column from new_chat_messages table."""
op.execute( op.execute("DROP INDEX IF EXISTS ix_new_chat_messages_author_id")
""" op.execute("ALTER TABLE new_chat_messages DROP COLUMN IF EXISTS author_id")
DROP INDEX IF EXISTS ix_new_chat_messages_author_id;
ALTER TABLE new_chat_messages
DROP COLUMN IF EXISTS author_id;
"""
)

View file

@ -22,7 +22,6 @@ def upgrade() -> None:
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
comment_id INTEGER NOT NULL REFERENCES chat_comments(id) ON DELETE CASCADE, comment_id INTEGER NOT NULL REFERENCES chat_comments(id) ON DELETE CASCADE,
mentioned_user_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, mentioned_user_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
read BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (comment_id, mentioned_user_id) UNIQUE (comment_id, mentioned_user_id)
) )
@ -31,9 +30,6 @@ def upgrade() -> None:
op.execute( op.execute(
"CREATE INDEX IF NOT EXISTS idx_chat_comment_mentions_comment_id ON chat_comment_mentions(comment_id)" "CREATE INDEX IF NOT EXISTS idx_chat_comment_mentions_comment_id ON chat_comment_mentions(comment_id)"
) )
op.execute(
"CREATE INDEX IF NOT EXISTS idx_chat_comment_mentions_user_unread ON chat_comment_mentions(mentioned_user_id) WHERE read = FALSE"
)
def downgrade() -> None: def downgrade() -> None:

View file

@ -513,7 +513,6 @@ class ChatCommentMention(BaseModel, TimestampMixin):
nullable=False, nullable=False,
index=True, index=True,
) )
read = Column(Boolean, nullable=False, default=False)
# Relationships # Relationships
comment = relationship("ChatComment", back_populates="mentions") comment = relationship("ChatComment", back_populates="mentions")

View file

@ -20,8 +20,6 @@ from app.services.chat_comments_service import (
delete_comment, delete_comment,
get_comments_for_message, get_comments_for_message,
get_user_mentions, get_user_mentions,
mark_all_mentions_as_read,
mark_mention_as_read,
update_comment, update_comment,
) )
from app.users import current_active_user from app.users import current_active_user
@ -90,28 +88,8 @@ async def remove_comment(
@router.get("/mentions", response_model=MentionListResponse) @router.get("/mentions", response_model=MentionListResponse)
async def list_mentions( async def list_mentions(
search_space_id: int | None = None, search_space_id: int | None = None,
unread_only: bool = False,
session: AsyncSession = Depends(get_async_session), session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user), user: User = Depends(current_active_user),
): ):
"""List mentions for the current user.""" """List mentions for the current user."""
return await get_user_mentions(session, user, search_space_id, unread_only) return await get_user_mentions(session, user, search_space_id)
@router.put("/mentions/{mention_id}/read")
async def read_mention(
mention_id: int,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
"""Mark a specific mention as read."""
return await mark_mention_as_read(session, mention_id, user)
@router.put("/mentions/read-all")
async def read_all_mentions(
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
"""Mark all mentions as read for the current user."""
return await mark_all_mentions_as_read(session, user)

View file

@ -115,7 +115,6 @@ class MentionResponse(BaseModel):
"""Schema for a mention notification.""" """Schema for a mention notification."""
id: int id: int
read: bool
created_at: datetime created_at: datetime
comment: MentionCommentResponse comment: MentionCommentResponse
context: MentionContextResponse context: MentionContextResponse
@ -127,4 +126,4 @@ class MentionListResponse(BaseModel):
"""Response for listing user's mentions.""" """Response for listing user's mentions."""
mentions: list[MentionResponse] mentions: list[MentionResponse]
unread_count: int total_count: int

View file

@ -569,7 +569,6 @@ async def get_user_mentions(
session: AsyncSession, session: AsyncSession,
user: User, user: User,
search_space_id: int | None = None, search_space_id: int | None = None,
unread_only: bool = False,
) -> MentionListResponse: ) -> MentionListResponse:
""" """
Get mentions for the current user, optionally filtered by search space. Get mentions for the current user, optionally filtered by search space.
@ -578,10 +577,9 @@ async def get_user_mentions(
session: Database session session: Database session
user: The current authenticated user user: The current authenticated user
search_space_id: Optional search space ID to filter mentions search_space_id: Optional search space ID to filter mentions
unread_only: If True, only return unread mentions
Returns: Returns:
MentionListResponse with mentions and unread count MentionListResponse with mentions and total count
""" """
# Build query with joins for filtering by search_space_id # Build query with joins for filtering by search_space_id
query = ( query = (
@ -599,9 +597,6 @@ async def get_user_mentions(
if search_space_id is not None: if search_space_id is not None:
query = query.filter(NewChatThread.search_space_id == search_space_id) query = query.filter(NewChatThread.search_space_id == search_space_id)
if unread_only:
query = query.filter(ChatCommentMention.read.is_(False))
result = await session.execute(query) result = await session.execute(query)
mention_records = result.scalars().all() mention_records = result.scalars().all()
@ -617,9 +612,6 @@ async def get_user_mentions(
else: else:
threads_map = {} threads_map = {}
# Count unread from fetched data
unread_count = sum(1 for m in mention_records if not m.read)
mentions = [] mentions = []
for mention in mention_records: for mention in mention_records:
comment = mention.comment comment = mention.comment
@ -645,7 +637,6 @@ async def get_user_mentions(
mentions.append( mentions.append(
MentionResponse( MentionResponse(
id=mention.id, id=mention.id,
read=mention.read,
created_at=mention.created_at, created_at=mention.created_at,
comment=MentionCommentResponse( comment=MentionCommentResponse(
id=comment.id, id=comment.id,
@ -665,75 +656,5 @@ async def get_user_mentions(
return MentionListResponse( return MentionListResponse(
mentions=mentions, mentions=mentions,
unread_count=unread_count, total_count=len(mentions),
) )
async def mark_mention_as_read(
session: AsyncSession,
mention_id: int,
user: User,
) -> dict:
"""
Mark a specific mention as read.
Args:
session: Database session
mention_id: ID of the mention to mark as read
user: The current authenticated user
Returns:
Dict with mention_id and read status
Raises:
HTTPException: If mention not found or doesn't belong to user
"""
result = await session.execute(
select(ChatCommentMention).filter(ChatCommentMention.id == mention_id)
)
mention = result.scalars().first()
if not mention:
raise HTTPException(status_code=404, detail="Mention not found")
if mention.mentioned_user_id != user.id:
raise HTTPException(
status_code=403,
detail="You can only mark your own mentions as read",
)
mention.read = True
await session.commit()
return {"mention_id": mention_id, "read": True}
async def mark_all_mentions_as_read(
session: AsyncSession,
user: User,
) -> dict:
"""
Mark all mentions for the current user as read.
Args:
session: Database session
user: The current authenticated user
Returns:
Dict with count of mentions marked as read
"""
from sqlalchemy import update
result = await session.execute(
update(ChatCommentMention)
.where(
ChatCommentMention.mentioned_user_id == user.id,
ChatCommentMention.read.is_(False),
)
.values(read=True)
.returning(ChatCommentMention.id)
)
marked_ids = result.scalars().all()
await session.commit()
return {"message": "All mentions marked as read", "count": len(marked_ids)}

View file

@ -4,7 +4,6 @@ import type {
CreateCommentRequest, CreateCommentRequest,
CreateReplyRequest, CreateReplyRequest,
DeleteCommentRequest, DeleteCommentRequest,
MarkMentionReadRequest,
UpdateCommentRequest, UpdateCommentRequest,
} from "@/contracts/types/chat-comments.types"; } from "@/contracts/types/chat-comments.types";
import { chatCommentsApiService } from "@/lib/apis/chat-comments-api.service"; import { chatCommentsApiService } from "@/lib/apis/chat-comments-api.service";
@ -71,39 +70,3 @@ export const deleteCommentMutationAtom = atomWithMutation(() => ({
toast.error("Failed to delete comment"); toast.error("Failed to delete comment");
}, },
})); }));
export const markMentionReadMutationAtom = atomWithMutation(() => ({
mutationFn: async (request: MarkMentionReadRequest & { search_space_id?: number }) => {
return chatCommentsApiService.markMentionRead(request);
},
onSuccess: (_, variables) => {
queryClient.invalidateQueries({
queryKey: cacheKeys.mentions.all(variables.search_space_id),
});
queryClient.invalidateQueries({
queryKey: cacheKeys.mentions.unreadOnly(variables.search_space_id),
});
},
onError: (error: Error) => {
console.error("Error marking mention as read:", error);
},
}));
export const markAllMentionsReadMutationAtom = atomWithMutation(() => ({
mutationFn: async (request: { search_space_id?: number }) => {
return chatCommentsApiService.markAllMentionsRead();
},
onSuccess: (_, variables) => {
queryClient.invalidateQueries({
queryKey: cacheKeys.mentions.all(variables.search_space_id),
});
queryClient.invalidateQueries({
queryKey: cacheKeys.mentions.unreadOnly(variables.search_space_id),
});
toast.success("All mentions marked as read");
},
onError: (error: Error) => {
console.error("Error marking all mentions as read:", error);
toast.error("Failed to mark mentions as read");
},
}));

View file

@ -15,17 +15,3 @@ export const mentionsAtom = atomWithQuery((get) => {
}, },
}; };
}); });
export const unreadMentionsAtom = atomWithQuery((get) => {
const searchSpaceId = get(activeSearchSpaceIdAtom);
return {
queryKey: cacheKeys.mentions.unreadOnly(searchSpaceId ? Number(searchSpaceId) : undefined),
queryFn: async () => {
return chatCommentsApiService.getMentions({
search_space_id: searchSpaceId ? Number(searchSpaceId) : undefined,
unread_only: true,
});
},
};
});

View file

@ -51,7 +51,6 @@ export const mentionComment = z.object({
export const mention = z.object({ export const mention = z.object({
id: z.number(), id: z.number(),
read: z.boolean(),
created_at: z.string(), created_at: z.string(),
comment: mentionComment, comment: mentionComment,
context: mentionContext, context: mentionContext,
@ -116,32 +115,11 @@ export const deleteCommentResponse = z.object({
*/ */
export const getMentionsRequest = z.object({ export const getMentionsRequest = z.object({
search_space_id: z.number().optional(), search_space_id: z.number().optional(),
unread_only: z.boolean().optional(),
}); });
export const getMentionsResponse = z.object({ export const getMentionsResponse = z.object({
mentions: z.array(mention), mentions: z.array(mention),
unread_count: z.number(), total_count: z.number(),
});
/**
* Mark mention as read
*/
export const markMentionReadRequest = z.object({
mention_id: z.number(),
});
export const markMentionReadResponse = z.object({
mention_id: z.number(),
read: z.boolean(),
});
/**
* Mark all mentions as read
*/
export const markAllMentionsReadResponse = z.object({
message: z.string(),
count: z.number(),
}); });
export type Author = z.infer<typeof author>; export type Author = z.infer<typeof author>;
@ -162,6 +140,3 @@ export type DeleteCommentRequest = z.infer<typeof deleteCommentRequest>;
export type DeleteCommentResponse = z.infer<typeof deleteCommentResponse>; export type DeleteCommentResponse = z.infer<typeof deleteCommentResponse>;
export type GetMentionsRequest = z.infer<typeof getMentionsRequest>; export type GetMentionsRequest = z.infer<typeof getMentionsRequest>;
export type GetMentionsResponse = z.infer<typeof getMentionsResponse>; export type GetMentionsResponse = z.infer<typeof getMentionsResponse>;
export type MarkMentionReadRequest = z.infer<typeof markMentionReadRequest>;
export type MarkMentionReadResponse = z.infer<typeof markMentionReadResponse>;
export type MarkAllMentionsReadResponse = z.infer<typeof markAllMentionsReadResponse>;

View file

@ -14,10 +14,6 @@ import {
getCommentsResponse, getCommentsResponse,
getMentionsRequest, getMentionsRequest,
getMentionsResponse, getMentionsResponse,
type MarkMentionReadRequest,
markAllMentionsReadResponse,
markMentionReadRequest,
markMentionReadResponse,
type UpdateCommentRequest, type UpdateCommentRequest,
updateCommentRequest, updateCommentRequest,
updateCommentResponse, updateCommentResponse,
@ -127,39 +123,12 @@ class ChatCommentsApiService {
if (parsed.data.search_space_id !== undefined) { if (parsed.data.search_space_id !== undefined) {
params.set("search_space_id", String(parsed.data.search_space_id)); params.set("search_space_id", String(parsed.data.search_space_id));
} }
if (parsed.data.unread_only !== undefined) {
params.set("unread_only", String(parsed.data.unread_only));
}
const queryString = params.toString(); const queryString = params.toString();
const url = queryString ? `/api/v1/mentions?${queryString}` : "/api/v1/mentions"; const url = queryString ? `/api/v1/mentions?${queryString}` : "/api/v1/mentions";
return baseApiService.get(url, getMentionsResponse); return baseApiService.get(url, getMentionsResponse);
}; };
/**
* Mark a mention as read
*/
markMentionRead = async (request: MarkMentionReadRequest) => {
const parsed = markMentionReadRequest.safeParse(request);
if (!parsed.success) {
const errorMessage = parsed.error.issues.map((issue) => issue.message).join(", ");
throw new ValidationError(`Invalid request: ${errorMessage}`);
}
return baseApiService.put(
`/api/v1/mentions/${parsed.data.mention_id}/read`,
markMentionReadResponse
);
};
/**
* Mark all mentions as read
*/
markAllMentionsRead = async () => {
return baseApiService.put("/api/v1/mentions/read-all", markAllMentionsReadResponse);
};
} }
export const chatCommentsApiService = new ChatCommentsApiService(); export const chatCommentsApiService = new ChatCommentsApiService();

View file

@ -77,6 +77,5 @@ export const cacheKeys = {
}, },
mentions: { mentions: {
all: (searchSpaceId?: number) => ["mentions", searchSpaceId] as const, all: (searchSpaceId?: number) => ["mentions", searchSpaceId] as const,
unreadOnly: (searchSpaceId?: number) => ["mentions", "unread", searchSpaceId] as const,
}, },
}; };