Sifa professional network API (Fastify, AT Protocol, Jetstream) sifa.id/

feat: add GET /api/following endpoint (#133)

* feat(follow): add GET /api/following endpoint (#317)

Returns paginated list of everyone the authenticated user follows,
with LEFT JOIN on profiles for claimed status. Supports source
filter and cursor-based pagination.

* fix(follow): validate source/cursor params, handle enrichment errors

- Validate source against whitelist (sifa, bluesky, tangled)
- Validate cursor is a valid ISO date
- Wrap fetchProfilesFromBluesky in try/catch for graceful degradation
- Rename response field to avatarUrl for consistency with suggestions

authored by

Guido X Jansen and committed by
GitHub
54a84686 0072c58f

+117 -2
+86 -2
src/routes/follow.ts
··· 2 2 import { z } from 'zod'; 3 3 import type { NodeOAuthClient } from '@atproto/oauth-client-node'; 4 4 import type { Database } from '../db/index.js'; 5 - import { connections } from '../db/schema/index.js'; 6 - import { and, eq } from 'drizzle-orm'; 5 + import { connections, profiles } from '../db/schema/index.js'; 6 + import { and, eq, lt, sql } from 'drizzle-orm'; 7 7 import { 8 8 generateTid, 9 9 buildApplyWritesOp, ··· 12 12 handlePdsError, 13 13 } from '../services/pds-writer.js'; 14 14 import { createAuthMiddleware, getAuthContext } from '../middleware/auth.js'; 15 + import { fetchProfilesFromBluesky } from '../services/profile-resolver.js'; 15 16 16 17 const followSchema = z.object({ 17 18 subjectDid: z.string().startsWith('did:'), ··· 110 111 return reply.status(200).send({ status: 'ok' }); 111 112 }, 112 113 ); 114 + 115 + // GET /api/following -- list everyone the authenticated user follows 116 + app.get('/api/following', { preHandler: requireAuth }, async (request, reply) => { 117 + const { did } = getAuthContext(request); 118 + const query = request.query as { 119 + source?: string; 120 + cursor?: string; 121 + limit?: string; 122 + }; 123 + 124 + const source = query.source; 125 + const limit = Math.min(parseInt(query.limit ?? '20', 10) || 20, 50); 126 + 127 + const conditions = [eq(connections.followerDid, did)]; 128 + if (source) { 129 + const validSources = ['sifa', 'bluesky', 'tangled'] as const; 130 + if (!validSources.includes(source as (typeof validSources)[number])) { 131 + return reply.status(400).send({ error: 'InvalidRequest', message: 'Invalid source value' }); 132 + } 133 + conditions.push(eq(connections.source, source)); 134 + } 135 + if (query.cursor) { 136 + const cursorDate = new Date(query.cursor); 137 + if (isNaN(cursorDate.getTime())) { 138 + return reply.status(400).send({ error: 'InvalidRequest', message: 'Invalid cursor value' }); 139 + } 140 + conditions.push(lt(connections.createdAt, cursorDate)); 141 + } 142 + 143 + const rows = await db 144 + .select({ 145 + subjectDid: connections.subjectDid, 146 + source: connections.source, 147 + createdAt: connections.createdAt, 148 + handle: profiles.handle, 149 + displayName: profiles.displayName, 150 + headline: profiles.headline, 151 + avatarUrl: profiles.avatarUrl, 152 + }) 153 + .from(connections) 154 + .leftJoin(profiles, eq(connections.subjectDid, profiles.did)) 155 + .where(and(...conditions)) 156 + .orderBy(sql`${connections.createdAt} DESC`) 157 + .limit(limit + 1); 158 + 159 + const hasMore = rows.length > limit; 160 + const items = hasMore ? rows.slice(0, limit) : rows; 161 + 162 + // Enrich rows without profile data from Bluesky 163 + const needEnrich = items.filter((r) => !r.handle).map((r) => r.subjectDid); 164 + let enrichedMap = new Map< 165 + string, 166 + { did: string; handle: string; displayName?: string; avatarUrl?: string } 167 + >(); 168 + if (needEnrich.length > 0) { 169 + try { 170 + const enriched = await fetchProfilesFromBluesky(needEnrich, app.log); 171 + enrichedMap = new Map(enriched.map((p) => [p.did, p])); 172 + } catch (err) { 173 + app.log.warn({ err }, 'Failed to enrich following list from Bluesky'); 174 + } 175 + } 176 + 177 + const follows = items.map((r) => { 178 + const bsky = enrichedMap.get(r.subjectDid); 179 + const claimed = r.handle !== null; 180 + return { 181 + did: r.subjectDid, 182 + handle: r.handle || bsky?.handle || '', 183 + displayName: r.displayName ?? bsky?.displayName, 184 + headline: r.headline ?? undefined, 185 + avatarUrl: r.avatarUrl ?? bsky?.avatarUrl, 186 + source: r.source, 187 + claimed, 188 + followedAt: r.createdAt.toISOString(), 189 + }; 190 + }); 191 + 192 + return reply.send({ 193 + follows, 194 + cursor: hasMore ? items[items.length - 1]?.createdAt?.toISOString() : undefined, 195 + }); 196 + }); 113 197 }
+31
tests/routes/follow.test.ts
··· 104 104 expect(res.statusCode).toBe(503); 105 105 expect(res.json().error).toBe('ServiceUnavailable'); 106 106 }); 107 + 108 + // --- GET /api/following --- 109 + 110 + it('GET /api/following returns 401 without auth', async () => { 111 + const res = await app.inject({ 112 + method: 'GET', 113 + url: '/api/following', 114 + }); 115 + expect(res.statusCode).toBe(401); 116 + expect(res.json().error).toBe('Unauthorized'); 117 + }); 118 + 119 + it('GET /api/following returns 503 with session but no OAuth client', async () => { 120 + const res = await app.inject({ 121 + method: 'GET', 122 + url: '/api/following', 123 + cookies: { session: 'test-session-id' }, 124 + }); 125 + expect(res.statusCode).toBe(503); 126 + expect(res.json().error).toBe('ServiceUnavailable'); 127 + }); 128 + 129 + it('GET /api/following accepts source query param', async () => { 130 + const res = await app.inject({ 131 + method: 'GET', 132 + url: '/api/following?source=sifa', 133 + cookies: { session: 'test-session-id' }, 134 + }); 135 + // 503 because no OAuth client, but route is registered and accepts the param 136 + expect(res.statusCode).toBe(503); 137 + }); 107 138 });