[READ ONLY MIRROR] Spark Social AppView Server
github.com/sprksocial/server
atproto
deno
hono
lexicon
1import { Database } from "../db/index.ts";
2import { GenericKeyset } from "../db/pagination.ts";
3
4type SortAtCidResult = { sortAt: string; recordCid: string };
5type SortAtCidLabeledResult = { primary: string; secondary: string };
6
7class SortAtCidKeyset extends GenericKeyset<
8 SortAtCidResult,
9 SortAtCidLabeledResult
10> {
11 constructor() {
12 super("sortAt", "recordCid");
13 }
14
15 labelResult(result: SortAtCidResult): SortAtCidLabeledResult {
16 const sortAt = result.sortAt || new Date().toISOString();
17 return { primary: sortAt, secondary: result.recordCid };
18 }
19
20 labeledResultToCursor(labeled: SortAtCidLabeledResult) {
21 const timestamp = new Date(labeled.primary).getTime();
22 if (isNaN(timestamp)) {
23 throw new Error("Invalid date for cursor");
24 }
25 const secondsBase36 = Math.floor(timestamp / 1000).toString(36);
26 return {
27 primary: secondsBase36,
28 secondary: labeled.secondary,
29 };
30 }
31
32 cursorToLabeledResult(cursor: { primary: string; secondary: string }) {
33 const seconds = parseInt(cursor.primary, 36);
34 if (isNaN(seconds)) {
35 throw new Error("Malformed cursor: invalid timestamp");
36 }
37 const primaryDate = new Date(seconds * 1000);
38 if (isNaN(primaryDate.getTime())) {
39 throw new Error("Malformed cursor: invalid date");
40 }
41 return {
42 primary: primaryDate.toISOString(),
43 secondary: cursor.secondary,
44 };
45 }
46}
47
48export interface Notification {
49 recipientDid: string;
50 uri: string;
51 cid: string;
52 reason: string;
53 reasonSubject?: string;
54 sortAt: string;
55 authorDid: string;
56 priority?: boolean;
57}
58
59export class Notifications {
60 private db: Database;
61 private sortAtCidKeyset: SortAtCidKeyset;
62
63 constructor(db: Database) {
64 this.db = db;
65 this.sortAtCidKeyset = new SortAtCidKeyset();
66 }
67
68 async getNotifications(
69 actorDid: string,
70 limit = 50,
71 cursor?: string,
72 priority?: boolean,
73 ): Promise<{ notifications: Notification[]; cursor?: string }> {
74 // Get follows for priority filtering
75 let priorityDids: string[] | undefined;
76 if (priority) {
77 const follows = await this.db.models.Follow.find({
78 authorDid: actorDid,
79 }).select("subject");
80 priorityDids = follows.map((f) => f.subject);
81 if (priorityDids.length === 0) {
82 return { notifications: [], cursor: undefined };
83 }
84 }
85
86 // Build base query
87 const baseFilter: Record<string, unknown> = { did: actorDid };
88
89 // If priority, filter to only notifications from followed users
90 if (priorityDids) {
91 baseFilter.author = { $in: priorityDids };
92 }
93
94 // Get notifications
95 const notifsQuery = this.db.models.Notification.find(baseFilter);
96
97 // Apply pagination
98 const paginatedQuery = this.sortAtCidKeyset.paginate(notifsQuery, {
99 limit,
100 cursor,
101 direction: "desc",
102 });
103
104 const notifs = await paginatedQuery.exec();
105
106 // Filter out notifications with missing reasonSubject records
107 const filteredNotifs = await this.filterValidReasonSubjects(notifs);
108
109 // Get priority status for each notification
110 const followedDids = priorityDids ?? await this.getFollowedDids(actorDid);
111 const followedSet = new Set(followedDids);
112
113 // Generate cursor from the last item if we have results
114 let nextCursor: string | undefined;
115 if (notifs.length === limit && notifs.length > 0) {
116 const lastNotif = notifs[notifs.length - 1];
117 nextCursor = this.sortAtCidKeyset.pack({
118 primary: lastNotif.sortAt,
119 secondary: lastNotif.recordCid,
120 });
121 }
122
123 const notifications = filteredNotifs.map((notif) => ({
124 recipientDid: actorDid,
125 uri: notif.recordUri,
126 cid: notif.recordCid,
127 reason: notif.reason,
128 reasonSubject: notif.reasonSubject ?? undefined,
129 sortAt: notif.sortAt,
130 authorDid: notif.author,
131 priority: followedSet.has(notif.author),
132 }));
133
134 return {
135 notifications,
136 cursor: nextCursor,
137 };
138 }
139
140 async getNotificationSeen(
141 actorDid: string,
142 _priority?: boolean,
143 ): Promise<{ timestamp?: string }> {
144 const actor = await this.db.models.Actor.findOne({ did: actorDid });
145 if (!actor || !actor.lastSeenNotifs) {
146 return {};
147 }
148
149 return { timestamp: actor.lastSeenNotifs };
150 }
151
152 async getUnreadNotificationCount(
153 actorDid: string,
154 lastSeen?: string,
155 priority?: boolean,
156 ): Promise<{ count: number }> {
157 const baseFilter: Record<string, unknown> = { did: actorDid };
158
159 // Filter by lastSeen if provided
160 if (lastSeen) {
161 baseFilter.sortAt = { $gt: lastSeen };
162 }
163
164 // If priority, filter to only notifications from followed users
165 if (priority) {
166 const follows = await this.db.models.Follow.find({
167 authorDid: actorDid,
168 }).select("subject");
169 const priorityDids = follows.map((f) => f.subject);
170 if (priorityDids.length === 0) {
171 return { count: 0 };
172 }
173 baseFilter.author = { $in: priorityDids };
174 }
175
176 const count = await this.db.models.Notification.countDocuments(baseFilter);
177
178 return { count };
179 }
180
181 async updateNotificationSeen(
182 actorDid: string,
183 timestamp: string,
184 _priority?: boolean,
185 ): Promise<void> {
186 await this.db.models.Actor.findOneAndUpdate(
187 { did: actorDid },
188 { $set: { lastSeenNotifs: timestamp } },
189 { upsert: false },
190 );
191 }
192
193 // Helper methods
194
195 private async getFollowedDids(actorDid: string): Promise<string[]> {
196 const follows = await this.db.models.Follow.find({
197 authorDid: actorDid,
198 }).select("subject");
199 return follows.map((f) => f.subject);
200 }
201
202 private async filterValidReasonSubjects(
203 notifs: Array<{
204 recordUri: string;
205 recordCid: string;
206 author: string;
207 reason: string;
208 reasonSubject: string | null;
209 sortAt: string;
210 }>,
211 ): Promise<
212 Array<{
213 recordUri: string;
214 recordCid: string;
215 author: string;
216 reason: string;
217 reasonSubject: string | null;
218 sortAt: string;
219 }>
220 > {
221 // Filter out notifications where reasonSubject exists but the record doesn't
222 const notifsWithSubject = notifs.filter((n) => n.reasonSubject);
223 if (notifsWithSubject.length === 0) {
224 return notifs;
225 }
226
227 const subjectUris = notifsWithSubject.map((n) => n.reasonSubject as string);
228
229 const existingRecords = await this.db.models.Record.find({
230 uri: { $in: subjectUris },
231 }).select("uri").lean();
232
233 const existingUris = new Set(existingRecords.map((r) => r.uri));
234
235 return notifs.filter(
236 (n) => !n.reasonSubject || existingUris.has(n.reasonSubject),
237 );
238 }
239}