Files
backspace/packages/server/src/routes/social.ts
T
Jannis Braun bc408235c9 feat: add Activity type system, DB migration, and self-only showActivity in sanitizeUser
- Add Activity, ActivityType, ActivityTimestamps, ActivityAssets types to shared types
- Add activity_update client event and activities field on presence_update server event
- Add userActivities to ready payload and showActivity to User/UpdateUserRequest
- Create shared activities.ts with ACTIVITY_LIMITS, ACTIVITY_PRIORITY, getPrimaryActivity
- Add show_activity column to users table (schema + migration)
- Update sanitizeUser with isSelf parameter; only include showActivity for self
- Fix .map(sanitizeUser) calls to use arrow wrapper to prevent index-as-boolean bug
- Mark auth routes (register/login) as isSelf=true since they return own user data
2026-03-21 01:38:36 +01:00

508 lines
17 KiB
TypeScript

import type { FastifyInstance } from 'fastify';
import { eq, and, or, ne, like, sql, inArray } from 'drizzle-orm';
import { getDb, schema } from '../db/index.js';
import { authenticate } from '../utils/auth.js';
import { generateSnowflake } from '../utils/snowflake.js';
import { connectionManager } from '../ws/handler.js';
import type {
Friend,
FriendRequest,
SendFriendRequest,
UpdateFriendRequest,
DiscoverUser,
} from '@backspace/shared';
import { sanitizeUser } from '../utils/sanitize.js';
export async function socialRoutes(app: FastifyInstance): Promise<void> {
// GET /api/social/friends - List all friends
app.get('/api/social/friends', {
preHandler: authenticate,
}, async (request, reply) => {
const db = getDb();
// Get all friends where current user is either userId or friendId
const friendRows = db.select()
.from(schema.friends)
.where(or(
eq(schema.friends.userId, request.userId),
eq(schema.friends.friendId, request.userId)
))
.all();
if (friendRows.length === 0) {
return reply.code(200).send([]);
}
// Get the IDs of the actual friends (not the current user)
const friendIds = friendRows.map(f => f.userId === request.userId ? f.friendId : f.userId);
const friendUsers = db.select()
.from(schema.users)
.where(or(...friendIds.map(id => eq(schema.users.id, id))))
.all();
const friends: Friend[] = friendUsers.map(u => {
const relationship = friendRows.find(f => f.userId === u.id || f.friendId === u.id);
return {
...sanitizeUser(u),
addedAt: relationship?.createdAt ?? Date.now(),
};
});
return reply.code(200).send(friends);
});
// GET /api/social/requests - List pending friend requests
app.get('/api/social/requests', {
preHandler: authenticate,
}, async (request, reply) => {
const db = getDb();
const requests = db.select()
.from(schema.friendRequests)
.where(and(
or(
eq(schema.friendRequests.fromId, request.userId),
eq(schema.friendRequests.toId, request.userId)
),
eq(schema.friendRequests.status, 'pending')
))
.all();
if (requests.length === 0) {
return reply.code(200).send([]);
}
// Enhance with user data
const userIds = requests.map(r => r.fromId === request.userId ? r.toId : r.fromId);
const users = db.select()
.from(schema.users)
.where(or(...userIds.map(id => eq(schema.users.id, id))))
.all();
const userMap = new Map(users.map(u => [u.id, u]));
const result: FriendRequest[] = requests.map(r => {
const otherId = r.fromId === request.userId ? r.toId : r.fromId;
const otherUser = userMap.get(otherId);
return {
id: r.id,
fromId: r.fromId,
toId: r.toId,
status: (r.status ?? 'pending') as any,
createdAt: r.createdAt,
user: otherUser ? sanitizeUser(otherUser) : undefined,
};
});
return reply.code(200).send(result);
});
// POST /api/social/requests - Send a friend request
app.post<{ Body: SendFriendRequest }>('/api/social/requests', {
preHandler: authenticate,
}, async (request, reply) => {
const { username } = request.body;
const db = getDb();
if (!username) {
return reply.code(400).send({ error: 'Username is required', statusCode: 400 });
}
// Find the target user
const targetUser = db.select().from(schema.users).where(eq(schema.users.username, username)).get();
if (!targetUser) {
return reply.code(404).send({ error: 'User not found', statusCode: 404 });
}
if (targetUser.id === request.userId) {
return reply.code(400).send({ error: 'You cannot add yourself as a friend', statusCode: 400 });
}
// Check if already friends
const existingFriend = db.select().from(schema.friends).where(or(
and(eq(schema.friends.userId, request.userId), eq(schema.friends.friendId, targetUser.id)),
and(eq(schema.friends.userId, targetUser.id), eq(schema.friends.friendId, request.userId))
)).get();
if (existingFriend) {
return reply.code(400).send({ error: 'You are already friends with this user', statusCode: 400 });
}
// Check for existing pending request
const existingRequest = db.select().from(schema.friendRequests).where(and(
or(
and(eq(schema.friendRequests.fromId, request.userId), eq(schema.friendRequests.toId, targetUser.id)),
and(eq(schema.friendRequests.fromId, targetUser.id), eq(schema.friendRequests.toId, request.userId))
),
eq(schema.friendRequests.status, 'pending')
)).get();
if (existingRequest) {
return reply.code(400).send({ error: 'A friend request is already pending', statusCode: 400 });
}
// Create the request
const id = generateSnowflake();
const now = Date.now();
db.insert(schema.friendRequests).values({
id,
fromId: request.userId,
toId: targetUser.id,
status: 'pending',
createdAt: now,
}).run();
// Get the sender user for the WS event
const senderUser = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get();
// Broadcast friend_request_received to the target user
const friendRequestPayload: FriendRequest = {
id,
fromId: request.userId,
toId: targetUser.id,
status: 'pending',
createdAt: now,
user: senderUser ? sanitizeUser(senderUser) : undefined,
};
connectionManager.sendToUser(targetUser.id, {
type: 'friend_request_received',
request: friendRequestPayload,
});
return reply.code(201).send({ success: true, requestId: id });
});
// PATCH /api/social/requests/:id - Accept/Decline a friend request
app.patch<{ Params: { id: string }; Body: UpdateFriendRequest }>('/api/social/requests/:id', {
preHandler: authenticate,
}, async (request, reply) => {
const { id } = request.params;
const { status } = request.body;
const db = getDb();
if (!['accepted', 'declined'].includes(status)) {
return reply.code(400).send({ error: 'Invalid status', statusCode: 400 });
}
const friendRequest = db.select().from(schema.friendRequests).where(eq(schema.friendRequests.id, id)).get();
if (!friendRequest) {
return reply.code(404).send({ error: 'Friend request not found', statusCode: 404 });
}
if (friendRequest.toId !== request.userId) {
return reply.code(403).send({ error: 'You can only manage requests sent to you', statusCode: 403 });
}
if (status === 'accepted') {
// Insert friend and update request status atomically
const now = Date.now();
db.transaction((tx) => {
tx.insert(schema.friends).values({
userId: friendRequest.fromId,
friendId: friendRequest.toId,
createdAt: now,
}).run();
tx.update(schema.friendRequests)
.set({ status })
.where(eq(schema.friendRequests.id, id))
.run();
});
// WS broadcast AFTER transaction commits
const acceptingUser = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get();
if (acceptingUser) {
const friend: Friend = {
...sanitizeUser(acceptingUser),
addedAt: now,
};
connectionManager.sendToUser(friendRequest.fromId, {
type: 'friend_request_accepted',
friend,
requestId: id,
});
}
} else {
// For declined, just update the status (single write, no transaction needed)
db.update(schema.friendRequests)
.set({ status })
.where(eq(schema.friendRequests.id, id))
.run();
// Broadcast to the original sender so their UI updates in real-time
connectionManager.sendToUser(friendRequest.fromId, {
type: 'friend_request_declined',
requestId: id,
userId: request.userId,
});
}
return reply.code(200).send({ success: true });
});
// DELETE /api/social/requests/:id - Cancel an outgoing friend request
app.delete<{ Params: { id: string } }>('/api/social/requests/:id', {
preHandler: authenticate,
}, async (request, reply) => {
const { id } = request.params;
const db = getDb();
const friendRequest = db.select().from(schema.friendRequests).where(eq(schema.friendRequests.id, id)).get();
if (!friendRequest) {
return reply.code(404).send({ error: 'Friend request not found', statusCode: 404 });
}
// Only the sender can cancel an outgoing request
if (friendRequest.fromId !== request.userId) {
return reply.code(403).send({ error: 'You can only cancel requests you sent', statusCode: 403 });
}
if (friendRequest.status !== 'pending') {
return reply.code(400).send({ error: 'Can only cancel pending requests', statusCode: 400 });
}
db.delete(schema.friendRequests)
.where(eq(schema.friendRequests.id, id))
.run();
// Broadcast to the recipient so their UI updates in real-time
connectionManager.sendToUser(friendRequest.toId, {
type: 'friend_request_cancelled',
requestId: id,
userId: request.userId,
});
return reply.code(200).send({ success: true });
});
// DELETE /api/social/friends/:id - Remove a friend
app.delete<{ Params: { id: string } }>('/api/social/friends/:id', {
preHandler: authenticate,
}, async (request, reply) => {
const { id } = request.params;
const db = getDb();
// Check friendship exists before deleting
const existing = db.select().from(schema.friends).where(or(
and(eq(schema.friends.userId, request.userId), eq(schema.friends.friendId, id)),
and(eq(schema.friends.userId, id), eq(schema.friends.friendId, request.userId))
)).get();
if (!existing) {
return reply.code(404).send({ error: 'You are not friends with this user', statusCode: 404 });
}
db.delete(schema.friends).where(or(
and(eq(schema.friends.userId, request.userId), eq(schema.friends.friendId, id)),
and(eq(schema.friends.userId, id), eq(schema.friends.friendId, request.userId))
)).run();
// Broadcast friend_removed to the other user so their friends list updates
connectionManager.sendToUser(id, {
type: 'friend_removed',
userId: request.userId,
});
return reply.code(200).send({ success: true });
});
// GET /api/social/discover - Discover users on this instance
app.get<{ Querystring: { q?: string; limit?: string; offset?: string } }>('/api/social/discover', {
preHandler: authenticate,
config: { rateLimit: { max: 30, timeWindow: '1 minute' } },
}, async (request, reply) => {
const db = getDb();
const q = request.query.q?.trim() || '';
const limit = Math.min(Math.max(parseInt(request.query.limit || '24', 10) || 24, 1), 100);
const offset = Math.max(parseInt(request.query.offset || '0', 10) || 0, 0);
const myId = request.userId;
// Build WHERE clause
const conditions = [
eq(schema.users.discoverable, 1),
eq(schema.users.isDeleted, 0),
ne(schema.users.id, myId),
// Exclude replicated federated users — each instance only surfaces its own native users.
// Federated users are discovered through the parallel fetch across connected instances.
sql`(${schema.users.homeInstance} IS NULL OR ${schema.users.homeInstance} = '')`,
];
if (q) {
const pattern = `%${q}%`;
conditions.push(or(
like(schema.users.username, pattern),
like(schema.users.displayName, pattern),
)!);
}
// Get total count
const countResult = db.select({ count: sql<number>`count(*)` })
.from(schema.users)
.where(and(...conditions))
.get();
const total = countResult?.count ?? 0;
if (total === 0) {
return reply.code(200).send({ users: [], total: 0 });
}
// Pre-load my social graph
const myFriendRows = db.select().from(schema.friends).where(
or(eq(schema.friends.userId, myId), eq(schema.friends.friendId, myId))
).all();
const myFriendIds = new Set(myFriendRows.map(f => f.userId === myId ? f.friendId : f.userId));
const mySpaceRows = db.select({ spaceId: schema.spaceMembers.spaceId })
.from(schema.spaceMembers)
.where(eq(schema.spaceMembers.userId, myId))
.all();
const mySpaceIds = new Set(mySpaceRows.map(s => s.spaceId));
const outboundRequests = db.select().from(schema.friendRequests).where(
and(eq(schema.friendRequests.fromId, myId), eq(schema.friendRequests.status, 'pending'))
).all();
const outboundMap = new Map(outboundRequests.map(r => [r.toId, r.id]));
const inboundRequests = db.select().from(schema.friendRequests).where(
and(eq(schema.friendRequests.toId, myId), eq(schema.friendRequests.status, 'pending'))
).all();
const inboundMap = new Map(inboundRequests.map(r => [r.fromId, r.id]));
// Fetch page of users
const userRows = db.select()
.from(schema.users)
.where(and(...conditions))
.orderBy(sql`created_at DESC`)
.limit(limit)
.offset(offset)
.all();
// Batch fetch friends and space memberships for all page users
const pageUserIds = userRows.map(r => r.id);
// Batch fetch all friends for page users
const pageFriendRows = pageUserIds.length > 0
? db.select().from(schema.friends).where(
or(
inArray(schema.friends.userId, pageUserIds),
inArray(schema.friends.friendId, pageUserIds),
)
).all()
: [];
// Build Map<userId, Set<friendId>> for page users
const friendIdsByUser = new Map<string, Set<string>>();
for (const f of pageFriendRows) {
// Map both directions
if (pageUserIds.includes(f.userId)) {
if (!friendIdsByUser.has(f.userId)) friendIdsByUser.set(f.userId, new Set());
friendIdsByUser.get(f.userId)!.add(f.friendId);
}
if (pageUserIds.includes(f.friendId)) {
if (!friendIdsByUser.has(f.friendId)) friendIdsByUser.set(f.friendId, new Set());
friendIdsByUser.get(f.friendId)!.add(f.userId);
}
}
// Batch fetch all space memberships for page users
const pageSpaceMemberRows = pageUserIds.length > 0
? db.select({ userId: schema.spaceMembers.userId, spaceId: schema.spaceMembers.spaceId })
.from(schema.spaceMembers)
.where(inArray(schema.spaceMembers.userId, pageUserIds))
.all()
: [];
// Build Map<userId, Set<spaceId>> for page users
const spaceIdsByUser = new Map<string, Set<string>>();
for (const sm of pageSpaceMemberRows) {
if (!spaceIdsByUser.has(sm.userId)) spaceIdsByUser.set(sm.userId, new Set());
spaceIdsByUser.get(sm.userId)!.add(sm.spaceId);
}
// Compute mutual counts + relationship for each user
const discoverUsers: DiscoverUser[] = userRows.map(row => {
const u = sanitizeUser(row);
// Mutual friends (using batch-fetched data)
const theirFriendIds = friendIdsByUser.get(row.id) ?? new Set();
const mutualFriendCount = [...myFriendIds].filter(id => theirFriendIds.has(id)).length;
// Mutual spaces (using batch-fetched data)
const theirSpaceIds = spaceIdsByUser.get(row.id) ?? new Set();
const mutualSpaceCount = [...mySpaceIds].filter(id => theirSpaceIds.has(id)).length;
// Relationship
let relationship: DiscoverUser['relationship'] = 'none';
let requestId: string | undefined;
if (myFriendIds.has(row.id)) {
relationship = 'friends';
} else if (outboundMap.has(row.id)) {
relationship = 'outbound_pending';
requestId = outboundMap.get(row.id);
} else if (inboundMap.has(row.id)) {
relationship = 'inbound_pending';
requestId = inboundMap.get(row.id);
}
return {
id: u.id,
username: u.username,
displayName: u.displayName,
avatar: u.avatar,
banner: u.banner,
avatarColor: u.avatarColor,
bio: u.bio,
status: u.status,
customStatus: u.customStatus,
createdAt: u.createdAt,
homeInstance: u.homeInstance,
homeUserId: u.homeUserId,
mutualFriendCount,
mutualSpaceCount,
relationship,
...(requestId ? { requestId } : {}),
};
});
// Sort: mutual friends DESC, then created_at DESC
discoverUsers.sort((a, b) => {
if (b.mutualFriendCount !== a.mutualFriendCount) return b.mutualFriendCount - a.mutualFriendCount;
return b.createdAt - a.createdAt;
});
return reply.code(200).send({ users: discoverUsers, total });
});
// GET /api/social/search?q=... - Search for users to add as friends
app.get<{ Querystring: { q: string } }>('/api/social/search', {
preHandler: authenticate,
config: { rateLimit: { max: 30, timeWindow: '1 minute' } },
}, async (request, reply) => {
const { q } = request.query;
const db = getDb();
if (!q || q.length < 2) {
return reply.code(200).send([]);
}
const pattern = `%${q}%`;
// Search by username or display name with partial matching, excluding current user
const users = db.select()
.from(schema.users)
.where(and(
or(
like(schema.users.username, pattern),
like(schema.users.displayName, pattern)
),
ne(schema.users.id, request.userId)
))
.limit(10)
.all();
return reply.code(200).send(users.map(u => sanitizeUser(u)));
});
}