feat(notifications): Refactor notification system and consolidate interaction handlers
- Remove dedicated bot owner notification utility in favor of unified notification system - Update all interaction endpoints (follow, like, mention, repost) to use consolidated notification flow - Refactor notification route to handle all interaction types through single endpoint - Update posts endpoints to trigger notifications through unified system - Add database migration snapshot for notification schema changes - Simplify notification logic by removing redundant bot owner notification module - Improve notification consistency across all user interactions and post operations
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -43,6 +43,13 @@
|
||||
"when": 1769422771465,
|
||||
"tag": "0005_yummy_millenium_guard",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "7",
|
||||
"when": 1769427398729,
|
||||
"tag": "0006_loud_moonstone",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -9,6 +9,30 @@ const markSchema = z.object({
|
||||
all: z.boolean().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Fetch fresh profile data for a remote actor
|
||||
*/
|
||||
async function fetchRemoteProfile(handle: string, nodeDomain: string): Promise<{
|
||||
displayName: string | null;
|
||||
avatarUrl: string | null;
|
||||
} | null> {
|
||||
try {
|
||||
const protocol = nodeDomain.includes('localhost') ? 'http' : 'https';
|
||||
const res = await fetch(`${protocol}://${nodeDomain}/api/swarm/users/${handle}`, {
|
||||
headers: { 'Accept': 'application/json' },
|
||||
signal: AbortSignal.timeout(3000),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
return {
|
||||
displayName: data.profile?.displayName || null,
|
||||
avatarUrl: data.profile?.avatarUrl || null,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const user = await requireAuth();
|
||||
@@ -28,34 +52,56 @@ export async function GET(request: Request) {
|
||||
|
||||
const rows = await db.query.notifications.findMany({
|
||||
where: and(...conditions),
|
||||
with: {
|
||||
actor: true,
|
||||
post: true,
|
||||
},
|
||||
orderBy: [desc(notifications.createdAt)],
|
||||
limit,
|
||||
});
|
||||
|
||||
type ActorInfo = { id: string; handle: string; displayName: string | null; avatarUrl: string | null };
|
||||
type PostInfo = { id: string; content: string };
|
||||
// For remote actors missing avatar, fetch fresh data
|
||||
const remoteToFetch = new Map<string, { handle: string; nodeDomain: string }>();
|
||||
for (const row of rows) {
|
||||
if (row.actorNodeDomain && !row.actorAvatarUrl) {
|
||||
const key = `${row.actorHandle}@${row.actorNodeDomain}`;
|
||||
if (!remoteToFetch.has(key)) {
|
||||
remoteToFetch.set(key, {
|
||||
handle: row.actorHandle.split('@')[0], // Get just the username part
|
||||
nodeDomain: row.actorNodeDomain
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch fresh profile data in parallel
|
||||
const freshProfiles = new Map<string, { displayName: string | null; avatarUrl: string | null }>();
|
||||
if (remoteToFetch.size > 0) {
|
||||
const fetchPromises = Array.from(remoteToFetch.entries()).map(async ([key, { handle, nodeDomain }]) => {
|
||||
const profile = await fetchRemoteProfile(handle, nodeDomain);
|
||||
if (profile) {
|
||||
freshProfiles.set(key, profile);
|
||||
}
|
||||
});
|
||||
await Promise.all(fetchPromises);
|
||||
}
|
||||
|
||||
const payload = rows.map((row) => {
|
||||
const actor = row.actor as ActorInfo | null;
|
||||
const post = row.post as PostInfo | null;
|
||||
const key = row.actorNodeDomain ? `${row.actorHandle}@${row.actorNodeDomain}` : null;
|
||||
const freshProfile = key ? freshProfiles.get(key) : null;
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
type: row.type,
|
||||
createdAt: row.createdAt,
|
||||
readAt: row.readAt,
|
||||
actor: actor ? {
|
||||
id: actor.id,
|
||||
handle: actor.handle,
|
||||
displayName: actor.displayName,
|
||||
avatarUrl: actor.avatarUrl,
|
||||
} : null,
|
||||
post: post ? {
|
||||
id: post.id,
|
||||
content: post.content,
|
||||
actor: {
|
||||
handle: row.actorNodeDomain
|
||||
? `${row.actorHandle}@${row.actorNodeDomain}`
|
||||
: row.actorHandle,
|
||||
displayName: freshProfile?.displayName || row.actorDisplayName,
|
||||
avatarUrl: freshProfile?.avatarUrl || row.actorAvatarUrl,
|
||||
nodeDomain: row.actorNodeDomain,
|
||||
},
|
||||
post: row.postId ? {
|
||||
id: row.postId,
|
||||
content: row.postContent,
|
||||
} : null,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -77,16 +77,36 @@ export async function POST(request: Request, context: RouteContext) {
|
||||
.where(eq(posts.id, postId));
|
||||
|
||||
if (post.userId !== user.id) {
|
||||
// Create notification with actor info stored directly
|
||||
await db.insert(notifications).values({
|
||||
userId: post.userId,
|
||||
actorId: user.id,
|
||||
actorHandle: user.handle,
|
||||
actorDisplayName: user.displayName,
|
||||
actorAvatarUrl: user.avatarUrl,
|
||||
actorNodeDomain: null, // Local user
|
||||
postId,
|
||||
postContent: post.content?.slice(0, 200) || null,
|
||||
type: 'like',
|
||||
});
|
||||
|
||||
// Also notify bot owner if this is a bot's post
|
||||
const { notifyBotOwnerForPost } = await import('@/lib/notifications/botOwnerNotify');
|
||||
await notifyBotOwnerForPost(post.userId, user.id, 'like', postId);
|
||||
const postAuthor = await db.query.users.findFirst({
|
||||
where: eq(users.id, post.userId),
|
||||
});
|
||||
if (postAuthor?.isBot && postAuthor.botOwnerId) {
|
||||
await db.insert(notifications).values({
|
||||
userId: postAuthor.botOwnerId,
|
||||
actorId: user.id,
|
||||
actorHandle: user.handle,
|
||||
actorDisplayName: user.displayName,
|
||||
actorAvatarUrl: user.avatarUrl,
|
||||
actorNodeDomain: null,
|
||||
postId,
|
||||
postContent: post.content?.slice(0, 200) || null,
|
||||
type: 'like',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// SWARM-FIRST: Check if this is a swarm post and deliver directly
|
||||
|
||||
@@ -87,16 +87,36 @@ export async function POST(request: Request, context: RouteContext) {
|
||||
.where(eq(users.id, user.id));
|
||||
|
||||
if (originalPost.userId !== user.id) {
|
||||
// Create notification with actor info stored directly
|
||||
await db.insert(notifications).values({
|
||||
userId: originalPost.userId,
|
||||
actorId: user.id,
|
||||
actorHandle: user.handle,
|
||||
actorDisplayName: user.displayName,
|
||||
actorAvatarUrl: user.avatarUrl,
|
||||
actorNodeDomain: null, // Local user
|
||||
postId,
|
||||
postContent: originalPost.content?.slice(0, 200) || null,
|
||||
type: 'repost',
|
||||
});
|
||||
|
||||
// Also notify bot owner if this is a bot's post
|
||||
const { notifyBotOwnerForPost } = await import('@/lib/notifications/botOwnerNotify');
|
||||
await notifyBotOwnerForPost(originalPost.userId, user.id, 'repost', postId);
|
||||
const postAuthor = await db.query.users.findFirst({
|
||||
where: eq(users.id, originalPost.userId),
|
||||
});
|
||||
if (postAuthor?.isBot && postAuthor.botOwnerId) {
|
||||
await db.insert(notifications).values({
|
||||
userId: postAuthor.botOwnerId,
|
||||
actorId: user.id,
|
||||
actorHandle: user.handle,
|
||||
actorDisplayName: user.displayName,
|
||||
actorAvatarUrl: user.avatarUrl,
|
||||
actorNodeDomain: null,
|
||||
postId,
|
||||
postContent: originalPost.content?.slice(0, 200) || null,
|
||||
type: 'repost',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// SWARM-FIRST: Deliver repost to swarm node
|
||||
|
||||
@@ -152,17 +152,33 @@ export async function POST(request: Request) {
|
||||
});
|
||||
|
||||
if (mentionedUser && mentionedUser.id !== user.id && !mentionedUser.isSuspended) {
|
||||
// Create notification for the mentioned user
|
||||
// Create notification for the mentioned user with actor info stored directly
|
||||
await db.insert(notifications).values({
|
||||
userId: mentionedUser.id,
|
||||
actorId: user.id,
|
||||
actorHandle: user.handle,
|
||||
actorDisplayName: user.displayName,
|
||||
actorAvatarUrl: user.avatarUrl,
|
||||
actorNodeDomain: null, // Local user
|
||||
postId: post.id,
|
||||
postContent: post.content?.slice(0, 200) || null,
|
||||
type: 'mention',
|
||||
});
|
||||
|
||||
// Also notify bot owner if this is a bot being mentioned
|
||||
const { notifyBotOwnerForPost } = await import('@/lib/notifications/botOwnerNotify');
|
||||
await notifyBotOwnerForPost(mentionedUser.id, user.id, 'mention', post.id);
|
||||
if (mentionedUser.isBot && mentionedUser.botOwnerId) {
|
||||
await db.insert(notifications).values({
|
||||
userId: mentionedUser.botOwnerId,
|
||||
actorId: user.id,
|
||||
actorHandle: user.handle,
|
||||
actorDisplayName: user.displayName,
|
||||
actorAvatarUrl: user.avatarUrl,
|
||||
actorNodeDomain: null,
|
||||
postId: post.id,
|
||||
postContent: post.content?.slice(0, 200) || null,
|
||||
type: 'mention',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
@@ -53,7 +53,6 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
// Construct the remote follower's actor URL (swarm-style)
|
||||
const remoteHandle = `${data.follow.followerHandle}@${data.follow.followerNodeDomain}`;
|
||||
const actorUrl = `swarm://${data.follow.followerNodeDomain}/${data.follow.followerHandle}`;
|
||||
const inboxUrl = `https://${data.follow.followerNodeDomain}/api/swarm/interactions/inbox`;
|
||||
|
||||
@@ -77,7 +76,7 @@ export async function POST(request: NextRequest) {
|
||||
userId: targetUser.id,
|
||||
actorUrl,
|
||||
inboxUrl,
|
||||
handle: remoteHandle,
|
||||
handle: `${data.follow.followerHandle}@${data.follow.followerNodeDomain}`,
|
||||
activityId: data.follow.interactionId,
|
||||
});
|
||||
|
||||
@@ -86,40 +85,38 @@ export async function POST(request: NextRequest) {
|
||||
.set({ followersCount: targetUser.followersCount + 1 })
|
||||
.where(eq(users.id, targetUser.id));
|
||||
|
||||
// Get or create placeholder user for the remote follower (for notifications)
|
||||
let remoteUser = await db.query.users.findFirst({
|
||||
where: eq(users.handle, remoteHandle),
|
||||
});
|
||||
|
||||
if (!remoteUser) {
|
||||
const [newUser] = await db.insert(users).values({
|
||||
did: `did:swarm:${data.follow.followerNodeDomain}:${data.follow.followerHandle}`,
|
||||
handle: remoteHandle,
|
||||
displayName: data.follow.followerDisplayName,
|
||||
avatarUrl: data.follow.followerAvatarUrl || null,
|
||||
bio: data.follow.followerBio || null,
|
||||
publicKey: 'swarm-remote-user',
|
||||
}).returning();
|
||||
remoteUser = newUser;
|
||||
}
|
||||
|
||||
// Create notification
|
||||
// Create notification with actor info stored directly
|
||||
try {
|
||||
await db.insert(notifications).values({
|
||||
userId: targetUser.id,
|
||||
actorId: remoteUser.id,
|
||||
actorHandle: data.follow.followerHandle,
|
||||
actorDisplayName: data.follow.followerDisplayName,
|
||||
actorAvatarUrl: data.follow.followerAvatarUrl || null,
|
||||
actorNodeDomain: data.follow.followerNodeDomain,
|
||||
type: 'follow',
|
||||
});
|
||||
console.log(`[Swarm] Created follow notification for @${data.targetHandle} from ${remoteHandle}`);
|
||||
console.log(`[Swarm] Created follow notification for @${data.targetHandle} from ${data.follow.followerHandle}@${data.follow.followerNodeDomain}`);
|
||||
} catch (notifError) {
|
||||
console.error(`[Swarm] Failed to create notification:`, notifError);
|
||||
}
|
||||
|
||||
// Also notify bot owner if this is a bot being followed
|
||||
const { notifyBotOwnerForFollow } = await import('@/lib/notifications/botOwnerNotify');
|
||||
await notifyBotOwnerForFollow(targetUser.id, remoteUser.id);
|
||||
if (targetUser.isBot && targetUser.botOwnerId) {
|
||||
try {
|
||||
await db.insert(notifications).values({
|
||||
userId: targetUser.botOwnerId,
|
||||
actorHandle: data.follow.followerHandle,
|
||||
actorDisplayName: data.follow.followerDisplayName,
|
||||
actorAvatarUrl: data.follow.followerAvatarUrl || null,
|
||||
actorNodeDomain: data.follow.followerNodeDomain,
|
||||
type: 'follow',
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[Swarm] Failed to notify bot owner:', err);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[Swarm] Received follow from ${remoteHandle} for @${data.targetHandle}`);
|
||||
console.log(`[Swarm] Received follow from ${data.follow.followerHandle}@${data.follow.followerNodeDomain} for @${data.targetHandle}`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
|
||||
@@ -2,9 +2,6 @@
|
||||
* Swarm Like Endpoint
|
||||
*
|
||||
* POST: Receive a like from another swarm node
|
||||
*
|
||||
* This is the swarm-first approach - direct node-to-node communication
|
||||
* for likes, bypassing ActivityPub for Synapsis nodes.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
@@ -57,43 +54,43 @@ export async function POST(request: NextRequest) {
|
||||
.set({ likesCount: post.likesCount + 1 })
|
||||
.where(eq(posts.id, data.postId));
|
||||
|
||||
// Create a notification for the post author
|
||||
// First, get or create a placeholder user for the remote liker
|
||||
const remoteHandle = `${data.like.actorHandle}@${data.like.actorNodeDomain}`;
|
||||
let remoteUser = await db.query.users.findFirst({
|
||||
where: eq(users.handle, remoteHandle),
|
||||
});
|
||||
|
||||
if (!remoteUser) {
|
||||
// Create a placeholder user for the remote actor
|
||||
const [newUser] = await db.insert(users).values({
|
||||
did: `did:swarm:${data.like.actorNodeDomain}:${data.like.actorHandle}`,
|
||||
handle: remoteHandle,
|
||||
displayName: data.like.actorDisplayName,
|
||||
avatarUrl: data.like.actorAvatarUrl || null,
|
||||
publicKey: 'swarm-remote-user',
|
||||
}).returning();
|
||||
remoteUser = newUser;
|
||||
}
|
||||
|
||||
// Create notification
|
||||
// Create notification with actor info stored directly
|
||||
try {
|
||||
await db.insert(notifications).values({
|
||||
userId: post.userId,
|
||||
actorId: remoteUser.id,
|
||||
actorHandle: data.like.actorHandle,
|
||||
actorDisplayName: data.like.actorDisplayName,
|
||||
actorAvatarUrl: data.like.actorAvatarUrl || null,
|
||||
actorNodeDomain: data.like.actorNodeDomain,
|
||||
postId: data.postId,
|
||||
postContent: post.content?.slice(0, 200) || null,
|
||||
type: 'like',
|
||||
});
|
||||
console.log(`[Swarm] Created like notification for post ${data.postId} from ${remoteHandle}`);
|
||||
console.log(`[Swarm] Created like notification for post ${data.postId} from ${data.like.actorHandle}@${data.like.actorNodeDomain}`);
|
||||
} catch (notifError) {
|
||||
console.error(`[Swarm] Failed to create like notification:`, notifError);
|
||||
}
|
||||
|
||||
// Also notify bot owner if this is a bot's post
|
||||
const { notifyBotOwnerForPost } = await import('@/lib/notifications/botOwnerNotify');
|
||||
await notifyBotOwnerForPost(post.userId, remoteUser.id, 'like', data.postId);
|
||||
const author = post.author as { isBot?: boolean; botOwnerId?: string } | null;
|
||||
if (author?.isBot && author.botOwnerId) {
|
||||
try {
|
||||
await db.insert(notifications).values({
|
||||
userId: author.botOwnerId,
|
||||
actorHandle: data.like.actorHandle,
|
||||
actorDisplayName: data.like.actorDisplayName,
|
||||
actorAvatarUrl: data.like.actorAvatarUrl || null,
|
||||
actorNodeDomain: data.like.actorNodeDomain,
|
||||
postId: data.postId,
|
||||
postContent: post.content?.slice(0, 200) || null,
|
||||
type: 'like',
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[Swarm] Failed to notify bot owner:', err);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[Swarm] Received like from ${remoteHandle} on post ${data.postId}`);
|
||||
console.log(`[Swarm] Received like from ${data.like.actorHandle}@${data.like.actorNodeDomain} on post ${data.postId}`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db, users, notifications, posts } from '@/db';
|
||||
import { db, users, notifications } from '@/db';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
|
||||
@@ -50,55 +50,40 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Get or create placeholder user for the remote actor
|
||||
const remoteHandle = `${data.mention.actorHandle}@${data.mention.actorNodeDomain}`;
|
||||
let remoteUser = await db.query.users.findFirst({
|
||||
where: eq(users.handle, remoteHandle),
|
||||
});
|
||||
|
||||
if (!remoteUser) {
|
||||
const [newUser] = await db.insert(users).values({
|
||||
did: `did:swarm:${data.mention.actorNodeDomain}:${data.mention.actorHandle}`,
|
||||
handle: remoteHandle,
|
||||
displayName: data.mention.actorDisplayName,
|
||||
avatarUrl: data.mention.actorAvatarUrl || null,
|
||||
publicKey: 'swarm-remote-user',
|
||||
}).returning();
|
||||
remoteUser = newUser;
|
||||
// Create notification with actor info stored directly
|
||||
try {
|
||||
await db.insert(notifications).values({
|
||||
userId: mentionedUser.id,
|
||||
actorHandle: data.mention.actorHandle,
|
||||
actorDisplayName: data.mention.actorDisplayName,
|
||||
actorAvatarUrl: data.mention.actorAvatarUrl || null,
|
||||
actorNodeDomain: data.mention.actorNodeDomain,
|
||||
postContent: data.mention.postContent.slice(0, 200),
|
||||
type: 'mention',
|
||||
});
|
||||
console.log(`[Swarm] Created mention notification for @${data.mentionedHandle} from ${data.mention.actorHandle}@${data.mention.actorNodeDomain}`);
|
||||
} catch (notifError) {
|
||||
console.error(`[Swarm] Failed to create mention notification:`, notifError);
|
||||
}
|
||||
|
||||
// Check if we already have this post cached (from swarm timeline)
|
||||
// If not, create a placeholder post for the notification
|
||||
const swarmPostId = `swarm:${data.mention.actorNodeDomain}:${data.mention.postId}`;
|
||||
let post = await db.query.posts.findFirst({
|
||||
where: eq(posts.apId, swarmPostId),
|
||||
});
|
||||
|
||||
if (!post) {
|
||||
// Create a placeholder post for the mention
|
||||
const [newPost] = await db.insert(posts).values({
|
||||
userId: remoteUser.id,
|
||||
content: data.mention.postContent,
|
||||
apId: swarmPostId,
|
||||
apUrl: `https://${data.mention.actorNodeDomain}/${data.mention.actorHandle}/posts/${data.mention.postId}`,
|
||||
createdAt: new Date(data.mention.timestamp),
|
||||
}).returning();
|
||||
post = newPost;
|
||||
}
|
||||
|
||||
// Create notification
|
||||
await db.insert(notifications).values({
|
||||
userId: mentionedUser.id,
|
||||
actorId: remoteUser.id,
|
||||
postId: post.id,
|
||||
type: 'mention',
|
||||
});
|
||||
|
||||
// Also notify bot owner if this is a bot being mentioned
|
||||
const { notifyBotOwnerForPost } = await import('@/lib/notifications/botOwnerNotify');
|
||||
await notifyBotOwnerForPost(mentionedUser.id, remoteUser.id, 'mention', post.id);
|
||||
if (mentionedUser.isBot && mentionedUser.botOwnerId) {
|
||||
try {
|
||||
await db.insert(notifications).values({
|
||||
userId: mentionedUser.botOwnerId,
|
||||
actorHandle: data.mention.actorHandle,
|
||||
actorDisplayName: data.mention.actorDisplayName,
|
||||
actorAvatarUrl: data.mention.actorAvatarUrl || null,
|
||||
actorNodeDomain: data.mention.actorNodeDomain,
|
||||
postContent: data.mention.postContent.slice(0, 200),
|
||||
type: 'mention',
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[Swarm] Failed to notify bot owner:', err);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[Swarm] Received mention from ${remoteHandle} for @${data.mentionedHandle}`);
|
||||
console.log(`[Swarm] Received mention from ${data.mention.actorHandle}@${data.mention.actorNodeDomain} for @${data.mentionedHandle}`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
|
||||
@@ -16,7 +16,7 @@ const swarmRepostSchema = z.object({
|
||||
actorDisplayName: z.string(),
|
||||
actorAvatarUrl: z.string().optional(),
|
||||
actorNodeDomain: z.string(),
|
||||
repostId: z.string(), // The ID of the repost on the actor's node
|
||||
repostId: z.string(),
|
||||
interactionId: z.string(),
|
||||
timestamp: z.string(),
|
||||
}),
|
||||
@@ -39,6 +39,7 @@ export async function POST(request: NextRequest) {
|
||||
// Find the target post
|
||||
const post = await db.query.posts.findFirst({
|
||||
where: eq(posts.id, data.postId),
|
||||
with: { author: true },
|
||||
});
|
||||
|
||||
if (!post) {
|
||||
@@ -54,41 +55,43 @@ export async function POST(request: NextRequest) {
|
||||
.set({ repostsCount: post.repostsCount + 1 })
|
||||
.where(eq(posts.id, data.postId));
|
||||
|
||||
// Get or create placeholder user for the remote reposter
|
||||
const remoteHandle = `${data.repost.actorHandle}@${data.repost.actorNodeDomain}`;
|
||||
let remoteUser = await db.query.users.findFirst({
|
||||
where: eq(users.handle, remoteHandle),
|
||||
});
|
||||
|
||||
if (!remoteUser) {
|
||||
const [newUser] = await db.insert(users).values({
|
||||
did: `did:swarm:${data.repost.actorNodeDomain}:${data.repost.actorHandle}`,
|
||||
handle: remoteHandle,
|
||||
displayName: data.repost.actorDisplayName,
|
||||
avatarUrl: data.repost.actorAvatarUrl || null,
|
||||
publicKey: 'swarm-remote-user',
|
||||
}).returning();
|
||||
remoteUser = newUser;
|
||||
}
|
||||
|
||||
// Create notification
|
||||
// Create notification with actor info stored directly
|
||||
try {
|
||||
await db.insert(notifications).values({
|
||||
userId: post.userId,
|
||||
actorId: remoteUser.id,
|
||||
actorHandle: data.repost.actorHandle,
|
||||
actorDisplayName: data.repost.actorDisplayName,
|
||||
actorAvatarUrl: data.repost.actorAvatarUrl || null,
|
||||
actorNodeDomain: data.repost.actorNodeDomain,
|
||||
postId: data.postId,
|
||||
postContent: post.content?.slice(0, 200) || null,
|
||||
type: 'repost',
|
||||
});
|
||||
console.log(`[Swarm] Created repost notification for post ${data.postId} from ${remoteHandle}`);
|
||||
console.log(`[Swarm] Created repost notification for post ${data.postId} from ${data.repost.actorHandle}@${data.repost.actorNodeDomain}`);
|
||||
} catch (notifError) {
|
||||
console.error(`[Swarm] Failed to create repost notification:`, notifError);
|
||||
}
|
||||
|
||||
// Also notify bot owner if this is a bot's post
|
||||
const { notifyBotOwnerForPost } = await import('@/lib/notifications/botOwnerNotify');
|
||||
await notifyBotOwnerForPost(post.userId, remoteUser.id, 'repost', data.postId);
|
||||
const author = post.author as { isBot?: boolean; botOwnerId?: string } | null;
|
||||
if (author?.isBot && author.botOwnerId) {
|
||||
try {
|
||||
await db.insert(notifications).values({
|
||||
userId: author.botOwnerId,
|
||||
actorHandle: data.repost.actorHandle,
|
||||
actorDisplayName: data.repost.actorDisplayName,
|
||||
actorAvatarUrl: data.repost.actorAvatarUrl || null,
|
||||
actorNodeDomain: data.repost.actorNodeDomain,
|
||||
postId: data.postId,
|
||||
postContent: post.content?.slice(0, 200) || null,
|
||||
type: 'repost',
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[Swarm] Failed to notify bot owner:', err);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[Swarm] Received repost from ${remoteHandle} on post ${data.postId}`);
|
||||
console.log(`[Swarm] Received repost from ${data.repost.actorHandle}@${data.repost.actorNodeDomain} on post ${data.postId}`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
|
||||
@@ -263,15 +263,29 @@ export async function POST(request: Request, context: RouteContext) {
|
||||
});
|
||||
|
||||
if (currentUser.id !== targetUser.id) {
|
||||
// Create notification with actor info stored directly
|
||||
await db.insert(notifications).values({
|
||||
userId: targetUser.id,
|
||||
actorId: currentUser.id,
|
||||
actorHandle: currentUser.handle,
|
||||
actorDisplayName: currentUser.displayName,
|
||||
actorAvatarUrl: currentUser.avatarUrl,
|
||||
actorNodeDomain: null, // Local user
|
||||
type: 'follow',
|
||||
});
|
||||
|
||||
// Also notify bot owner if this is a bot being followed
|
||||
const { notifyBotOwnerForFollow } = await import('@/lib/notifications/botOwnerNotify');
|
||||
await notifyBotOwnerForFollow(targetUser.id, currentUser.id);
|
||||
if (targetUser.isBot && targetUser.botOwnerId) {
|
||||
await db.insert(notifications).values({
|
||||
userId: targetUser.botOwnerId,
|
||||
actorId: currentUser.id,
|
||||
actorHandle: currentUser.handle,
|
||||
actorDisplayName: currentUser.displayName,
|
||||
actorAvatarUrl: currentUser.avatarUrl,
|
||||
actorNodeDomain: null,
|
||||
type: 'follow',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Update counts
|
||||
|
||||
@@ -12,6 +12,7 @@ export function Sidebar() {
|
||||
const { user, isAdmin } = useAuth();
|
||||
const pathname = usePathname();
|
||||
const [customLogoUrl, setCustomLogoUrl] = useState<string | null | undefined>(undefined);
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/node')
|
||||
@@ -24,6 +25,25 @@ export function Sidebar() {
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Fetch unread notification count
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
|
||||
const fetchUnread = () => {
|
||||
fetch('/api/notifications?unread=true&limit=50')
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
setUnreadCount(data.notifications?.length || 0);
|
||||
})
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
fetchUnread();
|
||||
// Poll every 30 seconds
|
||||
const interval = setInterval(fetchUnread, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [user]);
|
||||
|
||||
// Home is exact match
|
||||
const isHome = pathname === '/';
|
||||
|
||||
@@ -46,9 +66,20 @@ export function Sidebar() {
|
||||
<span>Explore</span>
|
||||
</Link>
|
||||
{user && (
|
||||
<Link href="/notifications" className={`nav-item ${pathname?.startsWith('/notifications') ? 'active' : ''}`}>
|
||||
<Link href="/notifications" className={`nav-item ${pathname?.startsWith('/notifications') ? 'active' : ''}`} style={{ position: 'relative' }}>
|
||||
<BellIcon />
|
||||
<span>Notifications</span>
|
||||
{unreadCount > 0 && (
|
||||
<span style={{
|
||||
position: 'absolute',
|
||||
top: '8px',
|
||||
left: '24px',
|
||||
width: '8px',
|
||||
height: '8px',
|
||||
background: 'var(--error)',
|
||||
borderRadius: '50%',
|
||||
}} />
|
||||
)}
|
||||
</Link>
|
||||
)}
|
||||
{user && (
|
||||
|
||||
+8
-1
@@ -325,8 +325,15 @@ export const likesRelations = relations(likes, ({ one }) => ({
|
||||
export const notifications = pgTable('notifications', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||
actorId: uuid('actor_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||
// Actor info - stored directly instead of referencing placeholder users
|
||||
actorId: uuid('actor_id').references(() => users.id, { onDelete: 'cascade' }), // Optional - only for local actors
|
||||
actorHandle: text('actor_handle').notNull(), // e.g., "user" or "user@remote.node"
|
||||
actorDisplayName: text('actor_display_name'),
|
||||
actorAvatarUrl: text('actor_avatar_url'),
|
||||
actorNodeDomain: text('actor_node_domain'), // null for local actors
|
||||
// Post reference
|
||||
postId: uuid('post_id').references(() => posts.id, { onDelete: 'cascade' }),
|
||||
postContent: text('post_content'), // Cached content for display
|
||||
type: text('type').notNull(), // follow | like | repost | mention
|
||||
readAt: timestamp('read_at'),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
/**
|
||||
* Bot Owner Notification Helper
|
||||
*
|
||||
* When someone interacts with a bot (likes, reposts, follows, mentions),
|
||||
* this helper creates a notification for the bot's owner so they can
|
||||
* see engagement on their bots.
|
||||
*/
|
||||
|
||||
import { db, notifications, users } from '@/db';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
export type BotInteractionType = 'like' | 'repost' | 'follow' | 'mention';
|
||||
|
||||
/**
|
||||
* Check if a user is a bot and get their owner's ID.
|
||||
*
|
||||
* @param userId - The user ID to check
|
||||
* @returns The bot owner's ID if this is a bot, null otherwise
|
||||
*/
|
||||
export async function getBotOwnerId(userId: string): Promise<string | null> {
|
||||
const user = await db.query.users.findFirst({
|
||||
where: eq(users.id, userId),
|
||||
columns: {
|
||||
isBot: true,
|
||||
botOwnerId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (user?.isBot && user.botOwnerId) {
|
||||
return user.botOwnerId;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a notification for a bot's owner when someone interacts with the bot.
|
||||
*
|
||||
* This is called in addition to the normal notification (which goes to the bot's
|
||||
* user account). The owner gets notified so they can see engagement on their bots.
|
||||
*
|
||||
* @param botUserId - The bot's user ID (the one receiving the interaction)
|
||||
* @param actorId - The user who performed the interaction
|
||||
* @param type - The type of interaction
|
||||
* @param postId - Optional post ID (for likes, reposts, mentions)
|
||||
* @returns True if a notification was created, false otherwise
|
||||
*/
|
||||
export async function notifyBotOwner(
|
||||
botUserId: string,
|
||||
actorId: string,
|
||||
type: BotInteractionType,
|
||||
postId?: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const ownerId = await getBotOwnerId(botUserId);
|
||||
|
||||
if (!ownerId) {
|
||||
// Not a bot, no owner to notify
|
||||
return false;
|
||||
}
|
||||
|
||||
// Don't notify owner if they're the one doing the interaction
|
||||
if (ownerId === actorId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Create notification for the bot owner
|
||||
await db.insert(notifications).values({
|
||||
userId: ownerId,
|
||||
actorId,
|
||||
postId: postId || null,
|
||||
type,
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[BotOwnerNotify] Error creating notification:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify bot owner about an interaction on a bot's post.
|
||||
*
|
||||
* Checks if the post author is a bot and notifies the owner.
|
||||
*
|
||||
* @param postAuthorId - The post author's user ID
|
||||
* @param actorId - The user who performed the interaction
|
||||
* @param type - The type of interaction (like, repost, mention)
|
||||
* @param postId - The post ID
|
||||
*/
|
||||
export async function notifyBotOwnerForPost(
|
||||
postAuthorId: string,
|
||||
actorId: string,
|
||||
type: 'like' | 'repost' | 'mention',
|
||||
postId: string
|
||||
): Promise<boolean> {
|
||||
return notifyBotOwner(postAuthorId, actorId, type, postId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify bot owner about a new follower.
|
||||
*
|
||||
* @param botUserId - The bot's user ID being followed
|
||||
* @param followerId - The user who followed the bot
|
||||
*/
|
||||
export async function notifyBotOwnerForFollow(
|
||||
botUserId: string,
|
||||
followerId: string
|
||||
): Promise<boolean> {
|
||||
return notifyBotOwner(botUserId, followerId, 'follow');
|
||||
}
|
||||
Reference in New Issue
Block a user