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:
Christomatt
2026-01-26 12:45:35 +01:00
parent 17c68a99bf
commit 12f515b7fb
14 changed files with 3935 additions and 263 deletions
+63 -17
View File
@@ -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,
};
});
+22 -2
View File
@@ -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
+22 -2
View File
@@ -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
+19 -3
View File
@@ -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) {
+22 -25
View File
@@ -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,
+25 -28
View File
@@ -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,
+31 -46
View File
@@ -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,
+27 -24
View File
@@ -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,
+16 -2
View File
@@ -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
+32 -1
View File
@@ -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
View File
@@ -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(),
-112
View File
@@ -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');
}