From 362bf229a7ef9018a0f3f907540e91e309422186 Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Wed, 3 Jun 2026 15:13:01 -0700 Subject: [PATCH] Adding endpoints for profile page --- src/adapters/post-query.js | 17 +++ src/controllers/rest-api/posts/controller.js | 34 ++++++ src/controllers/rest-api/posts/index.js | 1 + src/use-cases/index.js | 3 + src/use-cases/list-posts-by-addr.js | 91 ++++++++++++++ test/unit/adapters/post-query.unit.js | 15 +++ .../unit/controllers/posts.controller.unit.js | 25 ++++ .../unit/use-cases/list-posts-by-addr.unit.js | 48 ++++++++ util/profiles/backfill_seen.js | 114 ------------------ 9 files changed, 234 insertions(+), 114 deletions(-) create mode 100644 src/use-cases/list-posts-by-addr.js create mode 100644 test/unit/use-cases/list-posts-by-addr.unit.js delete mode 100644 util/profiles/backfill_seen.js diff --git a/src/adapters/post-query.js b/src/adapters/post-query.js index 90d97d0..20fe093 100644 --- a/src/adapters/post-query.js +++ b/src/adapters/post-query.js @@ -27,6 +27,23 @@ class PostQuery { return posts } + + async scanPostsByAddr (addr) { + const posts = [] + + for await (const [txid, post] of this.postsDb.iterator()) { + if (post.addr !== addr) continue + posts.push({ + txid, + addr: post.addr, + text: post.text, + seen: post.seen, + blockHeight: post.blockHeight ?? 0 + }) + } + + return posts + } } export default PostQuery diff --git a/src/controllers/rest-api/posts/controller.js b/src/controllers/rest-api/posts/controller.js index 98f09dd..68a00e3 100644 --- a/src/controllers/rest-api/posts/controller.js +++ b/src/controllers/rest-api/posts/controller.js @@ -16,6 +16,7 @@ class PostsRESTControllerLib { } this.getRecentPosts = this.getRecentPosts.bind(this) + this.getPostsByAddr = this.getPostsByAddr.bind(this) this.handleError = this.handleError.bind(this) } @@ -62,6 +63,39 @@ class PostsRESTControllerLib { this.handleError(ctx, err) } } + + /** + * @api {get} /posts/by/:addr List posts by address + * @apiPermission public + * @apiName GetPostsByAddr + * @apiGroup REST Posts + * + * @apiDescription Returns posts for a single address sorted by block height (newest first). + * + * @apiParam {String} addr Author cash address + * @apiQuery {Number} [limit=100] Page size (max 100) + * @apiQuery {Number} [offset=0] Number of posts to skip after sorting + * + * @apiExample Example usage: + * curl -X GET "localhost:5021/posts/by/bitcoincash:q...?limit=50&offset=0" + * + * @apiSuccess {Object[]} posts Array of post objects + * @apiSuccess {String} posts.txid Post transaction id + * @apiSuccess {String} posts.addr Author cash address + * @apiSuccess {String} posts.text Post text + * @apiSuccess {Number} posts.seen Unix epoch milliseconds + * @apiSuccess {Number} posts.blockHeight Block height when indexed + * @apiSuccess {Object} pagination Pagination metadata + */ + async getPostsByAddr (ctx) { + try { + const { addr } = ctx.params + const { limit, offset } = ctx.query + ctx.body = await this.useCases.listPostsByAddr.execute({ addr, limit, offset }) + } catch (err) { + this.handleError(ctx, err) + } + } } export default PostsRESTControllerLib diff --git a/src/controllers/rest-api/posts/index.js b/src/controllers/rest-api/posts/index.js index aae5595..c4c37af 100644 --- a/src/controllers/rest-api/posts/index.js +++ b/src/controllers/rest-api/posts/index.js @@ -25,6 +25,7 @@ class PostsRouter { attach (app) { this.router.get('/recent', this.postsRESTController.getRecentPosts) + this.router.get('/by/:addr', this.postsRESTController.getPostsByAddr) app.use(this.router.routes()) app.use(this.router.allowedMethods()) } diff --git a/src/use-cases/index.js b/src/use-cases/index.js index a9ff9c6..929a602 100644 --- a/src/use-cases/index.js +++ b/src/use-cases/index.js @@ -4,6 +4,7 @@ import ListRecentProfiles from './list-recent-profiles.js' import ListRecentPosts from './list-recent-posts.js' +import ListPostsByAddr from './list-posts-by-addr.js' class UseCases { constructor (localConfig = {}) { @@ -14,11 +15,13 @@ class UseCases { this.listRecentProfiles = null this.listRecentPosts = null + this.listPostsByAddr = null } async start () { this.listRecentProfiles = new ListRecentProfiles({ adapters: this.adapters }) this.listRecentPosts = new ListRecentPosts({ adapters: this.adapters }) + this.listPostsByAddr = new ListPostsByAddr({ adapters: this.adapters }) console.log('Use cases initialized.') return true } diff --git a/src/use-cases/list-posts-by-addr.js b/src/use-cases/list-posts-by-addr.js new file mode 100644 index 0000000..bf4c4cf --- /dev/null +++ b/src/use-cases/list-posts-by-addr.js @@ -0,0 +1,91 @@ +/* + Use case: list posts for an address ordered by block height (most recent first), paginated. +*/ + +const DEFAULT_LIMIT = 100 +const MAX_LIMIT = 100 + +class ListPostsByAddr { + constructor (localConfig = {}) { + this.adapters = localConfig.adapters + if (!this.adapters) { + throw new Error('Adapters required when instantiating ListPostsByAddr use case.') + } + if (!this.adapters.postQuery) { + throw new Error('postQuery adapter required for ListPostsByAddr use case.') + } + this.execute = this.execute.bind(this) + } + + parseLimit (limit) { + if (limit === undefined || limit === null || limit === '') { + return DEFAULT_LIMIT + } + const parsed = parseInt(limit, 10) + if (Number.isNaN(parsed) || parsed < 1) { + const err = new Error('limit must be a positive integer') + err.status = 400 + throw err + } + if (parsed > MAX_LIMIT) { + const err = new Error(`limit cannot exceed ${MAX_LIMIT}`) + err.status = 400 + throw err + } + return parsed + } + + parseOffset (offset) { + if (offset === undefined || offset === null || offset === '') { + return 0 + } + const parsed = parseInt(offset, 10) + if (Number.isNaN(parsed) || parsed < 0) { + const err = new Error('offset must be a non-negative integer') + err.status = 400 + throw err + } + return parsed + } + + parseAddr (addr) { + if (!addr || typeof addr !== 'string') { + const err = new Error('addr is required') + err.status = 400 + throw err + } + return addr + } + + sortPosts (posts) { + return posts.sort((a, b) => { + if (b.blockHeight !== a.blockHeight) { + return b.blockHeight - a.blockHeight + } + return (b.seen || 0) - (a.seen || 0) + }) + } + + async execute (inObj = {}) { + const addr = this.parseAddr(inObj.addr) + const limit = this.parseLimit(inObj.limit) + const offset = this.parseOffset(inObj.offset) + + const allPosts = await this.adapters.postQuery.scanPostsByAddr(addr) + const sorted = this.sortPosts(allPosts) + const total = sorted.length + const posts = sorted.slice(offset, offset + limit) + + return { + posts, + pagination: { + limit, + offset, + total, + hasMore: offset + posts.length < total + } + } + } +} + +export default ListPostsByAddr diff --git a/test/unit/adapters/post-query.unit.js b/test/unit/adapters/post-query.unit.js index 7fade6d..683725d 100644 --- a/test/unit/adapters/post-query.unit.js +++ b/test/unit/adapters/post-query.unit.js @@ -43,4 +43,19 @@ describe('#PostQuery', () => { assert.equal(result[0].blockHeight, 0) }) + + it('should scan posts for a single address', async () => { + async function * mockIterator () { + yield ['tx1', { addr: 'addr-a', text: 'hello', seen: 1000, blockHeight: 600100 }] + yield ['tx2', { addr: 'addr-b', text: 'world', seen: 2000, blockHeight: 600200 }] + yield ['tx3', { addr: 'addr-a', text: 'again', seen: 3000, blockHeight: 600300 }] + } + postsDb.iterator.returns(mockIterator()) + + const result = await uut.scanPostsByAddr('addr-a') + + assert.equal(result.length, 2) + assert.equal(result[0].txid, 'tx1') + assert.equal(result[1].txid, 'tx3') + }) }) diff --git a/test/unit/controllers/posts.controller.unit.js b/test/unit/controllers/posts.controller.unit.js index 62f039d..d148356 100644 --- a/test/unit/controllers/posts.controller.unit.js +++ b/test/unit/controllers/posts.controller.unit.js @@ -16,6 +16,12 @@ describe('#PostsRESTController', () => { posts: [{ txid: 'tx1', blockHeight: 600000 }], pagination: { limit: 100, offset: 0, total: 1, hasMore: false } }) + }, + listPostsByAddr: { + execute: sandbox.stub().resolves({ + posts: [{ txid: 'tx2', addr: 'addr-a', blockHeight: 600100 }], + pagination: { limit: 100, offset: 0, total: 1, hasMore: false } + }) } } }) @@ -35,4 +41,23 @@ describe('#PostsRESTController', () => { assert.equal(ctx.body.posts.length, 1) assert.equal(ctx.body.pagination.total, 1) }) + + it('should return posts by address from use case', async () => { + const ctx = { + params: { addr: 'addr-a' }, + query: { limit: '25', offset: '0' }, + body: null, + throw: sandbox.stub() + } + await uut.getPostsByAddr(ctx) + + assert.equal(uut.useCases.listPostsByAddr.execute.callCount, 1) + assert.deepEqual(uut.useCases.listPostsByAddr.execute.firstCall.args[0], { + addr: 'addr-a', + limit: '25', + offset: '0' + }) + assert.equal(ctx.body.posts.length, 1) + assert.equal(ctx.body.posts[0].txid, 'tx2') + }) }) diff --git a/test/unit/use-cases/list-posts-by-addr.unit.js b/test/unit/use-cases/list-posts-by-addr.unit.js new file mode 100644 index 0000000..97512cb --- /dev/null +++ b/test/unit/use-cases/list-posts-by-addr.unit.js @@ -0,0 +1,48 @@ +import { assert } from 'chai' +import sinon from 'sinon' +import ListPostsByAddr from '../../../src/use-cases/list-posts-by-addr.js' + +describe('#ListPostsByAddr', () => { + let uut + let sandbox + + const mockPosts = [ + { txid: 'tx-a', addr: 'addr-a', text: 'a', seen: 100, blockHeight: 600100 }, + { txid: 'tx-b', addr: 'addr-b', text: 'b', seen: 200, blockHeight: 600200 }, + { txid: 'tx-c', addr: 'addr-a', text: 'c', seen: 50, blockHeight: 600200 } + ] + + beforeEach(() => { + sandbox = sinon.createSandbox() + uut = new ListPostsByAddr({ + adapters: { + postQuery: { + scanPostsByAddr: sandbox.stub().callsFake(async (addr) => { + return mockPosts.filter((post) => post.addr === addr) + }) + } + } + }) + }) + + afterEach(() => sandbox.restore()) + + it('should return posts for an address sorted by block height descending', async () => { + const result = await uut.execute({ addr: 'addr-a', limit: 10, offset: 0 }) + + assert.equal(result.posts.length, 2) + assert.equal(result.posts[0].txid, 'tx-c') + assert.equal(result.posts[1].txid, 'tx-a') + assert.equal(result.pagination.total, 2) + }) + + it('should reject missing addr', async () => { + try { + await uut.execute({ limit: 10 }) + assert.fail('Expected error') + } catch (err) { + assert.equal(err.status, 400) + assert.include(err.message, 'addr is required') + } + }) +}) diff --git a/util/profiles/backfill_seen.js b/util/profiles/backfill_seen.js deleted file mode 100644 index 916dc07..0000000 --- a/util/profiles/backfill_seen.js +++ /dev/null @@ -1,114 +0,0 @@ -/* - Utility: backfill profile `seen` timestamps from on-chain block header times. - - Reads each profile's txid, looks up blockHeight in ptxs, fetches block header - time via BCH RPC, and writes seen = block.time * 1000 (Unix ms). - - Run from the psf-memo-db repo root (memo-db must be stopped to avoid lock conflicts): - node util/profiles/backfill_seen.js - - Environment (same as psf-memo-indexer): - RPC_IP, RPC_PORT, RPC_USER, RPC_PASS -*/ - -import 'dotenv/config' -import level from 'level' -import * as url from 'url' - -const __dirname = url.fileURLToPath(new URL('.', import.meta.url)) - -const rpcIp = process.env.RPC_IP || '172.17.0.1' -const rpcPort = process.env.RPC_PORT || '8332' -const rpcUser = process.env.RPC_USER || 'bitcoin' -const rpcPass = process.env.RPC_PASS || 'password' - -const profilesDb = level(`${__dirname}/../../leveldb/current/profiles`, { - valueEncoding: 'json' -}) - -const ptxsDb = level(`${__dirname}/../../leveldb/current/ptxs`, { - valueEncoding: 'json' -}) - -const blockTimeCache = new Map() - -async function rpcCall (method, params = []) { - const response = await fetch(`http://${rpcIp}:${rpcPort}/`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Basic ${Buffer.from(`${rpcUser}:${rpcPass}`).toString('base64')}` - }, - body: JSON.stringify({ - jsonrpc: '1.0', - id: method, - method, - params - }) - }) - - const body = await response.json() - if (body.error) { - throw new Error(body.error.message || `RPC ${method} failed`) - } - return body.result -} - -async function getBlockTimeMs (blockHeight) { - if (blockTimeCache.has(blockHeight)) { - return blockTimeCache.get(blockHeight) - } - - const hash = await rpcCall('getblockhash', [blockHeight]) - const header = await rpcCall('getblockheader', [hash, true]) - const seen = header.time * 1000 - blockTimeCache.set(blockHeight, seen) - return seen -} - -async function backfillProfiles () { - let updated = 0 - let skipped = 0 - let errors = 0 - - try { - for await (const [addr, profile] of profilesDb.iterator()) { - try { - if (!profile.txid) { - skipped++ - continue - } - - const ptx = await ptxsDb.get(profile.txid) - if (!ptx || ptx.blockHeight === undefined) { - console.warn(`No ptx blockHeight for ${addr} txid ${profile.txid}`) - skipped++ - continue - } - - const seen = await getBlockTimeMs(ptx.blockHeight) - if (profile.seen === seen) { - skipped++ - continue - } - - await profilesDb.put(addr, { ...profile, seen }) - updated++ - console.log(`${addr}: seen ${profile.seen} -> ${seen} (block ${ptx.blockHeight})`) - } catch (err) { - errors++ - console.error(`Error updating ${addr}:`, err.message) - } - } - - console.log(`\nBackfill complete. Updated: ${updated}, skipped: ${skipped}, errors: ${errors}`) - } finally { - await profilesDb.close() - await ptxsDb.close() - } -} - -backfillProfiles().catch((err) => { - console.error('Backfill failed:', err.message) - process.exit(1) -})