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,
|
"when": 1769422771465,
|
||||||
"tag": "0005_yummy_millenium_guard",
|
"tag": "0005_yummy_millenium_guard",
|
||||||
"breakpoints": true
|
"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(),
|
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) {
|
export async function GET(request: Request) {
|
||||||
try {
|
try {
|
||||||
const user = await requireAuth();
|
const user = await requireAuth();
|
||||||
@@ -28,34 +52,56 @@ export async function GET(request: Request) {
|
|||||||
|
|
||||||
const rows = await db.query.notifications.findMany({
|
const rows = await db.query.notifications.findMany({
|
||||||
where: and(...conditions),
|
where: and(...conditions),
|
||||||
with: {
|
|
||||||
actor: true,
|
|
||||||
post: true,
|
|
||||||
},
|
|
||||||
orderBy: [desc(notifications.createdAt)],
|
orderBy: [desc(notifications.createdAt)],
|
||||||
limit,
|
limit,
|
||||||
});
|
});
|
||||||
|
|
||||||
type ActorInfo = { id: string; handle: string; displayName: string | null; avatarUrl: string | null };
|
// For remote actors missing avatar, fetch fresh data
|
||||||
type PostInfo = { id: string; content: string };
|
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 payload = rows.map((row) => {
|
||||||
const actor = row.actor as ActorInfo | null;
|
const key = row.actorNodeDomain ? `${row.actorHandle}@${row.actorNodeDomain}` : null;
|
||||||
const post = row.post as PostInfo | null;
|
const freshProfile = key ? freshProfiles.get(key) : null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
type: row.type,
|
type: row.type,
|
||||||
createdAt: row.createdAt,
|
createdAt: row.createdAt,
|
||||||
readAt: row.readAt,
|
readAt: row.readAt,
|
||||||
actor: actor ? {
|
actor: {
|
||||||
id: actor.id,
|
handle: row.actorNodeDomain
|
||||||
handle: actor.handle,
|
? `${row.actorHandle}@${row.actorNodeDomain}`
|
||||||
displayName: actor.displayName,
|
: row.actorHandle,
|
||||||
avatarUrl: actor.avatarUrl,
|
displayName: freshProfile?.displayName || row.actorDisplayName,
|
||||||
} : null,
|
avatarUrl: freshProfile?.avatarUrl || row.actorAvatarUrl,
|
||||||
post: post ? {
|
nodeDomain: row.actorNodeDomain,
|
||||||
id: post.id,
|
},
|
||||||
content: post.content,
|
post: row.postId ? {
|
||||||
|
id: row.postId,
|
||||||
|
content: row.postContent,
|
||||||
} : null,
|
} : null,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -77,16 +77,36 @@ export async function POST(request: Request, context: RouteContext) {
|
|||||||
.where(eq(posts.id, postId));
|
.where(eq(posts.id, postId));
|
||||||
|
|
||||||
if (post.userId !== user.id) {
|
if (post.userId !== user.id) {
|
||||||
|
// Create notification with actor info stored directly
|
||||||
await db.insert(notifications).values({
|
await db.insert(notifications).values({
|
||||||
userId: post.userId,
|
userId: post.userId,
|
||||||
actorId: user.id,
|
actorId: user.id,
|
||||||
|
actorHandle: user.handle,
|
||||||
|
actorDisplayName: user.displayName,
|
||||||
|
actorAvatarUrl: user.avatarUrl,
|
||||||
|
actorNodeDomain: null, // Local user
|
||||||
postId,
|
postId,
|
||||||
|
postContent: post.content?.slice(0, 200) || null,
|
||||||
type: 'like',
|
type: 'like',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Also notify bot owner if this is a bot's post
|
// Also notify bot owner if this is a bot's post
|
||||||
const { notifyBotOwnerForPost } = await import('@/lib/notifications/botOwnerNotify');
|
const postAuthor = await db.query.users.findFirst({
|
||||||
await notifyBotOwnerForPost(post.userId, user.id, 'like', postId);
|
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
|
// 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));
|
.where(eq(users.id, user.id));
|
||||||
|
|
||||||
if (originalPost.userId !== user.id) {
|
if (originalPost.userId !== user.id) {
|
||||||
|
// Create notification with actor info stored directly
|
||||||
await db.insert(notifications).values({
|
await db.insert(notifications).values({
|
||||||
userId: originalPost.userId,
|
userId: originalPost.userId,
|
||||||
actorId: user.id,
|
actorId: user.id,
|
||||||
|
actorHandle: user.handle,
|
||||||
|
actorDisplayName: user.displayName,
|
||||||
|
actorAvatarUrl: user.avatarUrl,
|
||||||
|
actorNodeDomain: null, // Local user
|
||||||
postId,
|
postId,
|
||||||
|
postContent: originalPost.content?.slice(0, 200) || null,
|
||||||
type: 'repost',
|
type: 'repost',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Also notify bot owner if this is a bot's post
|
// Also notify bot owner if this is a bot's post
|
||||||
const { notifyBotOwnerForPost } = await import('@/lib/notifications/botOwnerNotify');
|
const postAuthor = await db.query.users.findFirst({
|
||||||
await notifyBotOwnerForPost(originalPost.userId, user.id, 'repost', postId);
|
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
|
// 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) {
|
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({
|
await db.insert(notifications).values({
|
||||||
userId: mentionedUser.id,
|
userId: mentionedUser.id,
|
||||||
actorId: user.id,
|
actorId: user.id,
|
||||||
|
actorHandle: user.handle,
|
||||||
|
actorDisplayName: user.displayName,
|
||||||
|
actorAvatarUrl: user.avatarUrl,
|
||||||
|
actorNodeDomain: null, // Local user
|
||||||
postId: post.id,
|
postId: post.id,
|
||||||
|
postContent: post.content?.slice(0, 200) || null,
|
||||||
type: 'mention',
|
type: 'mention',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Also notify bot owner if this is a bot being mentioned
|
// Also notify bot owner if this is a bot being mentioned
|
||||||
const { notifyBotOwnerForPost } = await import('@/lib/notifications/botOwnerNotify');
|
if (mentionedUser.isBot && mentionedUser.botOwnerId) {
|
||||||
await notifyBotOwnerForPost(mentionedUser.id, user.id, 'mention', post.id);
|
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) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -53,7 +53,6 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Construct the remote follower's actor URL (swarm-style)
|
// 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 actorUrl = `swarm://${data.follow.followerNodeDomain}/${data.follow.followerHandle}`;
|
||||||
const inboxUrl = `https://${data.follow.followerNodeDomain}/api/swarm/interactions/inbox`;
|
const inboxUrl = `https://${data.follow.followerNodeDomain}/api/swarm/interactions/inbox`;
|
||||||
|
|
||||||
@@ -77,7 +76,7 @@ export async function POST(request: NextRequest) {
|
|||||||
userId: targetUser.id,
|
userId: targetUser.id,
|
||||||
actorUrl,
|
actorUrl,
|
||||||
inboxUrl,
|
inboxUrl,
|
||||||
handle: remoteHandle,
|
handle: `${data.follow.followerHandle}@${data.follow.followerNodeDomain}`,
|
||||||
activityId: data.follow.interactionId,
|
activityId: data.follow.interactionId,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -86,40 +85,38 @@ export async function POST(request: NextRequest) {
|
|||||||
.set({ followersCount: targetUser.followersCount + 1 })
|
.set({ followersCount: targetUser.followersCount + 1 })
|
||||||
.where(eq(users.id, targetUser.id));
|
.where(eq(users.id, targetUser.id));
|
||||||
|
|
||||||
// Get or create placeholder user for the remote follower (for notifications)
|
// Create notification with actor info stored directly
|
||||||
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
|
|
||||||
try {
|
try {
|
||||||
await db.insert(notifications).values({
|
await db.insert(notifications).values({
|
||||||
userId: targetUser.id,
|
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',
|
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) {
|
} catch (notifError) {
|
||||||
console.error(`[Swarm] Failed to create notification:`, notifError);
|
console.error(`[Swarm] Failed to create notification:`, notifError);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Also notify bot owner if this is a bot being followed
|
// Also notify bot owner if this is a bot being followed
|
||||||
const { notifyBotOwnerForFollow } = await import('@/lib/notifications/botOwnerNotify');
|
if (targetUser.isBot && targetUser.botOwnerId) {
|
||||||
await notifyBotOwnerForFollow(targetUser.id, remoteUser.id);
|
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({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -2,9 +2,6 @@
|
|||||||
* Swarm Like Endpoint
|
* Swarm Like Endpoint
|
||||||
*
|
*
|
||||||
* POST: Receive a like from another swarm node
|
* 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';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
@@ -57,43 +54,43 @@ export async function POST(request: NextRequest) {
|
|||||||
.set({ likesCount: post.likesCount + 1 })
|
.set({ likesCount: post.likesCount + 1 })
|
||||||
.where(eq(posts.id, data.postId));
|
.where(eq(posts.id, data.postId));
|
||||||
|
|
||||||
// Create a notification for the post author
|
// Create notification with actor info stored directly
|
||||||
// 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
|
|
||||||
try {
|
try {
|
||||||
await db.insert(notifications).values({
|
await db.insert(notifications).values({
|
||||||
userId: post.userId,
|
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,
|
postId: data.postId,
|
||||||
|
postContent: post.content?.slice(0, 200) || null,
|
||||||
type: 'like',
|
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) {
|
} catch (notifError) {
|
||||||
console.error(`[Swarm] Failed to create like notification:`, notifError);
|
console.error(`[Swarm] Failed to create like notification:`, notifError);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Also notify bot owner if this is a bot's post
|
// Also notify bot owner if this is a bot's post
|
||||||
const { notifyBotOwnerForPost } = await import('@/lib/notifications/botOwnerNotify');
|
const author = post.author as { isBot?: boolean; botOwnerId?: string } | null;
|
||||||
await notifyBotOwnerForPost(post.userId, remoteUser.id, 'like', data.postId);
|
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({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
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 { eq } from 'drizzle-orm';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
@@ -50,55 +50,40 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get or create placeholder user for the remote actor
|
// Create notification with actor info stored directly
|
||||||
const remoteHandle = `${data.mention.actorHandle}@${data.mention.actorNodeDomain}`;
|
try {
|
||||||
let remoteUser = await db.query.users.findFirst({
|
await db.insert(notifications).values({
|
||||||
where: eq(users.handle, remoteHandle),
|
userId: mentionedUser.id,
|
||||||
});
|
actorHandle: data.mention.actorHandle,
|
||||||
|
actorDisplayName: data.mention.actorDisplayName,
|
||||||
if (!remoteUser) {
|
actorAvatarUrl: data.mention.actorAvatarUrl || null,
|
||||||
const [newUser] = await db.insert(users).values({
|
actorNodeDomain: data.mention.actorNodeDomain,
|
||||||
did: `did:swarm:${data.mention.actorNodeDomain}:${data.mention.actorHandle}`,
|
postContent: data.mention.postContent.slice(0, 200),
|
||||||
handle: remoteHandle,
|
type: 'mention',
|
||||||
displayName: data.mention.actorDisplayName,
|
});
|
||||||
avatarUrl: data.mention.actorAvatarUrl || null,
|
console.log(`[Swarm] Created mention notification for @${data.mentionedHandle} from ${data.mention.actorHandle}@${data.mention.actorNodeDomain}`);
|
||||||
publicKey: 'swarm-remote-user',
|
} catch (notifError) {
|
||||||
}).returning();
|
console.error(`[Swarm] Failed to create mention notification:`, notifError);
|
||||||
remoteUser = newUser;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
// Also notify bot owner if this is a bot being mentioned
|
||||||
const { notifyBotOwnerForPost } = await import('@/lib/notifications/botOwnerNotify');
|
if (mentionedUser.isBot && mentionedUser.botOwnerId) {
|
||||||
await notifyBotOwnerForPost(mentionedUser.id, remoteUser.id, 'mention', post.id);
|
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({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ const swarmRepostSchema = z.object({
|
|||||||
actorDisplayName: z.string(),
|
actorDisplayName: z.string(),
|
||||||
actorAvatarUrl: z.string().optional(),
|
actorAvatarUrl: z.string().optional(),
|
||||||
actorNodeDomain: z.string(),
|
actorNodeDomain: z.string(),
|
||||||
repostId: z.string(), // The ID of the repost on the actor's node
|
repostId: z.string(),
|
||||||
interactionId: z.string(),
|
interactionId: z.string(),
|
||||||
timestamp: z.string(),
|
timestamp: z.string(),
|
||||||
}),
|
}),
|
||||||
@@ -39,6 +39,7 @@ export async function POST(request: NextRequest) {
|
|||||||
// Find the target post
|
// Find the target post
|
||||||
const post = await db.query.posts.findFirst({
|
const post = await db.query.posts.findFirst({
|
||||||
where: eq(posts.id, data.postId),
|
where: eq(posts.id, data.postId),
|
||||||
|
with: { author: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!post) {
|
if (!post) {
|
||||||
@@ -54,41 +55,43 @@ export async function POST(request: NextRequest) {
|
|||||||
.set({ repostsCount: post.repostsCount + 1 })
|
.set({ repostsCount: post.repostsCount + 1 })
|
||||||
.where(eq(posts.id, data.postId));
|
.where(eq(posts.id, data.postId));
|
||||||
|
|
||||||
// Get or create placeholder user for the remote reposter
|
// Create notification with actor info stored directly
|
||||||
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
|
|
||||||
try {
|
try {
|
||||||
await db.insert(notifications).values({
|
await db.insert(notifications).values({
|
||||||
userId: post.userId,
|
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,
|
postId: data.postId,
|
||||||
|
postContent: post.content?.slice(0, 200) || null,
|
||||||
type: 'repost',
|
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) {
|
} catch (notifError) {
|
||||||
console.error(`[Swarm] Failed to create repost notification:`, notifError);
|
console.error(`[Swarm] Failed to create repost notification:`, notifError);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Also notify bot owner if this is a bot's post
|
// Also notify bot owner if this is a bot's post
|
||||||
const { notifyBotOwnerForPost } = await import('@/lib/notifications/botOwnerNotify');
|
const author = post.author as { isBot?: boolean; botOwnerId?: string } | null;
|
||||||
await notifyBotOwnerForPost(post.userId, remoteUser.id, 'repost', data.postId);
|
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({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -263,15 +263,29 @@ export async function POST(request: Request, context: RouteContext) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (currentUser.id !== targetUser.id) {
|
if (currentUser.id !== targetUser.id) {
|
||||||
|
// Create notification with actor info stored directly
|
||||||
await db.insert(notifications).values({
|
await db.insert(notifications).values({
|
||||||
userId: targetUser.id,
|
userId: targetUser.id,
|
||||||
actorId: currentUser.id,
|
actorId: currentUser.id,
|
||||||
|
actorHandle: currentUser.handle,
|
||||||
|
actorDisplayName: currentUser.displayName,
|
||||||
|
actorAvatarUrl: currentUser.avatarUrl,
|
||||||
|
actorNodeDomain: null, // Local user
|
||||||
type: 'follow',
|
type: 'follow',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Also notify bot owner if this is a bot being followed
|
// Also notify bot owner if this is a bot being followed
|
||||||
const { notifyBotOwnerForFollow } = await import('@/lib/notifications/botOwnerNotify');
|
if (targetUser.isBot && targetUser.botOwnerId) {
|
||||||
await notifyBotOwnerForFollow(targetUser.id, currentUser.id);
|
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
|
// Update counts
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export function Sidebar() {
|
|||||||
const { user, isAdmin } = useAuth();
|
const { user, isAdmin } = useAuth();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const [customLogoUrl, setCustomLogoUrl] = useState<string | null | undefined>(undefined);
|
const [customLogoUrl, setCustomLogoUrl] = useState<string | null | undefined>(undefined);
|
||||||
|
const [unreadCount, setUnreadCount] = useState(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch('/api/node')
|
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
|
// Home is exact match
|
||||||
const isHome = pathname === '/';
|
const isHome = pathname === '/';
|
||||||
|
|
||||||
@@ -46,9 +66,20 @@ export function Sidebar() {
|
|||||||
<span>Explore</span>
|
<span>Explore</span>
|
||||||
</Link>
|
</Link>
|
||||||
{user && (
|
{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 />
|
<BellIcon />
|
||||||
<span>Notifications</span>
|
<span>Notifications</span>
|
||||||
|
{unreadCount > 0 && (
|
||||||
|
<span style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '8px',
|
||||||
|
left: '24px',
|
||||||
|
width: '8px',
|
||||||
|
height: '8px',
|
||||||
|
background: 'var(--error)',
|
||||||
|
borderRadius: '50%',
|
||||||
|
}} />
|
||||||
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
{user && (
|
{user && (
|
||||||
|
|||||||
+8
-1
@@ -325,8 +325,15 @@ export const likesRelations = relations(likes, ({ one }) => ({
|
|||||||
export const notifications = pgTable('notifications', {
|
export const notifications = pgTable('notifications', {
|
||||||
id: uuid('id').primaryKey().defaultRandom(),
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
userId: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
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' }),
|
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
|
type: text('type').notNull(), // follow | like | repost | mention
|
||||||
readAt: timestamp('read_at'),
|
readAt: timestamp('read_at'),
|
||||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
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