mirror of
https://github.com/Permissionless-Software-Foundation/psf-memo-db.git
synced 2026-09-21 16:52:02 -07:00
Adding endpoints for profile page
This commit is contained in:
@@ -27,6 +27,23 @@ class PostQuery {
|
|||||||
|
|
||||||
return posts
|
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
|
export default PostQuery
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ class PostsRESTControllerLib {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.getRecentPosts = this.getRecentPosts.bind(this)
|
this.getRecentPosts = this.getRecentPosts.bind(this)
|
||||||
|
this.getPostsByAddr = this.getPostsByAddr.bind(this)
|
||||||
this.handleError = this.handleError.bind(this)
|
this.handleError = this.handleError.bind(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,6 +63,39 @@ class PostsRESTControllerLib {
|
|||||||
this.handleError(ctx, err)
|
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
|
export default PostsRESTControllerLib
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ class PostsRouter {
|
|||||||
|
|
||||||
attach (app) {
|
attach (app) {
|
||||||
this.router.get('/recent', this.postsRESTController.getRecentPosts)
|
this.router.get('/recent', this.postsRESTController.getRecentPosts)
|
||||||
|
this.router.get('/by/:addr', this.postsRESTController.getPostsByAddr)
|
||||||
app.use(this.router.routes())
|
app.use(this.router.routes())
|
||||||
app.use(this.router.allowedMethods())
|
app.use(this.router.allowedMethods())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
import ListRecentProfiles from './list-recent-profiles.js'
|
import ListRecentProfiles from './list-recent-profiles.js'
|
||||||
import ListRecentPosts from './list-recent-posts.js'
|
import ListRecentPosts from './list-recent-posts.js'
|
||||||
|
import ListPostsByAddr from './list-posts-by-addr.js'
|
||||||
|
|
||||||
class UseCases {
|
class UseCases {
|
||||||
constructor (localConfig = {}) {
|
constructor (localConfig = {}) {
|
||||||
@@ -14,11 +15,13 @@ class UseCases {
|
|||||||
|
|
||||||
this.listRecentProfiles = null
|
this.listRecentProfiles = null
|
||||||
this.listRecentPosts = null
|
this.listRecentPosts = null
|
||||||
|
this.listPostsByAddr = null
|
||||||
}
|
}
|
||||||
|
|
||||||
async start () {
|
async start () {
|
||||||
this.listRecentProfiles = new ListRecentProfiles({ adapters: this.adapters })
|
this.listRecentProfiles = new ListRecentProfiles({ adapters: this.adapters })
|
||||||
this.listRecentPosts = new ListRecentPosts({ adapters: this.adapters })
|
this.listRecentPosts = new ListRecentPosts({ adapters: this.adapters })
|
||||||
|
this.listPostsByAddr = new ListPostsByAddr({ adapters: this.adapters })
|
||||||
console.log('Use cases initialized.')
|
console.log('Use cases initialized.')
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -43,4 +43,19 @@ describe('#PostQuery', () => {
|
|||||||
|
|
||||||
assert.equal(result[0].blockHeight, 0)
|
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')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -16,6 +16,12 @@ describe('#PostsRESTController', () => {
|
|||||||
posts: [{ txid: 'tx1', blockHeight: 600000 }],
|
posts: [{ txid: 'tx1', blockHeight: 600000 }],
|
||||||
pagination: { limit: 100, offset: 0, total: 1, hasMore: false }
|
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.posts.length, 1)
|
||||||
assert.equal(ctx.body.pagination.total, 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')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
|
||||||
})
|
|
||||||
Reference in New Issue
Block a user