[READ ONLY MIRROR] Spark Social AppView Server github.com/sprksocial/server
atproto deno hono lexicon
5
fork

Configure Feed

Select the types of activity you want to include in your feed.

get posts on appview

+459 -15
+316 -15
services/appview/src/db.ts
··· 1 1 import mongoose, { Schema, Document, Model, Connection } from 'mongoose' 2 2 import { env } from './env.js' 3 + import { pino } from 'pino' 3 4 4 5 export interface LikeDocument extends Document { 5 - uri: string // URI of the like event 6 - subject: string // URI of the post being liked 7 - subjectCid: string // CID of the post being liked 8 - authorDid: string // DID of the user who liked the post 9 - authorHandle: string // Handle of the user who liked the post 10 - createdAt: string // When the like was created 11 - indexedAt: string // When the like was indexed 6 + uri: string 7 + subject: string 8 + subjectCid: string 9 + authorDid: string 10 + authorHandle: string 11 + createdAt: string 12 + indexedAt: string 13 + cid: string 12 14 } 13 15 14 - const likeSchema = new Schema<LikeDocument>({ 16 + export const likeSchema = new Schema<LikeDocument>({ 15 17 uri: { type: String, required: true, unique: true, index: true }, 16 18 subject: { type: String, required: true, index: true }, 17 19 subjectCid: { type: String, required: true }, ··· 19 21 authorHandle: { type: String, required: true }, 20 22 createdAt: { type: String, required: true }, 21 23 indexedAt: { type: String, required: true }, 24 + cid: { type: String, required: true }, 22 25 }) 23 26 24 - // Model types 27 + export interface LookDocument extends Document { 28 + uri: string 29 + subject: string 30 + subjectCid: string 31 + authorDid: string 32 + authorHandle: string 33 + createdAt: string 34 + indexedAt: string 35 + cid: string 36 + } 37 + 38 + export const lookSchema = new Schema<LookDocument>({ 39 + uri: { type: String, required: true, unique: true, index: true }, 40 + subject: { type: String, required: true, index: true }, 41 + subjectCid: { type: String, required: true }, 42 + authorDid: { type: String, required: true, index: true }, 43 + authorHandle: { type: String, required: true }, 44 + createdAt: { type: String, required: true }, 45 + indexedAt: { type: String, required: true }, 46 + cid: { type: String, required: true }, 47 + }) 48 + 49 + export interface FollowDocument extends Document { 50 + uri: string 51 + subject: string 52 + authorDid: string 53 + authorHandle: string 54 + createdAt: string 55 + indexedAt: string 56 + cid: string 57 + } 58 + 59 + export const followSchema = new Schema<FollowDocument>({ 60 + uri: { type: String, required: true, unique: true, index: true }, 61 + subject: { type: String, required: true, index: true }, 62 + authorDid: { type: String, required: true, index: true }, 63 + authorHandle: { type: String, required: true }, 64 + createdAt: { type: String, required: true }, 65 + indexedAt: { type: String, required: true }, 66 + cid: { type: String, required: true }, 67 + }) 68 + 69 + export interface BlockDocument extends Document { 70 + uri: string 71 + subject: string 72 + authorDid: string 73 + authorHandle: string 74 + createdAt: string 75 + indexedAt: string 76 + cid: string 77 + } 78 + 79 + export const blockSchema = new Schema<BlockDocument>({ 80 + uri: { type: String, required: true, unique: true, index: true }, 81 + subject: { type: String, required: true, index: true }, 82 + authorDid: { type: String, required: true, index: true }, 83 + authorHandle: { type: String, required: true }, 84 + createdAt: { type: String, required: true }, 85 + indexedAt: { type: String, required: true }, 86 + cid: { type: String, required: true }, 87 + }) 88 + 89 + export interface ProfileDocument extends Document { 90 + uri: string 91 + displayName?: string 92 + description?: string 93 + avatar?: string 94 + banner?: string 95 + labels?: Record<string, any> 96 + joinedViaStarterPack?: Record<string, any> 97 + pinnedPost?: Record<string, any> 98 + authorDid: string 99 + authorHandle: string 100 + createdAt: string 101 + indexedAt: string 102 + cid: string 103 + } 104 + 105 + export const profileSchema = new Schema<ProfileDocument>({ 106 + uri: { type: String, required: true, unique: true, index: true }, 107 + displayName: { type: String, required: false }, 108 + description: { type: String, required: false }, 109 + avatar: { type: String, required: false }, 110 + banner: { type: String, required: false }, 111 + labels: { type: Object, required: false }, 112 + joinedViaStarterPack: { type: Object, required: false }, 113 + pinnedPost: { type: Object, required: false }, 114 + authorDid: { type: String, required: true, index: true }, 115 + authorHandle: { type: String, required: true }, 116 + createdAt: { type: String, required: true }, 117 + indexedAt: { type: String, required: true }, 118 + cid: { type: String, required: true }, 119 + }) 120 + 121 + export interface AudioDocument extends Document { 122 + uri: string 123 + sound: string 124 + origin: { 125 + uri: string 126 + cid: string 127 + } 128 + title?: string 129 + text?: string 130 + labels?: Record<string, any> 131 + authorDid: string 132 + authorHandle: string 133 + createdAt: string 134 + indexedAt: string 135 + cid: string 136 + } 137 + 138 + export const audioSchema = new Schema<AudioDocument>({ 139 + uri: { type: String, required: true, unique: true, index: true }, 140 + sound: { type: String, required: true }, 141 + origin: { 142 + uri: { type: String, required: true }, 143 + cid: { type: String, required: true }, 144 + }, 145 + title: { type: String, required: false }, 146 + text: { type: String, required: false }, 147 + labels: { type: Object, required: false }, 148 + authorDid: { type: String, required: true, index: true }, 149 + authorHandle: { type: String, required: true }, 150 + createdAt: { type: String, required: true }, 151 + indexedAt: { type: String, required: true }, 152 + cid: { type: String, required: true }, 153 + }) 154 + 155 + export interface RepostDocument extends Document { 156 + uri: string 157 + subject: { 158 + uri: string 159 + cid: string 160 + } 161 + authorDid: string 162 + authorHandle: string 163 + createdAt: string 164 + indexedAt: string 165 + cid: string 166 + } 167 + 168 + export const repostSchema = new Schema<RepostDocument>({ 169 + uri: { type: String, required: true, unique: true, index: true }, 170 + subject: { 171 + uri: { type: String, required: true }, 172 + cid: { type: String, required: true }, 173 + }, 174 + authorDid: { type: String, required: true, index: true }, 175 + authorHandle: { type: String, required: true }, 176 + createdAt: { type: String, required: true }, 177 + indexedAt: { type: String, required: true }, 178 + cid: { type: String, required: true }, 179 + }) 180 + 181 + export interface MusicDocument extends Document { 182 + uri: string 183 + sound: string 184 + title: string 185 + author: string 186 + releaseDate: string 187 + album?: string 188 + recordLabel?: string 189 + cover?: string 190 + text?: string 191 + copyright?: string[] 192 + facets?: Array<Record<string, any>> 193 + labels?: Record<string, any> 194 + tags?: string[] 195 + authorDid: string 196 + authorHandle: string 197 + createdAt: string 198 + indexedAt: string 199 + cid: string 200 + } 201 + 202 + export const musicSchema = new Schema<MusicDocument>({ 203 + uri: { type: String, required: true, unique: true, index: true }, 204 + sound: { type: String, required: true }, 205 + title: { type: String, required: true }, 206 + author: { type: String, required: true }, 207 + releaseDate: { type: String, required: true }, 208 + album: { type: String, required: false }, 209 + recordLabel: { type: String, required: false }, 210 + cover: { type: String, required: false }, 211 + text: { type: String, required: false }, 212 + copyright: { type: [String], required: false }, 213 + facets: { type: [Object], required: false }, 214 + labels: { type: Object, required: false }, 215 + tags: { type: [String], required: false }, 216 + authorDid: { type: String, required: true, index: true }, 217 + authorHandle: { type: String, required: true }, 218 + createdAt: { type: String, required: true }, 219 + indexedAt: { type: String, required: true }, 220 + cid: { type: String, required: true }, 221 + }) 222 + 223 + export interface PostDocument extends Document { 224 + uri: string 225 + text: string 226 + facets: Array<Record<string, any>> 227 + reply: { 228 + root: { 229 + uri: string 230 + cid: string 231 + } 232 + parent: { 233 + uri: string 234 + cid: string 235 + } 236 + } | null 237 + embed: Record<string, any> | null 238 + sound: { 239 + uri: string 240 + cid: string 241 + } | null 242 + langs: string[] 243 + labels: Record<string, any> | null 244 + tags: string[] 245 + authorDid: string 246 + authorHandle: string 247 + createdAt: string 248 + indexedAt: string 249 + cid: string 250 + } 251 + 252 + export const postSchema = new Schema<PostDocument>({ 253 + uri: { type: String, required: true, unique: true, index: true }, 254 + text: { type: String, required: false }, 255 + facets: { type: [Object], required: false, default: [] }, 256 + reply: { 257 + type: { 258 + root: { 259 + uri: { type: String, required: true }, 260 + cid: { type: String, required: true }, 261 + }, 262 + parent: { 263 + uri: { type: String, required: true }, 264 + cid: { type: String, required: true }, 265 + }, 266 + }, 267 + required: false, 268 + default: null, 269 + }, 270 + embed: { type: Object, required: false, default: null }, 271 + sound: { 272 + type: { 273 + uri: { type: String, required: true }, 274 + cid: { type: String, required: true }, 275 + }, 276 + required: false, 277 + default: null, 278 + }, 279 + langs: { type: [String], required: false, default: [] }, 280 + labels: { type: Object, required: false, default: null }, 281 + tags: { type: [String], required: false, default: [] }, 282 + authorDid: { type: String, required: true, index: true }, 283 + authorHandle: { type: String, required: true }, 284 + createdAt: { type: String, required: true }, 285 + indexedAt: { type: String, required: true }, 286 + cid: { type: String, required: true }, 287 + }) 288 + 289 + // Add compound indexes for more efficient queries 290 + postSchema.index({ authorDid: 1, createdAt: -1 }) 291 + postSchema.index({ tags: 1, createdAt: -1 }) 292 + 293 + // Add compound indexes for new schemas 294 + followSchema.index({ authorDid: 1, subject: 1 }, { unique: true }) 295 + followSchema.index({ subject: 1, createdAt: -1 }) 296 + 297 + blockSchema.index({ authorDid: 1, subject: 1 }, { unique: true }) 298 + blockSchema.index({ subject: 1, createdAt: -1 }) 299 + 300 + audioSchema.index({ authorDid: 1, createdAt: -1 }) 301 + repostSchema.index({ authorDid: 1, createdAt: -1 }) 302 + repostSchema.index({ 'subject.uri': 1, createdAt: -1 }) 303 + 304 + musicSchema.index({ authorDid: 1, createdAt: -1 }) 305 + musicSchema.index({ tags: 1, createdAt: -1 }) 306 + 25 307 export interface DatabaseModels { 26 308 Like: Model<LikeDocument> 309 + Post: Model<PostDocument> 310 + Follow: Model<FollowDocument> 311 + Block: Model<BlockDocument> 312 + Profile: Model<ProfileDocument> 313 + Audio: Model<AudioDocument> 314 + Repost: Model<RepostDocument> 315 + Music: Model<MusicDocument> 316 + Look: Model<LookDocument> 27 317 } 28 318 29 - // Database connection and models 30 319 export class Database { 31 320 private connection: Connection 32 321 public models: DatabaseModels 322 + private logger = pino({ name: 'database' }) 33 323 34 324 constructor() { 35 325 this.connection = mongoose.createConnection() 36 326 this.models = { 37 327 Like: this.connection.model<LikeDocument>('Like', likeSchema), 328 + Post: this.connection.model<PostDocument>('Post', postSchema), 329 + Follow: this.connection.model<FollowDocument>('Follow', followSchema), 330 + Block: this.connection.model<BlockDocument>('Block', blockSchema), 331 + Profile: this.connection.model<ProfileDocument>('Profile', profileSchema), 332 + Audio: this.connection.model<AudioDocument>('Audio', audioSchema), 333 + Repost: this.connection.model<RepostDocument>('Repost', repostSchema), 334 + Music: this.connection.model<MusicDocument>('Music', musicSchema), 335 + Look: this.connection.model<LookDocument>('Look', lookSchema), 38 336 } 39 337 } 40 338 41 339 async connect(): Promise<void> { 42 340 const { DB_USER, DB_PASSWORD, DB_HOST, DB_PORT, DB_NAME } = env 43 - const uri = `mongodb://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/` 44 - console.log('Connecting to MongoDB:', uri) 341 + const uri = `mongodb://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/?appName=appview` 342 + this.logger.info( 343 + `Connecting to MongoDB at ${DB_HOST}:${DB_PORT}/?appName=appview`, 344 + ) 45 345 46 346 try { 47 347 await this.connection.openUri(uri, { 48 348 autoIndex: true, 49 349 autoCreate: true, 350 + dbName: DB_NAME, 50 351 }) 51 - console.log('Connected to MongoDB') 352 + this.logger.info('Connected to MongoDB') 52 353 } catch (error) { 53 - console.error('MongoDB connection error:', error) 354 + this.logger.error({ error }, 'MongoDB connection error') 54 355 throw error 55 356 } 56 357 } ··· 58 359 async disconnect(): Promise<void> { 59 360 if (this.connection) { 60 361 await this.connection.close() 61 - console.log('Disconnected from MongoDB') 362 + this.logger.info('Disconnected from MongoDB') 62 363 } 63 364 } 64 365 }
+4
services/appview/src/index.ts
··· 15 15 import { HTTPException } from 'hono/http-exception' 16 16 import { authMiddleware } from './auth/middleware.js' 17 17 import { createFeedRouter } from './feed/feed.js' 18 + import { createGetPostsRouter } from './routes/getPosts.js' 18 19 import wellKnownRouter from './well-known.js' 19 20 20 21 export type AppContext = { ··· 60 61 61 62 const feedRouter = createFeedRouter(ctx) 62 63 app.route('/', feedRouter) 64 + 65 + const getPostsRouter = createGetPostsRouter(ctx.db) 66 + app.route('/', getPostsRouter) 63 67 64 68 app.route('/', wellKnownRouter()) 65 69
+139
services/appview/src/routes/getPosts.ts
··· 1 + import { Hono } from 'hono' 2 + 3 + import { OutputSchema as GetPostsView } from '../lexicon/types/so/sprk/feed/getPosts.js' 4 + import type * as SoSprkFeedDefs from '../lexicon/types/so/sprk/feed/defs.js' 5 + import type { ProfileViewBasic } from '../lexicon/types/so/sprk/actor/defs.js' 6 + import type { Label } from '../lexicon/types/com/atproto/label/defs.js' 7 + import type * as SoSprkEmbedImages from '../lexicon/types/so/sprk/embed/images.js' 8 + import type * as SoSprkEmbedVideo from '../lexicon/types/so/sprk/embed/video.js' 9 + import { Database, PostDocument } from '../db.js' 10 + 11 + // Transform DB post to PostView format 12 + async function transformPostToPostView( 13 + post: PostDocument, 14 + db: Database, 15 + ): Promise<SoSprkFeedDefs.PostView> { 16 + // Get like count 17 + const likeCount = await db.models.Like.countDocuments({ subject: post.uri }) 18 + 19 + // Get reply count 20 + const replyCount = await db.models.Post.countDocuments({ 21 + 'reply.parent.uri': post.uri, 22 + }) 23 + 24 + // Get repost count 25 + const repostCount = await db.models.Repost.countDocuments({ 26 + 'subject.uri': post.uri, 27 + }) 28 + 29 + // Get quote count - posts that embed this post 30 + // const quoteCount = await db.models.Post.countDocuments({ 31 + // 'embed.uri': post.uri 32 + // }) 33 + 34 + const lookCount = await db.models.Look.countDocuments({ 35 + 'subject.uri': post.uri, 36 + }) 37 + 38 + // Get author profile data 39 + const profile = await db.models.Profile.findOne({ 40 + authorDid: post.authorDid, 41 + }).lean() 42 + 43 + // Create the author object 44 + const author: ProfileViewBasic = { 45 + did: post.authorDid, 46 + handle: post.authorHandle, 47 + displayName: profile?.displayName ?? post.authorHandle, 48 + avatar: `https://cdn.sprk.so/avatar/${post.authorDid}`, 49 + } 50 + 51 + let embed 52 + 53 + if (post.embed?.$type === 'so.sprk.embed.images') { 54 + embed = { 55 + $type: 'so.sprk.embed.images#view', 56 + images: post.embed.images.map((img: any) => ({ 57 + thumb: `https://cdn.sprk.so/image/${post.authorDid}/${img.image.ref.$link}`, 58 + fullsize: `https://cdn.sprk.so/image/${post.authorDid}/${img.image.ref.$link}`, 59 + alt: img.alt, 60 + aspectRatio: img.aspectRatio, 61 + })), 62 + } satisfies SoSprkEmbedImages.View 63 + } else if (post.embed?.$type === 'so.sprk.embed.video') { 64 + embed = { 65 + $type: 'so.sprk.embed.video#view', 66 + cid: post.embed.cid, 67 + playlist: post.embed.playlist, 68 + thumbnail: post.embed.thumbnail, 69 + } satisfies SoSprkEmbedVideo.View 70 + } 71 + 72 + // Convert labels if any 73 + const labels = post.labels 74 + ? Array.isArray(post.labels) 75 + ? (post.labels as Label[]) 76 + : undefined 77 + : undefined 78 + 79 + return { 80 + uri: post.uri, 81 + cid: post.cid, 82 + author, 83 + record: { 84 + text: post.text, 85 + facets: post.facets, 86 + langs: post.langs, 87 + tags: post.tags, 88 + }, 89 + embed: embed, 90 + replyCount, 91 + repostCount, 92 + likeCount, 93 + lookCount, 94 + indexedAt: post.indexedAt, 95 + labels, 96 + } 97 + } 98 + 99 + // Function to fetch posts by URIs 100 + async function getPosts( 101 + uris: string | string[], 102 + db: Database, 103 + ): Promise<SoSprkFeedDefs.PostView[]> { 104 + if (!uris) { 105 + return [] 106 + } 107 + 108 + const uriArray = Array.isArray(uris) ? uris : [uris] 109 + 110 + if (uriArray.length === 0) { 111 + return [] 112 + } 113 + 114 + const dbPosts = await db.models.Post.find({ uri: { $in: uriArray } }).lean() 115 + 116 + // Transform each post to PostView format 117 + const postViews = await Promise.all( 118 + dbPosts.map((post) => transformPostToPostView(post, db)), 119 + ) 120 + 121 + return postViews 122 + } 123 + 124 + export const createGetPostsRouter = (db: Database) => { 125 + const router = new Hono() 126 + 127 + router.get('/xrpc/so.sprk.feed.getPosts', async (c) => { 128 + const uris = c.req.queries('uris') 129 + 130 + if (!uris || uris.length === 0) { 131 + return c.json({ posts: [] } as GetPostsView) 132 + } 133 + 134 + const posts = await getPosts(uris, db) 135 + 136 + return c.json({ posts } as GetPostsView) 137 + }) 138 + return router 139 + }