import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api'; import type { CreateNewsfeedCommentRequest, CreateNewsfeedRequest, DeleteNewsfeedCommentRequest, DeleteNewsfeedRequest, GetNewsfeedCommentsRequest, GetNewsfeedRequest, ListNewsfeed, ListNewsfeedRequest, NewsfeedCommentsResponse, ReactToNewsfeedRequest, UpdateNewsfeedCommentRequest, UpdateNewsfeedRequest } from '../utils/types'; import type { TNewsfeedCreate, TNewsfeedReactionUpdate, TNewsfeedUpdate } from '@cio/utils/validation/newsfeed'; import { ZNewsfeedCommentCreate, ZNewsfeedCommentUpdate, ZNewsfeedCreate, ZNewsfeedReactionUpdate, ZNewsfeedUpdate } from '@cio/utils/validation/newsfeed'; import { get } from 'svelte/store'; import { isHtmlValueEmpty } from '$lib/utils/functions/toHtml'; import { mapZodErrorsToTranslations } from '$lib/utils/validation'; import { profile } from '$lib/utils/store/user'; import { snackbar } from '$features/ui/snackbar/store'; export type NewsfeedCommentsByFeedId = { items: NewsfeedCommentsResponse['items']; totalCount: number; hasMore: boolean; isLoading: boolean; cursor: string | null; }; /** * API class for newsfeed operations */ export class NewsfeedApi extends BaseApiWithErrors { feeds = $state([]); commentsByFeedId = $state>({}); repliesByParentId = $state>({}); isNewFeedModalOpen = $state(false); /** * Opens the new feed modal */ openNewFeedModal() { this.isNewFeedModalOpen = true; } /** * Closes the new feed modal */ closeNewFeedModal() { this.isNewFeedModalOpen = false; } /** * Lists newsfeed items for a course */ async list(courseId: string) { await this.execute({ requestFn: () => classroomio.course[':courseId'].newsfeed.$get({ param: { courseId }, query: {} }), logContext: 'listing newsfeed', onSuccess: (response) => { if (response.data) { this.feeds = Array.isArray(response.data) ? response.data : response.data.items || []; for (const feed of this.feeds) { this.initComments(feed.id, feed.commentCount || 0); } } }, onError: (result) => { if (typeof result === 'string') { snackbar.error('Failed to list newsfeed'); } } }); } /** * Gets a newsfeed item by ID */ async get(courseId: string, feedId: string) { await this.execute({ requestFn: () => classroomio.course[':courseId'].newsfeed[':feedId'].$get({ param: { courseId, feedId } }), logContext: 'fetching newsfeed item', onError: (result) => { if (typeof result === 'string') { snackbar.error('Failed to fetch newsfeed item'); } } }); } /** * Creates a new newsfeed item */ async create(courseId: string, fields: TNewsfeedCreate) { if (isHtmlValueEmpty(fields.content)) { this.errors = mapZodErrorsToTranslations( ZNewsfeedCreate.safeParse({ ...fields, content: '', courseId }).error!, 'newsfeed' ); return; } const result = ZNewsfeedCreate.safeParse({ ...fields, courseId }); if (!result.success) { this.errors = mapZodErrorsToTranslations(result.error, 'newsfeed'); return; } await this.execute({ requestFn: () => classroomio.course[':courseId'].newsfeed.$post({ param: { courseId }, json: result.data }), logContext: 'creating newsfeed', onSuccess: (response) => { if (response.data) { snackbar.success('Post created successfully'); const feedsArray = Array.isArray(this.feeds) ? this.feeds : []; const author = get(profile); const newFeed = { ...response.data, authorProfileId: author.id, authorFullname: author.fullname, authorUsername: author.username, authorAvatarUrl: author.avatarUrl, commentCount: 0 }; this.feeds = [...feedsArray, newFeed]; } }, onError: (result) => { if (typeof result === 'string') { snackbar.error('Failed to create post'); return; } if ('error' in result && 'field' in result && result.field) { this.errors[result.field] = result.error; snackbar.error(result.error); } else if ('error' in result) { this.errors.general = result.error; snackbar.error(result.error); } } }); } /** * Updates a newsfeed item (content and isPinned only) */ async update(courseId: string, feedId: string, fields: TNewsfeedUpdate) { if (fields.content !== undefined && isHtmlValueEmpty(fields.content)) { this.errors = mapZodErrorsToTranslations( ZNewsfeedUpdate.safeParse({ ...fields, content: '' }).error!, 'newsfeed' ); snackbar.error(this.errors.content ?? 'Failed to update post'); return; } const result = ZNewsfeedUpdate.safeParse(fields); if (!result.success) { this.errors = mapZodErrorsToTranslations(result.error, 'newsfeed'); return; } await this.execute({ requestFn: () => classroomio.course[':courseId'].newsfeed[':feedId'].$put({ param: { courseId, feedId }, json: result.data }), logContext: 'updating newsfeed', onSuccess: (response) => { if (response.data) { snackbar.success('Post updated successfully'); this.feeds = this.feeds.map((feed) => { if (feed.id !== feedId) { return feed; } return { ...feed, ...response.data, authorProfileId: feed.authorProfileId, authorFullname: feed.authorFullname, authorUsername: feed.authorUsername, authorAvatarUrl: feed.authorAvatarUrl }; }); } }, onError: (result) => { if (typeof result === 'string') { snackbar.error('Failed to update post'); return; } if ('error' in result && 'field' in result && result.field) { this.errors[result.field] = result.error; snackbar.error(result.error); } else if ('error' in result) { this.errors.general = result.error; snackbar.error(result.error); } } }); } /** * Updates a newsfeed item's reaction */ async react(courseId: string, feedId: string, reaction: TNewsfeedReactionUpdate['reaction']) { const result = ZNewsfeedReactionUpdate.safeParse({ reaction }); if (!result.success) { this.errors = mapZodErrorsToTranslations(result.error, 'newsfeed'); return; } await this.execute({ requestFn: () => classroomio.course[':courseId'].newsfeed[':feedId'].react.$put({ param: { courseId, feedId }, json: result.data }), logContext: 'updating newsfeed reaction', onSuccess: (response) => { if (response.data) { this.feeds = this.feeds.map((feed) => { if (feed.id !== feedId) { return feed; } return { ...feed, ...response.data, authorProfileId: feed.authorProfileId, authorFullname: feed.authorFullname, authorUsername: feed.authorUsername, authorAvatarUrl: feed.authorAvatarUrl }; }); } }, onError: (result) => { if (typeof result === 'string') { snackbar.error('Failed to update reaction'); return; } if ('error' in result && 'field' in result && result.field) { this.errors[result.field] = result.error; snackbar.error(result.error); } else if ('error' in result) { this.errors.general = result.error; snackbar.error(result.error); } } }); } /** * Deletes a newsfeed item */ async delete(courseId: string, feedId: string) { await this.execute({ requestFn: () => classroomio.course[':courseId'].newsfeed[':feedId'].$delete({ param: { courseId, feedId } }), logContext: 'deleting newsfeed', onSuccess: (response) => { if (response.data) { snackbar.success('Post deleted successfully'); this.feeds = this.feeds.filter((feed) => feed.id !== feedId); } }, onError: (result) => { if (typeof result === 'string') { snackbar.error('Failed to delete post'); } } }); } /** * Initialize comment state for a feed */ initComments(feedId: string, totalCount: number) { if (!this.commentsByFeedId[feedId]) { this.commentsByFeedId[feedId] = { items: [], totalCount, hasMore: totalCount > 0, isLoading: false, cursor: null }; } else { this.commentsByFeedId[feedId].totalCount = totalCount; this.commentsByFeedId[feedId].hasMore = totalCount > this.commentsByFeedId[feedId].items.length; } } /** * Fetch paginated comments for a feed */ async getComments(courseId: string, feedId: string, limit = 5) { if (!this.commentsByFeedId[feedId]) { this.initComments(feedId, 0); } this.commentsByFeedId[feedId].isLoading = true; await this.execute({ requestFn: () => classroomio.course[':courseId'].newsfeed[':feedId'].comments.$get({ param: { courseId, feedId }, query: { limit: String(limit) } }), logContext: 'fetching newsfeed comments', onSuccess: (response) => { if (response.data) { this.commentsByFeedId[feedId] = { items: response.data.items, totalCount: response.data.totalCount, hasMore: response.data.hasMore, isLoading: false, cursor: response.data.nextCursor }; } }, onError: (result) => { this.commentsByFeedId[feedId].isLoading = false; if (typeof result === 'string') { snackbar.error('Failed to fetch comments'); } } }); } /** * Fetch replies for a specific parent comment */ async getReplies(courseId: string, feedId: string, parentId: number, limit = 5) { if (!this.repliesByParentId[parentId]) { this.repliesByParentId[parentId] = { items: [], totalCount: 0, hasMore: false, isLoading: true, cursor: null }; } else { this.repliesByParentId[parentId].isLoading = true; } await this.execute({ requestFn: () => classroomio.course[':courseId'].newsfeed[':feedId'].comments.$get({ param: { courseId, feedId }, query: { parentId: String(parentId), limit: String(limit) } }), logContext: 'fetching newsfeed comment replies', onSuccess: (response) => { if (response.data) { this.repliesByParentId[parentId] = { items: response.data.items, totalCount: response.data.totalCount, hasMore: response.data.hasMore, isLoading: false, cursor: response.data.nextCursor }; } }, onError: (result) => { if (this.repliesByParentId[parentId]) { this.repliesByParentId[parentId].isLoading = false; } if (typeof result === 'string') { snackbar.error('Failed to fetch replies'); } } }); } /** * Load more replies for a specific parent comment (uses cursor) */ async loadMoreReplies(courseId: string, feedId: string, parentId: number, limit = 5) { const replyState = this.repliesByParentId[parentId]; if (!replyState || !replyState.hasMore || replyState.isLoading || !replyState.cursor) { return; } replyState.isLoading = true; await this.execute({ requestFn: () => classroomio.course[':courseId'].newsfeed[':feedId'].comments.$get({ param: { courseId, feedId }, query: { parentId: String(parentId), cursor: replyState.cursor!, limit: String(limit) } }), logContext: 'loading more newsfeed replies', onSuccess: (response) => { if (response.data) { this.repliesByParentId[parentId] = { items: [...replyState.items, ...response.data.items], totalCount: response.data.totalCount, hasMore: response.data.hasMore, isLoading: false, cursor: response.data.nextCursor }; } }, onError: (result) => { if (this.repliesByParentId[parentId]) { this.repliesByParentId[parentId].isLoading = false; } if (typeof result === 'string') { snackbar.error('Failed to load more replies'); } } }); } /** * Load more comments (uses cursor) */ async loadMoreComments(courseId: string, feedId: string, limit = 5) { const commentState = this.commentsByFeedId[feedId]; if (!commentState || !commentState.hasMore || commentState.isLoading || !commentState.cursor) { return; } commentState.isLoading = true; await this.execute({ requestFn: () => classroomio.course[':courseId'].newsfeed[':feedId'].comments.$get({ param: { courseId, feedId }, query: { cursor: commentState.cursor!, limit: String(limit) } }), logContext: 'loading more newsfeed comments', onSuccess: (response) => { if (response.data) { this.commentsByFeedId[feedId] = { items: [...commentState.items, ...response.data.items], totalCount: response.data.totalCount, hasMore: response.data.hasMore, isLoading: false, cursor: response.data.nextCursor }; } }, onError: (result) => { this.commentsByFeedId[feedId].isLoading = false; if (typeof result === 'string') { snackbar.error('Failed to load more comments'); } } }); } /** * Creates a newsfeed comment or reply */ async createComment( courseId: string, feedId: string, content: string, author: { id: string; username: string; fullname: string; avatarUrl: string }, parentId?: number ) { const result = ZNewsfeedCommentCreate.safeParse({ courseNewsfeedId: feedId, content, parentId }); if (!result.success) { this.errors = mapZodErrorsToTranslations(result.error, 'newsfeed'); return; } await this.execute({ requestFn: () => classroomio.course[':courseId'].newsfeed[':feedId'].comment.$post({ param: { courseId, feedId }, json: result.data }), logContext: 'creating newsfeed comment', onSuccess: (response) => { if (response.data) { snackbar.success('Comment added successfully'); const newComment = { ...response.data, authorProfileId: author.id, authorFullname: author.fullname, authorUsername: author.username, authorAvatarUrl: author.avatarUrl, replyCount: 0 }; if (parentId) { const repliesState = this.repliesByParentId[parentId]; if (repliesState) { this.repliesByParentId[parentId] = { ...repliesState, items: [...repliesState.items, newComment], totalCount: repliesState.totalCount + 1 }; } else { this.repliesByParentId[parentId] = { items: [newComment], totalCount: 1, hasMore: false, isLoading: false, cursor: null }; } // Increment parent reply count in local top-level items const commentState = this.commentsByFeedId[feedId]; if (commentState) { const updatedItems = commentState.items.map((item) => { if (item.id === parentId) { return { ...item, replyCount: (item.replyCount || 0) + 1 }; } return item; }); this.commentsByFeedId[feedId] = { ...commentState, items: updatedItems }; } } else { const commentState = this.commentsByFeedId[feedId]; if (commentState) { this.commentsByFeedId[feedId] = { ...commentState, items: [newComment, ...commentState.items], totalCount: commentState.totalCount + 1 }; } } } }, onError: (result) => { if (typeof result === 'string') { snackbar.error('Failed to add comment'); } } }); } /** * Updates a newsfeed comment */ async updateComment(courseId: string, feedId: string, commentId: string, content: string, parentId?: number) { const result = ZNewsfeedCommentUpdate.safeParse({ content }); if (!result.success) { this.errors = mapZodErrorsToTranslations(result.error, 'newsfeed'); return; } await this.execute({ requestFn: () => classroomio.course[':courseId'].newsfeed.comment[':commentId'].$put({ param: { courseId, commentId }, json: result.data }), logContext: 'updating newsfeed comment', onSuccess: (response) => { if (response.data) { snackbar.success('Comment updated successfully'); const numericId = Number(commentId); if (parentId && this.repliesByParentId[parentId]) { const replyState = this.repliesByParentId[parentId]; const updatedItems = replyState.items.map((item) => { if (item.id === numericId) { return { ...item, content: response.data?.content ?? content }; } return item; }); this.repliesByParentId[parentId] = { ...replyState, items: updatedItems }; } else { const commentState = this.commentsByFeedId[feedId]; if (commentState) { const updatedItems = commentState.items.map((item) => { if (item.id === numericId) { return { ...item, content: response.data?.content ?? content }; } return item; }); this.commentsByFeedId[feedId] = { ...commentState, items: updatedItems }; } } } }, onError: (result) => { if (typeof result === 'string') { snackbar.error('Failed to update comment'); } } }); } /** * Deletes a newsfeed comment */ async deleteComment(courseId: string, feedId: string, commentId: string, parentId?: number) { await this.execute({ requestFn: () => classroomio.course[':courseId'].newsfeed.comment[':commentId'].$delete({ param: { courseId, commentId } }), logContext: 'deleting newsfeed comment', onSuccess: (response) => { if (response.data) { snackbar.success('Comment deleted successfully'); const numericId = Number(commentId); if (parentId && this.repliesByParentId[parentId]) { const replyState = this.repliesByParentId[parentId]; this.repliesByParentId[parentId] = { ...replyState, items: replyState.items.filter((item) => String(item.id) !== commentId), totalCount: Math.max(0, replyState.totalCount - 1) }; const commentState = this.commentsByFeedId[feedId]; if (commentState) { const updatedItems = commentState.items.map((item) => { if (item.id === parentId) { return { ...item, replyCount: Math.max(0, (item.replyCount || 1) - 1) }; } return item; }); this.commentsByFeedId[feedId] = { ...commentState, items: updatedItems }; } } else { if (this.repliesByParentId[numericId]) { delete this.repliesByParentId[numericId]; } const commentState = this.commentsByFeedId[feedId]; if (commentState) { this.commentsByFeedId[feedId] = { ...commentState, items: commentState.items.filter((item) => String(item.id) !== commentId), totalCount: Math.max(0, commentState.totalCount - 1) }; } } } }, onError: (result) => { if (typeof result === 'string') { snackbar.error('Failed to delete comment'); } } }); } } export const newsfeedApi = /* @__PURE__ */ new NewsfeedApi();