import { and, count, eq, ilike, isNotNull, or } from 'drizzle-orm'; import type { FastifyInstance } from 'fastify'; import { sortFeed, type FeedItemDTO, type ProfileDTO } from '@musicshare/shared'; import { db, schema } from '../db'; import { requireUser } from '../lib/auth'; import { entitlementsFor } from '../lib/entitlements'; import { HttpError } from '../lib/http'; import { FOLLOWS_CHANGED_CHANNEL, redis } from '../lib/redis'; import { watchUsers } from '../services/connections'; import { getNowPlaying, getRecentPlays, toUserSummary } from '../services/nowPlaying'; import { getTrackDTOs } from '../services/tracks'; async function findByUsername(username: string) { const [user] = await db.select().from(schema.users).where(eq(schema.users.username, username.toLowerCase())); if (!user) throw new HttpError(404, 'User not found'); return user; } export async function userRoutes(app: FastifyInstance) { app.get<{ Querystring: { q?: string } }>('/users/search', async (req) => { const q = (req.query.q ?? '').trim().replace(/[%_\\]/g, ''); if (q.length < 2) return []; const rows = await db .select() .from(schema.users) .where(or(ilike(schema.users.username, `${q}%`), ilike(schema.users.displayName, `%${q}%`))) .limit(20); return rows.map(toUserSummary); }); app.get<{ Params: { username: string } }>('/users/:username', async (req): Promise => { const user = await findByUsername(req.params.username); const viewer = req.user; const [[followers], [following], [follow], [featured], nowPlaying, recent] = await Promise.all([ db.select({ n: count() }).from(schema.follows).where(eq(schema.follows.followeeId, user.id)), db.select({ n: count() }).from(schema.follows).where(eq(schema.follows.followerId, user.id)), viewer ? db .select({ pinnedAt: schema.follows.pinnedAt }) .from(schema.follows) .where(and(eq(schema.follows.followerId, viewer.id), eq(schema.follows.followeeId, user.id))) : Promise.resolve([]), db.select().from(schema.featuredSongs).where(eq(schema.featuredSongs.userId, user.id)), getNowPlaying([user.id]), getRecentPlays(user.id), ]); const featuredTrack = featured ? (await getTrackDTOs([featured.trackId])).get(featured.trackId) : undefined; await watchUsers([user.id]); return { user: { ...toUserSummary(user), bio: user.bio }, followerCount: followers?.n ?? 0, followingCount: following?.n ?? 0, isFollowing: Boolean(follow), isPinned: Boolean(follow?.pinnedAt), isSelf: viewer?.id === user.id, featured: featured && featuredTrack ? { track: featuredTrack, comment: featured.comment, updatedAt: featured.updatedAt.toISOString() } : null, nowPlaying: nowPlaying[0] ?? null, recent, }; }); app.post<{ Params: { username: string } }>('/users/:username/follow', async (req) => { const viewer = requireUser(req); const target = await findByUsername(req.params.username); if (target.id === viewer.id) throw new HttpError(400, "You can't follow yourself"); await db.insert(schema.follows).values({ followerId: viewer.id, followeeId: target.id }).onConflictDoNothing(); await redis.publish(FOLLOWS_CHANGED_CHANNEL, viewer.id); return { ok: true }; }); app.delete<{ Params: { username: string } }>('/users/:username/follow', async (req) => { const viewer = requireUser(req); const target = await findByUsername(req.params.username); await db .delete(schema.follows) .where(and(eq(schema.follows.followerId, viewer.id), eq(schema.follows.followeeId, target.id))); await redis.publish(FOLLOWS_CHANGED_CHANNEL, viewer.id); return { ok: true }; }); app.put<{ Params: { username: string } }>('/users/:username/pin', async (req) => { const viewer = requireUser(req); const target = await findByUsername(req.params.username); const [follow] = await db .select({ pinnedAt: schema.follows.pinnedAt }) .from(schema.follows) .where(and(eq(schema.follows.followerId, viewer.id), eq(schema.follows.followeeId, target.id))); if (!follow) throw new HttpError(400, 'Follow someone before pinning them'); if (follow.pinnedAt) return { ok: true }; const [pinned] = await db .select({ n: count() }) .from(schema.follows) .where(and(eq(schema.follows.followerId, viewer.id), isNotNull(schema.follows.pinnedAt))); const max = entitlementsFor(viewer).maxPinnedFriends; if ((pinned?.n ?? 0) >= max) throw new HttpError(402, `You can pin up to ${max} friends on your plan`); await db .update(schema.follows) .set({ pinnedAt: new Date() }) .where(and(eq(schema.follows.followerId, viewer.id), eq(schema.follows.followeeId, target.id))); return { ok: true }; }); app.delete<{ Params: { username: string } }>('/users/:username/pin', async (req) => { const viewer = requireUser(req); const target = await findByUsername(req.params.username); await db .update(schema.follows) .set({ pinnedAt: null }) .where(and(eq(schema.follows.followerId, viewer.id), eq(schema.follows.followeeId, target.id))); return { ok: true }; }); /** Now-playing state for everyone the viewer follows, pinned friends first. */ app.get('/feed', async (req): Promise => { const viewer = requireUser(req); const followees = await db .select({ id: schema.follows.followeeId, pinnedAt: schema.follows.pinnedAt }) .from(schema.follows) .where(eq(schema.follows.followerId, viewer.id)); const pins = new Map(followees.map((f) => [f.id, f.pinnedAt?.toISOString() ?? null])); await watchUsers([...pins.keys()]); const items = await getNowPlaying([...pins.keys()]); return sortFeed(items.map((np) => ({ ...np, pinnedAt: pins.get(np.user.id) ?? null }))); }); }