Skip to content

Commit 1f2b650

Browse files
committed
feat: enhance notification system with improved display and payload handling
- Introduced new functions for displaying notification content, including actor names and reaction labels. - Updated notification payload structure to include additional fields like gist_title. - Enhanced notification body formatting for better user experience. - Refactored comment creation logic to streamline notification sending process. - Added Bengali translations for new notification messages to support localization. This update improves the clarity and usability of notifications within the application.
1 parent b49acae commit 1f2b650

9 files changed

Lines changed: 922 additions & 306 deletions

File tree

src/app/dashboard/notifications/page.tsx

Lines changed: 323 additions & 86 deletions
Large diffs are not rendered by default.

src/backend/models/domain-models.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -269,7 +269,8 @@ export type NotificationType =
269269
| "REPLY_TO_COMMENT"
270270
| "COMMENT_ON_GIST"
271271
| "REACTION_ON_ARTICLE"
272-
| "REACTION_ON_COMMENT";
272+
| "REACTION_ON_COMMENT"
273+
| "REACTION_ON_GIST";
273274

274275
export interface NotificationPayload {
275276
article_id?: string;
@@ -278,6 +279,7 @@ export interface NotificationPayload {
278279
article_author_username?: string;
279280
comment_id?: string;
280281
gist_id?: string;
282+
gist_title?: string;
281283
reaction_type?: string;
282284
actor_name?: string;
283285
actor_username?: string;
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { eq } from "sqlkit";
2+
import { persistenceRepository } from "../persistence/persistence-repositories";
3+
4+
/** Walks parent COMMENT chain to the root ARTICLE or GIST for notification payloads and links. */
5+
export async function commentThreadRootResource(commentId: string): Promise<
6+
| {
7+
kind: "ARTICLE";
8+
article_id: string;
9+
article_handle: string;
10+
article_title: string;
11+
article_author_username?: string;
12+
}
13+
| { kind: "GIST"; gist_id: string; gist_title: string }
14+
| null
15+
> {
16+
let currentId: string | undefined = commentId;
17+
for (let depth = 0; depth < 50 && currentId; depth++) {
18+
const [row] = await persistenceRepository.comment.find({
19+
where: eq("id", currentId),
20+
limit: 1,
21+
columns: ["resource_id", "resource_type"],
22+
});
23+
if (!row) return null;
24+
const { resource_id, resource_type } = row;
25+
if (resource_type === "ARTICLE") {
26+
const [article] = await persistenceRepository.article.find({
27+
where: eq("id", resource_id),
28+
limit: 1,
29+
columns: ["id", "author_id", "title", "handle"],
30+
});
31+
if (!article) return null;
32+
const [articleAuthor] = await persistenceRepository.user.find({
33+
where: eq("id", article.author_id),
34+
limit: 1,
35+
columns: ["username"],
36+
});
37+
return {
38+
kind: "ARTICLE",
39+
article_id: article.id,
40+
article_handle: article.handle,
41+
article_title: article.title,
42+
...(articleAuthor?.username
43+
? { article_author_username: articleAuthor.username }
44+
: {}),
45+
};
46+
}
47+
if (resource_type === "GIST") {
48+
const [gist] = await persistenceRepository.gist.find({
49+
where: eq("id", resource_id),
50+
limit: 1,
51+
columns: ["id", "title"],
52+
});
53+
if (!gist) return null;
54+
return {
55+
kind: "GIST",
56+
gist_id: gist.id,
57+
gist_title: gist.title,
58+
};
59+
}
60+
if (resource_type === "COMMENT") {
61+
currentId = resource_id;
62+
continue;
63+
}
64+
return null;
65+
}
66+
return null;
67+
}

src/backend/services/comment.action.ts

Lines changed: 24 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -3,20 +3,18 @@
33
import z from "zod/v4";
44
import { CommentActionInput } from "./inputs/comment.input";
55
import { authID } from "./session.actions";
6-
import {
7-
ActionException,
8-
handleActionException,
9-
} from "./RepositoryException";
6+
import { ActionException, handleActionException } from "./RepositoryException";
107
import { persistenceRepository } from "../persistence/persistence-repositories";
118
import { pgClient } from "../persistence/clients";
129
import { and, eq, inArray } from "sqlkit";
1310
import { CommentPresentation } from "../models/domain-models";
1411
import { inngest } from "@/lib/inngest";
12+
import { assertCommentResourceExists } from "./notifications.payload";
1513

1614
const sql = String.raw;
1715

1816
export const getComments = async (
19-
_input: z.infer<typeof CommentActionInput.getComments>
17+
_input: z.infer<typeof CommentActionInput.getComments>,
2018
): Promise<CommentPresentation[]> => {
2119
const input = CommentActionInput.getComments.parse(_input);
2220

@@ -28,71 +26,22 @@ export const getComments = async (
2826
input.resource_id,
2927
input.resource_type,
3028
]);
31-
const rows = execution_response as { rows?: { comments?: CommentPresentation[] }[] };
29+
const rows = execution_response as {
30+
rows?: { comments?: CommentPresentation[] }[];
31+
};
3232
return rows?.rows?.[0]?.comments || [];
3333
};
3434

3535
export const createMyComment = async (
36-
input: z.infer<typeof CommentActionInput.create>
36+
input: z.infer<typeof CommentActionInput.create>,
3737
) => {
3838
const sessionId = await authID();
3939
if (!sessionId) {
4040
throw new ActionException("Unauthorized: No session ID found");
4141
}
4242
const { resource_id, resource_type, body } = input;
4343

44-
let notificationRecipientId: string | null = null;
45-
let notificationPayload: Record<string, string> = {};
46-
47-
switch (resource_type) {
48-
case "ARTICLE": {
49-
const [article] = await persistenceRepository.article.find({
50-
where: eq("id", resource_id),
51-
limit: 1,
52-
columns: ["id", "author_id", "title", "handle"],
53-
});
54-
if (!article) throw new ActionException("Resource not found");
55-
notificationRecipientId = article.author_id;
56-
const [articleAuthor] = await persistenceRepository.user.find({
57-
where: eq("id", article.author_id),
58-
limit: 1,
59-
columns: ["id", "username"],
60-
});
61-
notificationPayload = {
62-
article_id: article.id,
63-
article_handle: article.handle,
64-
article_title: article.title,
65-
...(articleAuthor?.username
66-
? { article_author_username: articleAuthor.username }
67-
: {}),
68-
};
69-
break;
70-
}
71-
case "COMMENT": {
72-
const [parentComment] = await persistenceRepository.comment.find({
73-
where: eq("id", resource_id),
74-
limit: 1,
75-
columns: ["id", "user_id"],
76-
});
77-
if (!parentComment) throw new ActionException("Parent comment not found");
78-
notificationRecipientId = parentComment.user_id;
79-
notificationPayload = { comment_id: parentComment.id };
80-
break;
81-
}
82-
case "GIST": {
83-
const [gist] = await persistenceRepository.gist.find({
84-
where: eq("id", resource_id),
85-
limit: 1,
86-
columns: ["id", "owner_id", "title"],
87-
});
88-
if (!gist) throw new ActionException("Resource not found");
89-
notificationRecipientId = gist.owner_id;
90-
notificationPayload = { gist_id: gist.id };
91-
break;
92-
}
93-
default:
94-
throw new ActionException("Invalid resource type");
95-
}
44+
await assertCommentResourceExists(resource_id, resource_type);
9645

9746
const created = await persistenceRepository.comment.insert([
9847
{
@@ -104,45 +53,26 @@ export const createMyComment = async (
10453
},
10554
]);
10655

107-
// Fetch actor info for notification payload
108-
const [actor] = await persistenceRepository.user.find({
109-
where: eq("id", sessionId),
110-
limit: 1,
111-
columns: ["id", "name", "username"],
112-
});
113-
114-
// Send notification event (log errors, don't fail the mutation)
115-
if (notificationRecipientId) {
116-
const notificationType =
117-
resource_type === "ARTICLE"
118-
? "COMMENT_ON_ARTICLE"
119-
: resource_type === "COMMENT"
120-
? "REPLY_TO_COMMENT"
121-
: "COMMENT_ON_GIST";
122-
inngest
123-
.send({
124-
name: "app/notification.requested",
125-
data: {
126-
recipient_id: notificationRecipientId,
127-
actor_id: sessionId,
128-
type: notificationType,
129-
payload: {
130-
...notificationPayload,
131-
actor_name: actor?.name,
132-
actor_username: actor?.username,
133-
},
56+
inngest
57+
.send({
58+
name: "app/notification.requested",
59+
data: {
60+
actor_id: sessionId,
61+
comment_request: {
62+
resource_id,
63+
resource_type,
13464
},
135-
})
136-
.catch((err) => {
137-
console.error("[inngest] Failed to send notification event:", err);
138-
});
139-
}
65+
},
66+
})
67+
.catch((err) => {
68+
console.error("[inngest] Failed to send notification event:", err);
69+
});
14070

14171
return created?.rows?.[0];
14272
};
14373

14474
export const updateMyComment = async (
145-
_input: z.infer<typeof CommentActionInput.update>
75+
_input: z.infer<typeof CommentActionInput.update>,
14676
) => {
14777
try {
14878
const input = await CommentActionInput.update.parseAsync(_input);
@@ -175,7 +105,7 @@ export const updateMyComment = async (
175105

176106
/** Deletes the comment and all nested replies; reactions on those comments are removed first. */
177107
export const deleteMyComment = async (
178-
_input: z.infer<typeof CommentActionInput.delete>
108+
_input: z.infer<typeof CommentActionInput.delete>,
179109
) => {
180110
try {
181111
const input = await CommentActionInput.delete.parseAsync(_input);
@@ -212,10 +142,7 @@ export const deleteMyComment = async (
212142
}
213143

214144
await persistenceRepository.reaction.delete({
215-
where: and(
216-
eq("resource_type", "COMMENT"),
217-
inArray("resource_id", ids)
218-
),
145+
where: and(eq("resource_type", "COMMENT"), inArray("resource_id", ids)),
219146
});
220147

221148
await persistenceRepository.comment.delete({

src/backend/services/notifications.actions.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
"use server";
22

3+
/** User-facing server actions. Worker-side payload assembly lives in `notifications.payload.ts`. */
4+
35
import z from "zod/v4";
46
import { and, eq } from "sqlkit";
57
import { NotificationActionInput } from "./inputs/notification.input";
@@ -13,7 +15,7 @@ import { Notification } from "../models/domain-models";
1315
const sql = String.raw;
1416

1517
export async function listMyNotifications(
16-
_input: z.infer<typeof NotificationActionInput.list>
18+
_input: z.infer<typeof NotificationActionInput.list>,
1719
) {
1820
try {
1921
const userId = await authID();
@@ -27,7 +29,10 @@ export async function listMyNotifications(
2729
FROM notifications
2830
WHERE recipient_id = $1
2931
`;
30-
const countResult = await pgClient.executeSQL<{ total: string }>(countQuery, [userId]);
32+
const countResult = await pgClient.executeSQL<{ total: string }>(
33+
countQuery,
34+
[userId],
35+
);
3136
const totalCount = Number(countResult?.rows?.[0]?.total ?? 0);
3237
const totalPages = Math.ceil(totalCount / input.limit);
3338

@@ -75,7 +80,7 @@ export async function listMyNotifications(
7580
}
7681

7782
export async function markNotificationRead(
78-
_input: z.infer<typeof NotificationActionInput.markRead>
83+
_input: z.infer<typeof NotificationActionInput.markRead>,
7984
): Promise<ActionResponse<{ id: string }>> {
8085
try {
8186
const userId = await authID();
@@ -127,7 +132,9 @@ export async function unreadNotificationCount(): Promise<
127132
FROM notifications
128133
WHERE recipient_id = $1 AND read_at IS NULL
129134
`;
130-
const result = await pgClient.executeSQL<{ count: string }>(query, [userId]);
135+
const result = await pgClient.executeSQL<{ count: string }>(query, [
136+
userId,
137+
]);
131138
const count = Number(result?.rows?.[0]?.count ?? 0);
132139

133140
return { success: true, data: { count } };

0 commit comments

Comments
 (0)