a tool for shared writing and social publishing
1"use server";
2
3import { supabaseServerClient } from "supabase/serverClient";
4import { Tables, TablesInsert } from "supabase/database.types";
5
6type NotificationRow = Tables<"notifications">;
7
8export type Notification = Omit<TablesInsert<"notifications">, "data"> & {
9 data: NotificationData;
10};
11
12export type NotificationData =
13 | { type: "comment"; comment_uri: string; parent_uri?: string }
14 | { type: "subscribe"; subscription_uri: string }
15 | { type: "quote"; bsky_post_uri: string; document_uri: string };
16
17export type HydratedNotification =
18 | HydratedCommentNotification
19 | HydratedSubscribeNotification
20 | HydratedQuoteNotification;
21export async function hydrateNotifications(
22 notifications: NotificationRow[],
23): Promise<Array<HydratedNotification>> {
24 // Call all hydrators in parallel
25 const [commentNotifications, subscribeNotifications, quoteNotifications] = await Promise.all([
26 hydrateCommentNotifications(notifications),
27 hydrateSubscribeNotifications(notifications),
28 hydrateQuoteNotifications(notifications),
29 ]);
30
31 // Combine all hydrated notifications
32 const allHydrated = [...commentNotifications, ...subscribeNotifications, ...quoteNotifications];
33
34 // Sort by created_at to maintain order
35 allHydrated.sort(
36 (a, b) =>
37 new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
38 );
39
40 return allHydrated;
41}
42
43// Type guard to extract notification type
44type ExtractNotificationType<T extends NotificationData["type"]> = Extract<
45 NotificationData,
46 { type: T }
47>;
48
49export type HydratedCommentNotification = Awaited<
50 ReturnType<typeof hydrateCommentNotifications>
51>[0];
52
53async function hydrateCommentNotifications(notifications: NotificationRow[]) {
54 const commentNotifications = notifications.filter(
55 (n): n is NotificationRow & { data: ExtractNotificationType<"comment"> } =>
56 (n.data as NotificationData)?.type === "comment",
57 );
58
59 if (commentNotifications.length === 0) {
60 return [];
61 }
62
63 // Fetch comment data from the database
64 const commentUris = commentNotifications.flatMap((n) =>
65 n.data.parent_uri
66 ? [n.data.comment_uri, n.data.parent_uri]
67 : [n.data.comment_uri],
68 );
69 const { data: comments } = await supabaseServerClient
70 .from("comments_on_documents")
71 .select(
72 "*,bsky_profiles(*), documents(*, documents_in_publications(publications(*)))",
73 )
74 .in("uri", commentUris);
75
76 return commentNotifications.map((notification) => ({
77 id: notification.id,
78 recipient: notification.recipient,
79 created_at: notification.created_at,
80 type: "comment" as const,
81 comment_uri: notification.data.comment_uri,
82 parentData: notification.data.parent_uri
83 ? comments?.find((c) => c.uri === notification.data.parent_uri)!
84 : undefined,
85 commentData: comments?.find(
86 (c) => c.uri === notification.data.comment_uri,
87 )!,
88 }));
89}
90
91export type HydratedSubscribeNotification = Awaited<
92 ReturnType<typeof hydrateSubscribeNotifications>
93>[0];
94
95async function hydrateSubscribeNotifications(notifications: NotificationRow[]) {
96 const subscribeNotifications = notifications.filter(
97 (
98 n,
99 ): n is NotificationRow & { data: ExtractNotificationType<"subscribe"> } =>
100 (n.data as NotificationData)?.type === "subscribe",
101 );
102
103 if (subscribeNotifications.length === 0) {
104 return [];
105 }
106
107 // Fetch subscription data from the database with related data
108 const subscriptionUris = subscribeNotifications.map(
109 (n) => n.data.subscription_uri,
110 );
111 const { data: subscriptions } = await supabaseServerClient
112 .from("publication_subscriptions")
113 .select("*, identities(bsky_profiles(*)), publications(*)")
114 .in("uri", subscriptionUris);
115
116 return subscribeNotifications.map((notification) => ({
117 id: notification.id,
118 recipient: notification.recipient,
119 created_at: notification.created_at,
120 type: "subscribe" as const,
121 subscription_uri: notification.data.subscription_uri,
122 subscriptionData: subscriptions?.find(
123 (s) => s.uri === notification.data.subscription_uri,
124 )!,
125 }));
126}
127
128export type HydratedQuoteNotification = Awaited<
129 ReturnType<typeof hydrateQuoteNotifications>
130>[0];
131
132async function hydrateQuoteNotifications(notifications: NotificationRow[]) {
133 const quoteNotifications = notifications.filter(
134 (n): n is NotificationRow & { data: ExtractNotificationType<"quote"> } =>
135 (n.data as NotificationData)?.type === "quote",
136 );
137
138 if (quoteNotifications.length === 0) {
139 return [];
140 }
141
142 // Fetch bsky post data and document data
143 const bskyPostUris = quoteNotifications.map((n) => n.data.bsky_post_uri);
144 const documentUris = quoteNotifications.map((n) => n.data.document_uri);
145
146 const { data: bskyPosts } = await supabaseServerClient
147 .from("bsky_posts")
148 .select("*")
149 .in("uri", bskyPostUris);
150
151 const { data: documents } = await supabaseServerClient
152 .from("documents")
153 .select("*, documents_in_publications(publications(*))")
154 .in("uri", documentUris);
155
156 return quoteNotifications.map((notification) => ({
157 id: notification.id,
158 recipient: notification.recipient,
159 created_at: notification.created_at,
160 type: "quote" as const,
161 bsky_post_uri: notification.data.bsky_post_uri,
162 document_uri: notification.data.document_uri,
163 bskyPost: bskyPosts?.find((p) => p.uri === notification.data.bsky_post_uri)!,
164 document: documents?.find((d) => d.uri === notification.data.document_uri)!,
165 }));
166}
167
168export async function pingIdentityToUpdateNotification(did: string) {
169 let channel = supabaseServerClient.channel(`identity.atp_did:${did}`);
170 await channel.send({
171 type: "broadcast",
172 event: "notification",
173 payload: { message: "poke" },
174 });
175 await supabaseServerClient.removeChannel(channel);
176}