Files
classroomio/packages/db/src/queries/newsfeed/newsfeed.ts
T
Rotimi Best e79d920aeb fix(newsfeed): tighten card spacing and reposition pinned icon (#818)
* 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

---------
2026-08-02 23:23:18 +01:00

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');
}