* fix(newsfeed): tighten card spacing and reposition pinned icon - Move pinned icon to top-left corner with a tilted rotation - Remove the extra pin row that added excess top padding - Reduce spacing between post content, reactions, and comments - Remove card box shadow Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * fix(newsfeed): place pinned icon on card corner instead of avatar Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * fix(newsfeed): stop dropdown menus locking body scroll bits-ui defaults DropdownMenu.Content's preventScroll to true, so every open ran a body scroll lock: a forced full-document style recalc and reflow, a horizontal shift by the scrollbar width, and pointer-events: none on the body. On the newsfeed page — a long list of cards each with a scrollable comment thread — that made the comment ellipsis menu feel sluggish to open. Menus don't need scroll locking; only modal layers do. Also tighten card spacing, use the primary color for the pinned icon, give the card menu trigger a pointer cursor, and switch the comment save and submit buttons to the loading and secondary variants. * refactor(newsfeed): store reply targets as data, not markup Replies used to prepend a presentational <blockquote> — Tailwind classes, hardcoded English and a copied snippet of the parent — onto the comment body, and that whole string was persisted to course_newsfeed_comment.content. Editing a reply therefore showed raw markup in the textarea, the quoted snippet froze at write time, the label could never be localized, and two of its ui: utilities never compiled because they appeared only in dashboard code. Add a replyToCommentId column, distinct from parentId (the thread root), so a reply can name the comment it answers. The service verifies the target belongs to the same feed and the same thread; the paginated query joins it to return replyToAuthorFullname, so the label is rendered from live data by the new CommentTree.ReplyingTo component using the existing replying_to key. Rows written the old way still display their stored quote, and stripLegacyReplyQuote keeps that markup out of the edit textarea. * refactor(newsfeed): drop redundant comments on reply target plumbing * fix(newsfeed): address review feedback on pinned icon and quote stripping - The pin icon carried an unprefixed `text-muted-foreground` plus `ui:stroke-primary`, which is emitted nowhere because it appears only in dashboard code, so the outline fell back to the muted colour. Use `ui:text-primary` and let the stroke inherit currentColor. - Give the pinned state an accessible name: the icon is `aria-hidden`, and header.svelte no longer carries an indicator, so screen readers had nothing. - Filter feeds on `isPinned` alone. `is_pinned` is only the database column name; drizzle maps it to `isPinned` and the API never returns the snake_case form, so the `as any` fallback was dead and let the page and card disagree. - `ui:transition-color` is not a Tailwind utility and emitted nothing; the plural `ui:transition-colors` is what applies the transition. - Strip a legacy reply quote only when the blockquote carries the `reply-quote` class the old code always wrote, so a blockquote the author typed is kept. * docs(agents): forbid presentational markup in persisted content columns ---------
542 lines
18 KiB
TypeScript
542 lines
18 KiB
TypeScript
import * as schema from '@db/schema';
|
|
|
|
import type { TCourseNewsfeed, TCourseNewsfeedComment, TNewCourseNewsfeed, TNewCourseNewsfeedComment } from '@db/types';
|
|
import { and, db, desc, eq, isNull, lt, ne, sql } from '@db/drizzle';
|
|
import { alias } from 'drizzle-orm/pg-core';
|
|
import { ROLE } from '@cio/utils/constants';
|
|
|
|
/**
|
|
* Gets newsfeed items by course ID
|
|
* @param courseId Course ID
|
|
* @returns Array of newsfeed items with flattened author profile fields
|
|
*/
|
|
export async function getNewsfeedByCourseId(courseId: string): Promise<
|
|
(TCourseNewsfeed & {
|
|
authorProfileId: string | null;
|
|
authorFullname: string | null;
|
|
authorUsername: string | null;
|
|
authorAvatarUrl: string | null;
|
|
})[]
|
|
> {
|
|
try {
|
|
const feeds = await db
|
|
.select({
|
|
feed: schema.courseNewsfeed,
|
|
profile: schema.profile
|
|
})
|
|
.from(schema.courseNewsfeed)
|
|
.leftJoin(schema.groupmember, eq(schema.courseNewsfeed.authorId, schema.groupmember.id))
|
|
.leftJoin(schema.profile, eq(schema.groupmember.profileId, schema.profile.id))
|
|
.where(eq(schema.courseNewsfeed.courseId, courseId))
|
|
.orderBy(desc(schema.courseNewsfeed.createdAt));
|
|
|
|
return feeds.map((row) => ({
|
|
...row.feed,
|
|
authorProfileId: row.profile?.id || null,
|
|
authorFullname: row.profile?.fullname || null,
|
|
authorUsername: row.profile?.username || null,
|
|
authorAvatarUrl: row.profile?.avatarUrl || null
|
|
}));
|
|
} catch (error) {
|
|
console.error('getNewsfeedByCourseId error:', error);
|
|
throw new Error(
|
|
`Failed to get newsfeed by course ID "${courseId}": ${error instanceof Error ? error.message : 'Unknown error'}`
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Gets paginated newsfeed items by course ID
|
|
* @param courseId Course ID
|
|
* @param options Pagination options (cursor, limit)
|
|
* @returns Object with feeds array, total count, hasMore flag, and nextCursor
|
|
*/
|
|
export async function getNewsfeedByCourseIdPaginated(
|
|
courseId: string,
|
|
options: { cursor?: string; limit: number }
|
|
): Promise<{
|
|
items: (TCourseNewsfeed & {
|
|
authorProfileId: string | null;
|
|
authorFullname: string | null;
|
|
authorUsername: string | null;
|
|
authorAvatarUrl: string | null;
|
|
commentCount: number;
|
|
})[];
|
|
totalCount: number;
|
|
hasMore: boolean;
|
|
nextCursor: string | null;
|
|
}> {
|
|
try {
|
|
const { cursor, limit } = options;
|
|
|
|
// Build where conditions
|
|
const whereConditions = [eq(schema.courseNewsfeed.courseId, courseId)];
|
|
if (cursor) {
|
|
// Cursor is the last feed ID, fetch feeds created before it
|
|
whereConditions.push(lt(schema.courseNewsfeed.createdAt, cursor));
|
|
}
|
|
|
|
// Get total count
|
|
const totalCountResult = await db
|
|
.select({ count: sql<number>`count(*)`.as('count') })
|
|
.from(schema.courseNewsfeed)
|
|
.where(eq(schema.courseNewsfeed.courseId, courseId));
|
|
const totalCount = Number(totalCountResult[0]?.count || 0);
|
|
|
|
// Fetch feeds with author profile and comment count (limit + 1 to check if there are more)
|
|
const feeds = await db
|
|
.select({
|
|
feed: schema.courseNewsfeed,
|
|
profile: schema.profile,
|
|
commentCount: getCommentCount()
|
|
})
|
|
.from(schema.courseNewsfeed)
|
|
.leftJoin(schema.groupmember, eq(schema.courseNewsfeed.authorId, schema.groupmember.id))
|
|
.leftJoin(schema.profile, eq(schema.groupmember.profileId, schema.profile.id))
|
|
.where(and(...whereConditions))
|
|
.orderBy(desc(schema.courseNewsfeed.createdAt))
|
|
.limit(limit + 1);
|
|
|
|
// Check if there are more feeds
|
|
const hasMore = feeds.length > limit;
|
|
const items = feeds.slice(0, limit);
|
|
|
|
// Get next cursor (createdAt timestamp of the oldest feed in this batch)
|
|
const nextCursor = hasMore && items.length > 0 ? items[items.length - 1].feed.createdAt : null;
|
|
|
|
return {
|
|
items: items.map((row) => ({
|
|
...row.feed,
|
|
authorProfileId: row.profile?.id || null,
|
|
authorFullname: row.profile?.fullname || null,
|
|
authorUsername: row.profile?.username || null,
|
|
authorAvatarUrl: row.profile?.avatarUrl || null,
|
|
commentCount: Number(row.commentCount || 0)
|
|
})),
|
|
totalCount,
|
|
hasMore,
|
|
nextCursor
|
|
};
|
|
} catch (error) {
|
|
console.error('getNewsfeedByCourseIdPaginated error:', error);
|
|
throw new Error(
|
|
`Failed to get paginated newsfeed by course ID "${courseId}": ${error instanceof Error ? error.message : 'Unknown error'}`
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Gets a newsfeed item by ID
|
|
* @param feedId Newsfeed ID
|
|
* @returns Newsfeed item or null
|
|
*/
|
|
export async function getNewsfeedById(feedId: string): Promise<TCourseNewsfeed | null> {
|
|
try {
|
|
const [feed] = await db.select().from(schema.courseNewsfeed).where(eq(schema.courseNewsfeed.id, feedId)).limit(1);
|
|
return feed || null;
|
|
} catch (error) {
|
|
console.error('getNewsfeedById error:', error);
|
|
throw new Error(
|
|
`Failed to get newsfeed by ID "${feedId}": ${error instanceof Error ? error.message : 'Unknown error'}`
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Creates a new newsfeed item
|
|
* @param data Newsfeed data
|
|
* @returns Created newsfeed item
|
|
*/
|
|
export async function createNewsfeed(data: TNewCourseNewsfeed): Promise<TCourseNewsfeed> {
|
|
try {
|
|
const [feed] = await db.insert(schema.courseNewsfeed).values(data).returning();
|
|
if (!feed) {
|
|
throw new Error('Failed to create newsfeed');
|
|
}
|
|
return feed;
|
|
} catch (error) {
|
|
console.error('createNewsfeed error:', error);
|
|
throw new Error(`Failed to create newsfeed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Updates a newsfeed item
|
|
* @param feedId Newsfeed ID
|
|
* @param data Partial newsfeed data
|
|
* @returns Updated newsfeed item or null
|
|
*/
|
|
export async function updateNewsfeed(feedId: string, data: Partial<TCourseNewsfeed>): Promise<TCourseNewsfeed | null> {
|
|
try {
|
|
const [updated] = await db
|
|
.update(schema.courseNewsfeed)
|
|
.set(data)
|
|
.where(eq(schema.courseNewsfeed.id, feedId))
|
|
.returning();
|
|
return updated || null;
|
|
} catch (error) {
|
|
console.error('updateNewsfeed error:', error);
|
|
throw new Error(
|
|
`Failed to update newsfeed "${feedId}": ${error instanceof Error ? error.message : 'Unknown error'}`
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Deletes a newsfeed item
|
|
* @param feedId Newsfeed ID
|
|
* @returns Deleted newsfeed item or null
|
|
*/
|
|
export async function deleteNewsfeed(feedId: string): Promise<TCourseNewsfeed | null> {
|
|
try {
|
|
const [deleted] = await db.delete(schema.courseNewsfeed).where(eq(schema.courseNewsfeed.id, feedId)).returning();
|
|
return deleted || null;
|
|
} catch (error) {
|
|
console.error('deleteNewsfeed error:', error);
|
|
throw new Error(
|
|
`Failed to delete newsfeed "${feedId}": ${error instanceof Error ? error.message : 'Unknown error'}`
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Gets comments for a newsfeed item
|
|
* @param feedId Newsfeed ID
|
|
* @returns Array of comments
|
|
*/
|
|
export async function getNewsfeedCommentsByFeedId(feedId: string): Promise<TCourseNewsfeedComment[]> {
|
|
try {
|
|
return db
|
|
.select()
|
|
.from(schema.courseNewsfeedComment)
|
|
.where(eq(schema.courseNewsfeedComment.courseNewsfeedId, feedId))
|
|
.orderBy(schema.courseNewsfeedComment.createdAt);
|
|
} catch (error) {
|
|
console.error('getNewsfeedCommentsByFeedId error:', error);
|
|
throw new Error(
|
|
`Failed to get newsfeed comments for feed "${feedId}": ${error instanceof Error ? error.message : 'Unknown error'}`
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Gets paginated comments for a newsfeed item with author profile
|
|
* @param feedId Newsfeed ID
|
|
* @param options Pagination options (parentId, cursor, limit)
|
|
* @returns Object with comments array, total count, hasMore flag, and nextCursor
|
|
*/
|
|
export async function getNewsfeedCommentsByFeedIdPaginated(
|
|
feedId: string,
|
|
options: { parentId?: number; cursor?: string; limit: number }
|
|
) {
|
|
try {
|
|
const { parentId, cursor, limit } = options;
|
|
|
|
// Build base parent-scoped conditions
|
|
const parentCondition =
|
|
parentId !== undefined
|
|
? eq(schema.courseNewsfeedComment.parentId, parentId)
|
|
: isNull(schema.courseNewsfeedComment.parentId);
|
|
|
|
const baseWhereConditions = [eq(schema.courseNewsfeedComment.courseNewsfeedId, feedId), parentCondition];
|
|
|
|
const whereConditions = [...baseWhereConditions];
|
|
|
|
if (cursor) {
|
|
whereConditions.push(lt(schema.courseNewsfeedComment.id, Number(cursor)));
|
|
}
|
|
|
|
const totalCountResult = await db
|
|
.select({ count: sql<number>`count(*)`.as('count') })
|
|
.from(schema.courseNewsfeedComment)
|
|
.where(and(...baseWhereConditions));
|
|
const totalCount = Number(totalCountResult[0]?.count || 0);
|
|
|
|
const replyTarget = alias(schema.courseNewsfeedComment, 'reply_target');
|
|
const replyTargetMember = alias(schema.groupmember, 'reply_target_member');
|
|
const replyTargetProfile = alias(schema.profile, 'reply_target_profile');
|
|
|
|
// Fetch comments with author profile & reply count
|
|
const comments = await db
|
|
.select({
|
|
comment: schema.courseNewsfeedComment,
|
|
groupmember: schema.groupmember,
|
|
profile: schema.profile,
|
|
replyToAuthorFullname: replyTargetProfile.fullname,
|
|
replyCount: sql<number>`
|
|
COALESCE(
|
|
(SELECT COUNT(*)::int
|
|
FROM ${schema.courseNewsfeedComment} c2
|
|
WHERE c2.parent_id = ${schema.courseNewsfeedComment.id}),
|
|
0
|
|
)
|
|
`.as('replyCount')
|
|
})
|
|
.from(schema.courseNewsfeedComment)
|
|
.leftJoin(schema.groupmember, eq(schema.courseNewsfeedComment.authorId, schema.groupmember.id))
|
|
.leftJoin(schema.profile, eq(schema.groupmember.profileId, schema.profile.id))
|
|
.leftJoin(replyTarget, eq(schema.courseNewsfeedComment.replyToCommentId, replyTarget.id))
|
|
.leftJoin(replyTargetMember, eq(replyTarget.authorId, replyTargetMember.id))
|
|
.leftJoin(replyTargetProfile, eq(replyTargetMember.profileId, replyTargetProfile.id))
|
|
.where(and(...whereConditions))
|
|
.orderBy(desc(schema.courseNewsfeedComment.createdAt))
|
|
.limit(limit + 1);
|
|
|
|
const hasMore = comments.length > limit;
|
|
const items = comments.slice(0, limit);
|
|
|
|
const nextCursor = hasMore && items.length > 0 ? String(items[items.length - 1].comment.id) : null;
|
|
|
|
return {
|
|
items: items.map((row) => ({
|
|
...row.comment,
|
|
authorProfileId: row.profile?.id || null,
|
|
authorFullname: row.profile?.fullname || null,
|
|
authorUsername: row.profile?.username || null,
|
|
authorAvatarUrl: row.profile?.avatarUrl || null,
|
|
replyToAuthorFullname: row.replyToAuthorFullname || null,
|
|
replyCount: Number(row.replyCount || 0)
|
|
})),
|
|
totalCount,
|
|
hasMore,
|
|
nextCursor
|
|
};
|
|
} catch (error) {
|
|
console.error('getNewsfeedCommentsByFeedIdPaginated error:', error);
|
|
throw new Error(
|
|
`Failed to get paginated newsfeed comments for feed "${feedId}": ${error instanceof Error ? error.message : 'Unknown error'}`
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Creates a newsfeed comment
|
|
* @param data Comment data
|
|
* @returns Created comment
|
|
*/
|
|
export async function createNewsfeedComment(data: TNewCourseNewsfeedComment): Promise<TCourseNewsfeedComment> {
|
|
try {
|
|
const [comment] = await db.insert(schema.courseNewsfeedComment).values(data).returning();
|
|
if (!comment) {
|
|
throw new Error('Failed to create newsfeed comment');
|
|
}
|
|
return comment;
|
|
} catch (error) {
|
|
console.error('createNewsfeedComment error:', error);
|
|
throw new Error(`Failed to create newsfeed comment: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Deletes a newsfeed comment
|
|
* @param commentId Comment ID
|
|
* @returns Deleted comment or null
|
|
*/
|
|
export async function updateNewsfeedComment(
|
|
commentId: number,
|
|
content: string
|
|
): Promise<TCourseNewsfeedComment | null> {
|
|
try {
|
|
const [updated] = await db
|
|
.update(schema.courseNewsfeedComment)
|
|
.set({ content })
|
|
.where(eq(schema.courseNewsfeedComment.id, commentId))
|
|
.returning();
|
|
return updated || null;
|
|
} catch (error) {
|
|
console.error('updateNewsfeedComment error:', error);
|
|
throw new Error(
|
|
`Failed to update newsfeed comment "${commentId}": ${error instanceof Error ? error.message : 'Unknown error'}`
|
|
);
|
|
}
|
|
}
|
|
|
|
export async function deleteNewsfeedComment(commentId: number): Promise<TCourseNewsfeedComment | null> {
|
|
try {
|
|
const [deleted] = await db
|
|
.delete(schema.courseNewsfeedComment)
|
|
.where(eq(schema.courseNewsfeedComment.id, commentId))
|
|
.returning();
|
|
return deleted || null;
|
|
} catch (error) {
|
|
console.error('deleteNewsfeedComment error:', error);
|
|
throw new Error(
|
|
`Failed to delete newsfeed comment "${commentId}": ${error instanceof Error ? error.message : 'Unknown error'}`
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Gets a single newsfeed comment by ID
|
|
* @param commentId Comment ID
|
|
* @returns Comment or null
|
|
*/
|
|
export async function getNewsfeedCommentById(commentId: number): Promise<TCourseNewsfeedComment | null> {
|
|
try {
|
|
const [comment] = await db
|
|
.select()
|
|
.from(schema.courseNewsfeedComment)
|
|
.where(eq(schema.courseNewsfeedComment.id, commentId))
|
|
.limit(1);
|
|
return comment || null;
|
|
} catch (error) {
|
|
console.error('getNewsfeedCommentById error:', error);
|
|
throw new Error(
|
|
`Failed to get newsfeed comment by ID "${commentId}": ${error instanceof Error ? error.message : 'Unknown error'}`
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Gets comment author and course ID for authorization checks
|
|
* @param commentId Comment ID
|
|
* @returns Object with authorId (group member ID) and courseId, or null if not found
|
|
*/
|
|
export async function getNewsfeedCommentAuthorAndCourse(commentId: number): Promise<{
|
|
authorId: string | null;
|
|
courseId: string | null;
|
|
} | null> {
|
|
try {
|
|
const result = await db
|
|
.select({
|
|
authorId: schema.courseNewsfeedComment.authorId,
|
|
courseId: schema.courseNewsfeed.courseId
|
|
})
|
|
.from(schema.courseNewsfeedComment)
|
|
.innerJoin(schema.courseNewsfeed, eq(schema.courseNewsfeedComment.courseNewsfeedId, schema.courseNewsfeed.id))
|
|
.where(eq(schema.courseNewsfeedComment.id, commentId))
|
|
.limit(1);
|
|
|
|
if (result.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
authorId: result[0].authorId,
|
|
courseId: result[0].courseId
|
|
};
|
|
} catch (error) {
|
|
console.error('getNewsfeedCommentAuthorAndCourse error:', error);
|
|
throw new Error(
|
|
`Failed to get newsfeed comment author for comment "${commentId}": ${error instanceof Error ? error.message : 'Unknown error'}`
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Gets feed data with course, organization, author, and members info for email notifications
|
|
* @param feedId Feed ID
|
|
* @param excludeAuthorId Group member ID to exclude from course members list
|
|
* @returns Feed data with course, org, author, and members info, or null if not found
|
|
*/
|
|
export async function getNewsfeedForEmail(
|
|
feedId: string,
|
|
excludeAuthorId?: string
|
|
): Promise<{
|
|
feedId: string;
|
|
courseId: string | null;
|
|
courseTitle: string | null;
|
|
content: string | null;
|
|
author: {
|
|
groupMemberId: string | null;
|
|
fullname: string | null;
|
|
email: string | null;
|
|
} | null;
|
|
organization: {
|
|
id: string;
|
|
name: string | null;
|
|
siteName: string | null;
|
|
avatarUrl: string | null;
|
|
theme: string | null;
|
|
} | null;
|
|
courseMembers: Array<{
|
|
email: string | null;
|
|
fullname: string | null;
|
|
}>;
|
|
} | null> {
|
|
try {
|
|
const result = await db
|
|
.select({
|
|
feed: schema.courseNewsfeed,
|
|
course: schema.course,
|
|
organization: schema.organization,
|
|
authorGroupMember: schema.groupmember,
|
|
authorProfile: schema.profile
|
|
})
|
|
.from(schema.courseNewsfeed)
|
|
.innerJoin(schema.course, eq(schema.courseNewsfeed.courseId, schema.course.id))
|
|
.innerJoin(schema.group, eq(schema.course.groupId, schema.group.id))
|
|
.innerJoin(schema.organization, eq(schema.group.organizationId, schema.organization.id))
|
|
.leftJoin(schema.groupmember, eq(schema.courseNewsfeed.authorId, schema.groupmember.id))
|
|
.leftJoin(schema.profile, eq(schema.groupmember.profileId, schema.profile.id))
|
|
.where(eq(schema.courseNewsfeed.id, feedId))
|
|
.limit(1);
|
|
|
|
if (result.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const row = result[0];
|
|
|
|
// Get all course members (excluding author if specified)
|
|
const memberConditions = [eq(schema.course.id, row.course.id), eq(schema.groupmember.roleId, ROLE.STUDENT)];
|
|
if (excludeAuthorId) {
|
|
memberConditions.push(ne(schema.groupmember.id, excludeAuthorId));
|
|
}
|
|
|
|
const membersResult = await db
|
|
.select({
|
|
groupMember: schema.groupmember,
|
|
profile: schema.profile
|
|
})
|
|
.from(schema.groupmember)
|
|
.innerJoin(schema.course, eq(schema.course.groupId, schema.groupmember.groupId))
|
|
.leftJoin(schema.profile, eq(schema.groupmember.profileId, schema.profile.id))
|
|
.where(and(...memberConditions));
|
|
|
|
return {
|
|
feedId,
|
|
courseId: row.course.id,
|
|
courseTitle: row.course.title,
|
|
content: row.feed.content,
|
|
author: row.authorGroupMember
|
|
? {
|
|
groupMemberId: row.authorGroupMember.id,
|
|
fullname: row.authorProfile?.fullname || null,
|
|
email: row.authorProfile?.email || null
|
|
}
|
|
: null,
|
|
organization: {
|
|
id: row.organization.id,
|
|
name: row.organization.name,
|
|
siteName: row.organization.siteName,
|
|
avatarUrl: row.organization.avatarUrl,
|
|
theme: row.organization.theme
|
|
},
|
|
courseMembers: membersResult
|
|
.filter((m) => m.profile?.email) // Only include members with email
|
|
.map((m) => ({
|
|
email: m.profile?.email || null,
|
|
fullname: m.profile?.fullname || null
|
|
}))
|
|
};
|
|
} catch (error) {
|
|
console.error('getNewsfeedForEmail error:', error);
|
|
throw new Error(
|
|
`Failed to get newsfeed data for email "${feedId}": ${error instanceof Error ? error.message : 'Unknown error'}`
|
|
);
|
|
}
|
|
}
|
|
|
|
function getCommentCount() {
|
|
return sql<number>`
|
|
COALESCE(
|
|
(SELECT COUNT(*)::int
|
|
FROM ${schema.courseNewsfeedComment}
|
|
WHERE ${eq(schema.courseNewsfeedComment.courseNewsfeedId, schema.courseNewsfeed.id)}
|
|
AND ${isNull(schema.courseNewsfeedComment.parentId)}),
|
|
0
|
|
)
|
|
`.as('commentCount');
|
|
}
|