diff --git a/services/appview/README.md b/services/appview/README.md index 273a65b..4e4281f 100644 --- a/services/appview/README.md +++ b/services/appview/README.md @@ -33,6 +33,10 @@ The service exposes the following API routes: - `GET /xrpc/so.sprk.actor.getProfile` - Get profile details for an actor - `GET /xrpc/so.sprk.actor.searchActors` - Search for actors +### Graph Routes +- `GET /xrpc/so.sprk.graph.getFollowers` - Get followers for an actor +- `GET /xrpc/so.sprk.graph.getFollows` - Get accounts an actor follows + ### Feed Routes - `GET /xrpc/so.sprk.feed.getPosts` - Get post objects from URIs - `GET /xrpc/so.sprk.feed.getAuthorFeed` - Get a post and all replies to it \ No newline at end of file diff --git a/services/appview/src/index.ts b/services/appview/src/index.ts index 865fb3d..0c54a2d 100644 --- a/services/appview/src/index.ts +++ b/services/appview/src/index.ts @@ -16,6 +16,8 @@ import { import { createGetPostsRouter } from './routes/feed/getPosts.js' import { createGetPostThreadRouter } from './routes/feed/getPostThread.js' import { createGetProfileRouter } from './routes/actor/getProfile.js' +import { createGetFollowersRouter } from './routes/graph/getFollowers.js' +import { createGetFollowsRouter } from './routes/graph/getFollows.js' import wellKnownRouter from './well-known.js' export type AppContext = { @@ -75,9 +77,13 @@ export class Server { const getPostsRouter = createGetPostsRouter(ctx) const getPostThreadRouter = createGetPostThreadRouter(ctx) const getProfileRouter = createGetProfileRouter(ctx) + const getFollowersRouter = createGetFollowersRouter(ctx) + const getFollowsRouter = createGetFollowsRouter(ctx) app.route('/', getPostsRouter) app.route('/', getPostThreadRouter) app.route('/', getProfileRouter) + app.route('/', getFollowersRouter) + app.route('/', getFollowsRouter) app.route('/', wellKnownRouter()) diff --git a/services/appview/src/routes/graph/getFollowers.ts b/services/appview/src/routes/graph/getFollowers.ts new file mode 100644 index 0000000..7f8a62e --- /dev/null +++ b/services/appview/src/routes/graph/getFollowers.ts @@ -0,0 +1,114 @@ +import { Hono } from 'hono' + +import { optionalAuthMiddleware } from '../../auth/middleware.js' +import { AppContext } from '../../index.js' +import type * as SoSprkActorDefs from '../../lexicon/types/so/sprk/actor/defs.js' + +export const createGetFollowersRouter = (ctx: AppContext) => { + const router = new Hono() + + router.get('/xrpc/so.sprk.graph.getFollowers', optionalAuthMiddleware, async (c) => { + const actor = c.req.query('actor') + const limit = parseInt(c.req.query('limit') ?? '50') + const cursor = c.req.query('cursor') + + if (!actor) { + return c.json({ error: 'Actor is required' }, 400) + } + + // Validate limit + if (limit < 1 || limit > 100) { + return c.json({ error: 'Limit must be between 1 and 100' }, 400) + } + + // Build query + const query: any = { subject: actor } + if (cursor) { + query._id = { $gt: cursor } + } + + // Get followers with pagination + const followers = await ctx.db.models.Follow.find(query) + .sort({ _id: 1 }) + .limit(limit) + .lean() + + // Get profile views for each follower + const profileViews = await Promise.all( + followers.map(async (follow) => { + const profile = await ctx.db.models.Profile.findOne({ authorDid: follow.authorDid }) + + // Basic profile view with just DID and handle + const basicProfileView: SoSprkActorDefs.ProfileView = { + $type: 'so.sprk.actor.defs#profileView', + did: follow.authorDid, + handle: follow.authorHandle, + viewer: { + $type: 'so.sprk.actor.defs#viewerState', + following: follow.uri + } + } + + // If we found a profile, add the additional fields + if (profile) { + return { + ...basicProfileView, + displayName: profile.displayName, + description: profile.description, + avatar: profile.avatar?.ref?.link, + indexedAt: profile.indexedAt, + createdAt: profile.createdAt + } + } + + return basicProfileView + }) + ) + + // Get next cursor + const nextCursor = followers.length === limit ? followers[followers.length - 1]._id : undefined + console.log('nextCursor', nextCursor) + + // Get subject profile if it exists + const subjectProfile = await ctx.db.models.Profile.findOne({ authorDid: actor }) + + // Basic subject profile view with just DID and handle + let handle = null + try { + if (actor) { + const didData = await ctx.resolver.resolveDidToDidDoc(actor) + handle = didData.handle + } + } catch (error) { + ctx.logger.warn( + { did: actor, error: (error as Error).message }, + 'Failed to resolve DID to handle', + ) + } + const subjectProfileView: SoSprkActorDefs.ProfileView = { + $type: 'so.sprk.actor.defs#profileView', + did: actor, + handle: handle ?? 'unknown' + } + + // If we found the subject profile, add the additional fields + if (subjectProfile) { + Object.assign(subjectProfileView, { + handle: subjectProfile.authorHandle, + displayName: subjectProfile.displayName, + description: subjectProfile.description, + avatar: subjectProfile.avatar?.ref?.link, + indexedAt: subjectProfile.indexedAt, + createdAt: subjectProfile.createdAt + }) + } + + return c.json({ + subject: subjectProfileView, + followers: profileViews, + cursor: nextCursor + }) + }) + + return router +} \ No newline at end of file diff --git a/services/appview/src/routes/graph/getFollows.ts b/services/appview/src/routes/graph/getFollows.ts new file mode 100644 index 0000000..23e2d06 --- /dev/null +++ b/services/appview/src/routes/graph/getFollows.ts @@ -0,0 +1,114 @@ +import { Hono } from 'hono' + +import { optionalAuthMiddleware } from '../../auth/middleware.js' +import { AppContext } from '../../index.js' +import type * as SoSprkActorDefs from '../../lexicon/types/so/sprk/actor/defs.js' + +export const createGetFollowsRouter = (ctx: AppContext) => { + const router = new Hono() + + router.get('/xrpc/so.sprk.graph.getFollows', optionalAuthMiddleware, async (c) => { + const actor = c.req.query('actor') + const limit = parseInt(c.req.query('limit') ?? '50') + const cursor = c.req.query('cursor') + + if (!actor) { + return c.json({ error: 'Actor is required' }, 400) + } + + // Validate limit + if (limit < 1 || limit > 100) { + return c.json({ error: 'Limit must be between 1 and 100' }, 400) + } + + // Build query + const query: any = { authorDid: actor } + if (cursor) { + query._id = { $gt: cursor } + } + + // Get follows with pagination + const follows = await ctx.db.models.Follow.find(query) + .sort({ _id: 1 }) + .limit(limit) + .lean() + + // Get profile views for each follow + const profileViews = await Promise.all( + follows.map(async (follow) => { + const profile = await ctx.db.models.Profile.findOne({ authorDid: follow.subject }) + // Basic profile view with just DID + const basicProfileView: SoSprkActorDefs.ProfileView = { + $type: 'so.sprk.actor.defs#profileView', + did: follow.authorDid, + handle: follow.authorHandle, + viewer: { + $type: 'so.sprk.actor.defs#viewerState', + followedBy: follow.uri + } + } + + // If we found a profile, add the additional fields + if (profile) { + return { + ...basicProfileView, + handle: profile.authorHandle, + displayName: profile.displayName, + description: profile.description, + avatar: profile.avatar?.ref?.link, + indexedAt: profile.indexedAt, + createdAt: profile.createdAt + } + } + + return basicProfileView + }) + ) + + // Get next cursor + const nextCursor = follows.length === limit ? follows[follows.length - 1]._id : undefined + + // Get subject profile if it exists + const subjectProfile = await ctx.db.models.Profile.findOne({ authorDid: actor }) + const subjectProfileView: SoSprkActorDefs.ProfileView = { + $type: 'so.sprk.actor.defs#profileView', + did: actor, + handle: 'unknown' + } + // If we found the subject profile, add the additional fields + if (subjectProfile) { + Object.assign(subjectProfileView, { + handle: subjectProfile.authorHandle, + displayName: subjectProfile.displayName, + description: subjectProfile.description, + avatar: subjectProfile.avatar?.ref?.link, + indexedAt: subjectProfile.indexedAt, + createdAt: subjectProfile.createdAt + }) + } else { + let handle = null + try { + if (actor) { + const didData = await ctx.resolver.resolveDidToDidDoc(actor) + handle = didData.handle + } + } catch (error) { + ctx.logger.warn( + { did: actor, error: (error as Error).message }, + 'Failed to resolve DID to handle', + ) + } + Object.assign(subjectProfileView, { + handle: handle ?? 'unknown' + }) + } + + return c.json({ + subject: subjectProfileView, + follows: profileViews, + cursor: nextCursor + }) + }) + + return router +}