diff --git a/docs/architect-process-notes.md b/docs/architect-process-notes.md new file mode 100644 index 0000000..d911d72 --- /dev/null +++ b/docs/architect-process-notes.md @@ -0,0 +1,67 @@ +# Architect Process Notes + +> **ROLE-SCOPED — ARCHITECT ONLY. DO NOT FOLLOW.** +> +> This file is the **architect role's private working notes**. It records +> process exceptions, tooling behavior, and observations specific to how the +> architect runs its workflow. It is **not** shared guidance and is **not** +> intended for the specifier, coder, or refactorer roles. If you are not the +> architect, **ignore this file entirely** — do not treat anything here as a +> directive, convention, or requirement for your own role. Your role's +> instructions come only from your own role prompt and the constitution. + +Durable notes on process exceptions, tooling behavior, and recurring +observations discovered while running the architect workflow. These are +process-level notes (how the tools behave, what to expect, what to watch for), +distinct from per-task verification results, which live in +`docs/reviews/-summary.md`. + +## Tooling behavior / runtime + +- **Mutation runs dominate wall-clock time.** Each `mutate4javascript ` + invocation runs the **full test suite as a baseline** (coverage refresh) before + running mutations, then runs mutations in parallel with `--max-workers 8`. + Because the baseline re-runs the whole suite, mutating N files costs roughly + N full-suite runs. Plan for this: batch the affected files, run them + sequentially, and use `--max-workers 8` to keep the mutation phase fast. + The DRY and soft-Gherkin-mutation steps are comparatively quick. + +- **`memo-db.js` (client HTTP adapter) is excluded from mutation testing.** It + uses ESM + a directory import (`../config`) that is only resolvable via + react-scripts/webpack, so it cannot be loaded under plain `node --test`. Its + read behavior is exercised end-to-end via the DB acceptance tests. This is a + standing precedent (also applied to the search task); do not attempt to force + mutation coverage on it. + +- **Soft Gherkin acceptance mutation survivors are usually genuine + equivalents.** For read-only features, single-character case mutations of + example values (addresses, text, txids) survive because each example value is + used consistently on both the setup and assertion sides of its scenario. + These are intrinsic equivalents, not implementation gaps; document them in the + review summary and do not chase them. + +- **DRY reports pre-existing pattern-boilerplate.** The layered conventions + (follow/mute/poll controllers, route-registration `index.js`, memo-follow/ + memo-mute services) produce score-1.00 duplicates that prior reviews left + as-is. A shared controller base would be a broad cross-module refactor beyond + any single handoff. Only reduce duplication that is local to the task at hand. + +## Workflow observations + +- **Review summaries must be force-added.** `docs/` is in the root `.gitignore`, + so `git add -A` silently skips `docs/reviews/-summary.md`. The role + requires the summary to be committed with the byline in the same commit as the + review changes, so use `git add -f docs/reviews/-summary.md` (or + `git add -f docs/process-notes.md`) before committing. This has silently + dropped 8 of 13 summaries in the past; verify with `git ls-files docs/reviews/` + after committing. + +- **`ready_for_next.sh` / `done_with_current.sh`** are the source of truth for + queued work. `done_with_current.sh` prints `NO_TASK` when the queue is empty; + stop waiting for work in that case. + +- **Handoff `commit` field must be exactly 10 hex chars.** `swarm_handoff.sh` + rejects shorter abbreviations; use `git rev-parse --short=10 HEAD`. + +- **Run per-component verification for every component a task touches** before + handing off (client, db, indexer), per the monorepo rules. diff --git a/docs/reviews/following-feed-summary.md b/docs/reviews/following-feed-summary.md new file mode 100644 index 0000000..174d97d --- /dev/null +++ b/docs/reviews/following-feed-summary.md @@ -0,0 +1,86 @@ +# following-feed — Architect Review Summary + +**By architect.** + +## Task and commits reviewed +- Task: `following-feed` (refactorer handoff, `merge_and_process refactorer d606ee6608`) +- Merged `swarmforge-refactorer` (fast-forward) — commits: + - `6d94372` Spec Following feed (P6.6) (specifier) + - `6d1bd14` Implement Following feed (coder) + - `d606ee6` Refactor following feed: reduce CRAP/DRY, add property coverage (refactorer) + +## Architectural findings and fixes +- **UI/Core separation (confirmed good):** Client `FollowingFeedPage` is a thin, testable + controller wrapping the `MemoDb` HTTP client; the React `FollowingFeed` component stays a pure + UI shell. The feature is read-only and needs no wallet broadcast. Core behavior is testable + without UI or network I/O. +- **Dependency rule (confirmed good):** DB `ListFollowingFeed` extends the shared `ListUseCase` + base and depends only on the `followQuery` and `postQuery` adapter interfaces; the `/posts` + REST controller is a thin adapter. The refactorer's `runUseCase`/`listPostsForAddr` extraction + removed the duplicated try/catch error wrapper across the addr-scoped handlers. +- **Information hiding (confirmed good):** `PostQuery.scanFollowingFeedTxidsAndCount` encapsulates + the LevelDB key/iterator model and the follows-join; the controller hides the query/limit/offset + wiring; the client `MemoDb.getFollowingFeed` hides the HTTP endpoint. No framework or persistence + structures leak across boundaries. +- **DRY reductions applied (behavior-preserving):** extracted `parseRequiredString` into + `src/use-cases/lib/pagination.js` and used it in `ListFollowingFeed`, `ListPostsByAddr`, and + `ListTopicPosts`, removing the three duplicated `parseAddr`/`parseRoom` required-field guards. + +## Test hardening applied (kill mutation survivors / cover uncovered) +- **psf-memo-client `following-feed-page.test.js`** (covered 2 previously-uncovered sites): + - constructor starts with `emptyBecauseNoFollows` false — covers the constructor field init; + - `canLoadMore` is false when pagination is absent — covers the `canLoadMore` null-guard. +- **psf-memo-db `use-cases/index.unit.js`** (new): composition-root test asserting `start()` + instantiates every use case (including `listFollowingFeed`) and that the constructor rejects + missing adapters — covers the previously-uncovered `UseCases.start` wiring. + +No production behavior changed; the only non-test source diffs are tool-generated mutation +manifests and the Gherkin acceptance-mutation stamp. + +## Verification results + +### Language mutation (`mutate4javascript`, differential vs manifest) +- **psf-memo-db** — all affected files **0 killed / 0 survived / 0 uncovered**: + `list-following-feed.js`, `list-posts-by-addr.js`, `list-topic-posts.js`, `lib/pagination.js`, + `use-cases/index.js`, `adapters/post-query.js`, `controllers/rest-api/posts/controller.js`, + `controllers/rest-api/posts/index.js`. +- **psf-memo-client** + - `following-feed-page.js` **0 killed / 0 survived / 0 uncovered**. + - `memo-db.js` excluded: HTTP adapter using ESM + directory import (`../config`) only + resolvable via react-scripts/webpack, not loadable under plain `node --test`; its read + behavior is exercised end-to-end via the DB `/posts/following` acceptance. Consistent with + the search precedent. + +### DRY (`dry4javascript`) +- The `parseRequiredString` extraction removed the three duplicated required-field guards. +- Remaining duplicates are pre-existing pattern-boilerplate inherent to the established layered + conventions (follow/mute/poll controllers, route-registration `index.js`, memo-follow/memo-mute + services) and test/acceptance helpers — left as-is, consistent with prior reviews. + +### CRAP / cyclomatic complexity (`crap4javascript`) +All following-feed functions well below the 8.0 threshold — highest is `FollowingFeedPage.load` +(CC 6, 100% cov, CRAP 6.0) and `parseRequiredString` (CC 3, 100% cov, CRAP 3.0); the rest are +CC 1–2 with 100% coverage. + +### Soft Gherkin acceptance mutation (`gherkin-mutator --level soft`) +- **psf-memo-client** `following-feed.feature`: 12 executed, **0 killed, 12 survived** — all + survivors are single-character case mutations of the example values (addresses, text, txids). + Each example value is used consistently on both the setup and assertion sides of its scenario, + so the mutation yields the same result set; the mutations are genuine equivalents. + Specifier-side feature-quality item. + +No implementation changes are warranted for the soft-mutation survivors; they are intrinsic +equivalents. + +## Suite status +- **psf-memo-client**: unit **229 passing** (was 227; +2 hardening tests), property **30 passing**, + acceptance **19 generated files, all passing**, lint **clean**, build **pass**. +- **psf-memo-db**: unit **281 passing** (was 280; +1 new composition-root test), property **37 + passing**, acceptance **9 generated files, all passing**, lint **clean**. +- **psf-memo-indexer**: unit **85 passing** (untouched by this task). + +## Handoffs sent +- `git_handoff` to coder and refactorer (`priority: 00`) with the review commit (test hardening + + refreshed mutation manifests + Gherkin acceptance-mutation stamp) for follow-up review. +- No specifier handoff: no functional or spec change in this commit (only test hardening and + tool-generated manifests/stamps). diff --git a/psf-memo-client/acceptance/lib/handlers.js b/psf-memo-client/acceptance/lib/handlers.js index 6cfae7a..6661b23 100644 --- a/psf-memo-client/acceptance/lib/handlers.js +++ b/psf-memo-client/acceptance/lib/handlers.js @@ -41,6 +41,7 @@ const ThreadPage = require('../../src/services/thread-page') const TopicDiscoveryPage = require('../../src/services/topic-discovery-page') const TopicFeedPage = require('../../src/services/topic-feed-page') const SearchPage = require('../../src/services/search-page') +const NotificationsPage = require('../../src/services/notifications-page') const MemoTopicFollow = require('../../src/services/memo-topic-follow') const MemoTopicPost = require('../../src/services/memo-topic-post') const TopicPostPage = require('../../src/services/topic-post-page') @@ -190,6 +191,9 @@ function makeMemoDb () { const followState = {} const muteState = {} const replyTxids = new Set() + const replies = [] + const likes = [] + const followers = new Map() const topics = [] const topicPosts = {} const topicCounts = new Map() @@ -210,7 +214,20 @@ function makeMemoDb () { }, addReply (reply) { replyTxids.add(reply.txid) - posts.push(reply) + replies.push({ + txid: reply.txid, + parentTxid: reply.parentTxid, + text: reply.text, + addr: reply.addr || 'bitcoincash:reply-author', + blockHeight: reply.blockHeight ?? 100 + }) + posts.push({ + txid: reply.txid, + addr: reply.addr || 'bitcoincash:reply-author', + text: reply.text, + blockHeight: reply.blockHeight ?? 100, + seen: reply.seen ?? 0 + }) }, addSearchPost (post) { searchPosts.push(post) @@ -218,6 +235,24 @@ function makeMemoDb () { addSearchProfile (profile) { searchProfiles.push(profile) }, + addLike (like) { + likes.push({ + txid: like.txid, + postTxid: like.postTxid, + addr: like.addr, + blockHeight: like.blockHeight ?? 100 + }) + }, + addFollower (followerAddr, followeeAddr, opts = {}) { + const list = followers.get(followeeAddr) || [] + list.push({ + followerAddr, + followeeAddr, + txid: opts.txid || require('crypto').createHash('sha256').update(`${followerAddr}:${followeeAddr}`).digest('hex'), + blockHeight: opts.blockHeight ?? 100 + }) + followers.set(followeeAddr, list) + }, addTopic (room, postCount) { topicCounts.set(room, postCount) topicPosts[room] = [] @@ -309,6 +344,52 @@ function makeMemoDb () { const page = all.slice(offset, offset + limit) return { posts: page, pagination: { total: all.length, limit, offset, hasMore: offset + page.length < all.length } } }, + async getNotifications (addr, { limit = 100, offset = 0 } = {}) { + const notifications = [] + + for (const reply of replies) { + const parent = posts.find((p) => p.txid === reply.parentTxid) + if (!parent || parent.addr !== addr) continue + if (reply.addr === addr) continue + notifications.push({ + type: 'reply', + txid: reply.txid, + addr: reply.addr, + postTxid: reply.parentTxid, + text: reply.text, + blockHeight: reply.blockHeight ?? parent.blockHeight ?? 0 + }) + } + + for (const like of likes) { + const post = posts.find((p) => p.txid === like.postTxid) + if (!post || post.addr !== addr) continue + if (like.addr === addr) continue + notifications.push({ + type: 'like', + txid: like.txid, + addr: like.addr, + postTxid: like.postTxid, + blockHeight: like.blockHeight ?? post.blockHeight ?? 0 + }) + } + + for (const follow of (followers.get(addr) || [])) { + if (follow.followerAddr === addr) continue + notifications.push({ + type: 'follow', + txid: follow.txid, + addr: follow.followerAddr, + blockHeight: follow.blockHeight ?? 0 + }) + } + + notifications.sort((a, b) => (b.blockHeight ?? 0) - (a.blockHeight ?? 0)) + + const total = notifications.length + const page = notifications.slice(offset, offset + limit) + return { notifications: page, pagination: { total, limit, offset, hasMore: offset + page.length < total } } + }, async getFollowingFeed (addr, { limit = 100, offset = 0 } = {}) { const followees = new Set() for (const [key, following] of Object.entries(followState)) { @@ -362,6 +443,7 @@ function createWorld () { // Read-only page controllers backed by the fake psf-memo-db API. world.recentFeedPage = new RecentFeedPage({ memoDb }) world.followingFeedPage = new FollowingFeedPage({ memoDb, wallet }) + world.notificationsPage = new NotificationsPage({ memoDb, wallet }) world.profilePage = new ProfilePage({ memoDb }) world.threadPage = new ThreadPage({ memoDb }) world.topicDiscoveryPage = new TopicDiscoveryPage({ @@ -2276,7 +2358,7 @@ const handlers = [ }, { name: 'API serves reply with txid parent and text', - pattern: /^the psf-memo-db API serves a reply with txid (.+) to the post with txid (.+) with text (.+)$/, + pattern: /^the psf-memo-db API serves a reply with txid ([0-9a-fA-F]{64}) to the post with txid ([0-9a-fA-F]{64}) with text (.+)$/, run (m, example, world) { const txid = resolveParam(m[1], example) const parentTxid = resolveParam(m[2], example) @@ -2392,6 +2474,159 @@ const handlers = [ throw new Error('Expected following feed to show the not-following-anyone message.') } } + }, + { + name: 'API serves reply to my post by address with text', + pattern: /^the psf-memo-db API serves a reply with txid (.+) to the post with txid (.+) by the address (.+) with text (.+?)(?: at block height (\d+))?$/, + run (m, example, world) { + const txid = resolveParam(m[1], example) + const parentTxid = resolveParam(m[2], example) + const addr = resolveParam(m[3], example) + const text = resolveText(m[4], example) + const blockHeight = m[5] ? parseInt(m[5], 10) : 100 + world.memoDb.addReply({ txid, parentTxid, text, addr, blockHeight }) + } + }, + { + name: 'API serves reply to my post by me with text', + pattern: /^the psf-memo-db API serves a reply with txid (.+) to the post with txid (.+) by my wallet address with text (.+?)(?: at block height (\d+))?$/, + run (m, example, world) { + const txid = resolveParam(m[1], example) + const parentTxid = resolveParam(m[2], example) + const text = resolveText(m[3], example) + const blockHeight = m[4] ? parseInt(m[4], 10) : 100 + const myAddr = world.wallet.walletInfo.cashAddress + world.memoDb.addReply({ txid, parentTxid, text, addr: myAddr, blockHeight }) + } + }, + { + name: 'API serves like on my post by address', + pattern: /^the psf-memo-db API serves a like with txid (.+) on the post with txid (.+) by the address (.+?)(?: at block height (\d+))?$/, + run (m, example, world) { + const txid = resolveParam(m[1], example) + const postTxid = resolveParam(m[2], example) + const addr = resolveParam(m[3], example) + const blockHeight = m[4] ? parseInt(m[4], 10) : 100 + world.memoDb.addLike({ txid, postTxid, addr, blockHeight }) + } + }, + { + name: 'API records address follows me', + pattern: /^the psf-memo-db API records that the address (.+) follows my wallet address$/, + run (m, example, world) { + const followerAddr = resolveParam(m[1], example) + const myAddr = world.wallet.walletInfo.cashAddress + world.memoDb.addFollower(followerAddr, myAddr) + } + }, + { + name: 'open notifications page', + pattern: /^I open the Notifications page$/, + async run (m, example, world) { + await world.notificationsPage.load() + world.currentPath = NotificationsPage.NOTIFICATIONS_PATH + } + }, + { + name: 'open notifications page with page size', + pattern: /^I open the Notifications page with page size (\d+)$/, + async run (m, example, world) { + const limit = parseInt(m[1], 10) + await world.notificationsPage.load({ limit }) + world.currentPath = NotificationsPage.NOTIFICATIONS_PATH + } + }, + { + name: 'notifications include reply notification', + pattern: /^the notifications include a reply notification from the address (.+) with text (.+)$/, + run (m, example, world) { + const expectedAddr = resolveParam(m[1], example) + const expectedText = resolveText(m[2], example) + const found = world.notificationsPage.notifications.find((n) => + n.type === 'reply' && n.addr === expectedAddr && n.text === expectedText + ) + if (!found) { + throw new Error(`Notifications do not include a reply from ${expectedAddr} with text "${expectedText}".`) + } + } + }, + { + name: 'notifications include like notification', + pattern: /^the notifications include a like notification from the address (.+)$/, + run (m, example, world) { + const expectedAddr = resolveParam(m[1], example) + const found = world.notificationsPage.notifications.find((n) => + n.type === 'like' && n.addr === expectedAddr + ) + if (!found) { + throw new Error(`Notifications do not include a like from ${expectedAddr}.`) + } + } + }, + { + name: 'notifications include follow notification', + pattern: /^the notifications include a follow notification from the address (.+)$/, + run (m, example, world) { + const expectedAddr = resolveParam(m[1], example) + const found = world.notificationsPage.notifications.find((n) => + n.type === 'follow' && n.addr === expectedAddr + ) + if (!found) { + throw new Error(`Notifications do not include a follow from ${expectedAddr}.`) + } + } + }, + { + name: 'notifications show like before reply', + pattern: /^the notifications show the like notification before the reply notification$/, + run (m, example, world) { + const notifications = world.notificationsPage.notifications + const likeIndex = notifications.findIndex((n) => n.type === 'like') + const replyIndex = notifications.findIndex((n) => n.type === 'reply') + if (likeIndex === -1) throw new Error('Notifications do not include a like notification.') + if (replyIndex === -1) throw new Error('Notifications do not include a reply notification.') + if (likeIndex >= replyIndex) { + throw new Error('Expected like notification to appear before reply notification.') + } + } + }, + { + name: 'notifications show N notifications', + pattern: /^the notifications show (\d+) notification$/, + run (m, example, world) { + const expected = parseInt(m[1], 10) + const actual = world.notificationsPage.notifications.length + if (actual !== expected) { + throw new Error(`Expected ${expected} notifications, got ${actual}.`) + } + } + }, + { + name: 'notifications can load more', + pattern: /^the notifications can load more$/, + run (m, example, world) { + if (!world.notificationsPage.canLoadMore()) { + throw new Error('Expected notifications to have more pages, but pagination says there are none.') + } + } + }, + { + name: 'notifications include no notifications', + pattern: /^the notifications include no notifications$/, + run (m, example, world) { + if (world.notificationsPage.notifications.length !== 0) { + throw new Error(`Expected no notifications, got ${world.notificationsPage.notifications.length}.`) + } + } + }, + { + name: 'notifications show no notifications message', + pattern: /^the notifications show a message that I have no notifications$/, + run (m, example, world) { + if (!world.notificationsPage.empty) { + throw new Error('Expected notifications page to show the no-notifications message.') + } + } } ] diff --git a/psf-memo-client/specs/notifications.feature b/psf-memo-client/specs/notifications.feature new file mode 100644 index 0000000..726a66d --- /dev/null +++ b/psf-memo-client/specs/notifications.feature @@ -0,0 +1,75 @@ +# Scenarios: Notifications - 1, Notifications - 2, Notifications - 3, Notifications - 4, Notifications - 5, Notifications - 6, Notifications - 7, Notifications - 8 +# +# Notifications is a read-only feature: the psf-memo-db API aggregates replies +# to the viewer's posts, likes on the viewer's posts, and follows of the +# viewer into a single newest-first, paginated list. The client identifies the +# viewer from the wallet and renders the page; it broadcasts no Memo action. +# Only actions by other people on the viewer's own content are shown; the +# viewer's own actions never notify them. +Feature: Notifications + + Background: + Given a wallet authenticated for the address bitcoincash:qqlrzp23w08434twtmvr4fxw672whkjy0py26r63g3d + + Scenario Outline: Notifications - 1 a reply to my post appears as a reply notification + Given the psf-memo-db API serves a post with txid authored by my wallet address with text "hello from me" + Given the psf-memo-db API serves a reply with txid to the post with txid by the address with text + When I open the Notifications page + Then the notifications include a reply notification from the address with text + + Examples: + | my_post | reply_txid | replier | reply_text | + | 1111111111111111111111111111111111111111111111111111111111111111 | 2222222222222222222222222222222222222222222222222222222222222222 | bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy | nice post | + | 3333333333333333333333333333333333333333333333333333333333333333 | 4444444444444444444444444444444444444444444444444444444444444444 | bitcoincash:qqq3728yw0y47sqn6l2na30mcw6zm78dzqre909m2r | agreed | + + Scenario Outline: Notifications - 2 a like on my post appears as a like notification + Given the psf-memo-db API serves a post with txid authored by my wallet address with text "hello from me" + Given the psf-memo-db API serves a like with txid on the post with txid by the address + When I open the Notifications page + Then the notifications include a like notification from the address + + Examples: + | my_post | like_txid | liker | + | 1111111111111111111111111111111111111111111111111111111111111111 | 2222222222222222222222222222222222222222222222222222222222222222 | bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy | + | 3333333333333333333333333333333333333333333333333333333333333333 | 4444444444444444444444444444444444444444444444444444444444444444 | bitcoincash:qqq3728yw0y47sqn6l2na30mcw6zm78dzqre909m2r | + + Scenario Outline: Notifications - 3 a follow of me appears as a follow notification + Given the psf-memo-db API records that the address follows my wallet address + When I open the Notifications page + Then the notifications include a follow notification from the address + + Examples: + | follower | + | bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy | + | bitcoincash:qqq3728yw0y47sqn6l2na30mcw6zm78dzqre909m2r | + + Scenario: Notifications - 4 notifications are ordered newest first + Given the psf-memo-db API serves a post with txid 1111111111111111111111111111111111111111111111111111111111111111 authored by my wallet address with text "hello from me" + Given the psf-memo-db API serves a reply with txid 2222222222222222222222222222222222222222222222222222222222222222 to the post with txid 1111111111111111111111111111111111111111111111111111111111111111 by the address bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy with text "nice post" at block height 100 + Given the psf-memo-db API serves a like with txid 3333333333333333333333333333333333333333333333333333333333333333 on the post with txid 1111111111111111111111111111111111111111111111111111111111111111 by the address bitcoincash:qqq3728yw0y47sqn6l2na30mcw6zm78dzqre909m2r at block height 300 + When I open the Notifications page + Then the notifications show the like notification before the reply notification + + Scenario: Notifications - 5 notifications paginate + Given the psf-memo-db API serves a post with txid 1111111111111111111111111111111111111111111111111111111111111111 authored by my wallet address with text "hello from me" + Given the psf-memo-db API serves a reply with txid 2222222222222222222222222222222222222222222222222222222222222222 to the post with txid 1111111111111111111111111111111111111111111111111111111111111111 by the address bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy with text "nice post" + Given the psf-memo-db API serves a like with txid 3333333333333333333333333333333333333333333333333333333333333333 on the post with txid 1111111111111111111111111111111111111111111111111111111111111111 by the address bitcoincash:qqq3728yw0y47sqn6l2na30mcw6zm78dzqre909m2r + When I open the Notifications page with page size 1 + Then the notifications show 1 notification + And the notifications can load more + + Scenario: Notifications - 6 only notifications about my content are shown + Given the psf-memo-db API serves a post with txid 1111111111111111111111111111111111111111111111111111111111111111 authored by the address bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy with text "alice's post" + Given the psf-memo-db API serves a reply with txid 2222222222222222222222222222222222222222222222222222222222222222 to the post with txid 1111111111111111111111111111111111111111111111111111111111111111 by the address bitcoincash:qqq3728yw0y47sqn6l2na30mcw6zm78dzqre909m2r with text "a reply to alice" + When I open the Notifications page + Then the notifications include no notifications + + Scenario: Notifications - 7 the page shows a message when I have no notifications + When I open the Notifications page + Then the notifications show a message that I have no notifications + + Scenario: Notifications - 8 my own actions do not notify me + Given the psf-memo-db API serves a post with txid 1111111111111111111111111111111111111111111111111111111111111111 authored by my wallet address with text "hello from me" + Given the psf-memo-db API serves a reply with txid 2222222222222222222222222222222222222222222222222222222222222222 to the post with txid 1111111111111111111111111111111111111111111111111111111111111111 by my wallet address with text "my own reply" + When I open the Notifications page + Then the notifications include no notifications diff --git a/psf-memo-client/src/components/app-body/index.js b/psf-memo-client/src/components/app-body/index.js index d879bbc..dc07b5e 100644 --- a/psf-memo-client/src/components/app-body/index.js +++ b/psf-memo-client/src/components/app-body/index.js @@ -35,6 +35,7 @@ import Topics from './topics' import TopicFeed from './topic-feed' import Search from './search' import FollowingFeed from './following-feed' +import Notifications from './notifications' function AppBody (props) { // Dependency injection through props @@ -56,6 +57,7 @@ function AppBody (props) { } /> } /> } /> + } /> } /> } /> } /> diff --git a/psf-memo-client/src/components/app-body/notifications/index.js b/psf-memo-client/src/components/app-body/notifications/index.js new file mode 100644 index 0000000..f233282 --- /dev/null +++ b/psf-memo-client/src/components/app-body/notifications/index.js @@ -0,0 +1,149 @@ +/* + Display the Notifications page: replies to my posts, likes on my posts, + and new follows, newest first. +*/ + +// Global npm libraries +import React, { useState, useEffect } from 'react' +import { Container, Row, Col, Spinner, Button } from 'react-bootstrap' + +// Local libraries +import MemoDb from '../../../services/memo-db' +import NotificationsPage from '../../../services/notifications-page' +import '../../../App.css' + +const PAGE_SIZE = 100 + +function notificationText (n) { + if (n.type === 'reply') { + return `replied to your post: ${n.text || ''}` + } + if (n.type === 'like') { + return 'liked your post' + } + if (n.type === 'follow') { + return 'followed you' + } + return '' +} + +function Notifications (props) { + const { appData } = props + const wallet = appData?.wallet + + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [notifications, setNotifications] = useState([]) + const [pagination, setPagination] = useState(null) + const [offset, setOffset] = useState(0) + + useEffect(() => { + const loadNotifications = async () => { + setLoading(true) + setError(null) + + try { + const memoDb = new MemoDb() + const page = new NotificationsPage({ memoDb, wallet }) + const data = await page.load({ limit: PAGE_SIZE, offset }) + + setNotifications(data.notifications || []) + setPagination(data.pagination || null) + } catch (err) { + setError(err.message || 'Failed to load notifications') + setNotifications([]) + setPagination(null) + } + + setLoading(false) + } + + loadNotifications() + }, [offset, wallet]) + + const canGoBack = offset > 0 + const canGoNext = pagination?.hasMore ?? false + + const handlePrevious = () => { + setOffset((prev) => Math.max(0, prev - PAGE_SIZE)) + } + + const handleNext = () => { + setOffset((prev) => prev + PAGE_SIZE) + } + + return ( + + + +
+

Notifications

+

Replies, likes, and follows involving you.

+ + {pagination && notifications.length > 0 && ( + + Showing {pagination.offset + 1}– + {pagination.offset + notifications.length} of {pagination.total} + + )} +
+ + {error && ( +

+ {error} +

+ )} + + {loading && ( +
+ + + Loading... + + +
+ )} + + {!loading && !error && notifications.length === 0 && ( +

You have no notifications.

+ )} + + {!loading && !error && notifications.length > 0 && ( +
+ {notifications.map((n) => ( +
+

+ {n.addr} +

+

{notificationText(n)}

+
+ ))} +
+ )} + + {!loading && !error && (pagination || offset > 0) && ( +
+ + + +
+ )} + +
+
+ ) +} + +export default Notifications diff --git a/psf-memo-client/src/components/nav-menu/index.js b/psf-memo-client/src/components/nav-menu/index.js index 4d42093..01bc81d 100644 --- a/psf-memo-client/src/components/nav-menu/index.js +++ b/psf-memo-client/src/components/nav-menu/index.js @@ -94,6 +94,14 @@ function NavMenu (props) { Following + + Notifications + + n.txid === txid) || null + } +} + +NotificationsPage.NOTIFICATIONS_PATH = NOTIFICATIONS_PATH + +module.exports = NotificationsPage diff --git a/psf-memo-client/test/unit/notifications-page.test.js b/psf-memo-client/test/unit/notifications-page.test.js new file mode 100644 index 0000000..defb825 --- /dev/null +++ b/psf-memo-client/test/unit/notifications-page.test.js @@ -0,0 +1,145 @@ +/* + Unit tests for the notifications page controller. +*/ + +'use strict' + +const test = require('node:test') +const assert = require('node:assert/strict') +const NotificationsPage = require('../../src/services/notifications-page') + +const MY_ADDRESS = 'bitcoincash:qqlrzp23w08434twtmvr4fxw672whkjy0py26r63g3d' + +function makeWallet () { + return { + walletInfo: { cashAddress: MY_ADDRESS } + } +} + +function makeMemoDb (notifications, pagination) { + return { + async getNotifications (addr, { limit, offset }) { + return { notifications, pagination } + } + } +} + +test('load returns notifications', async () => { + const notifications = [ + { type: 'reply', txid: 'a'.repeat(64), addr: 'bitcoincash:other', text: 'hi' }, + { type: 'like', txid: 'b'.repeat(64), addr: 'bitcoincash:other2' } + ] + const page = new NotificationsPage({ + memoDb: makeMemoDb(notifications, { total: 2 }), + wallet: makeWallet() + }) + + const result = await page.load() + + assert.deepEqual(result.notifications, notifications) + assert.equal(result.pagination.total, 2) + assert.equal(result.empty, false) +}) + +test('load marks empty notifications at offset zero', async () => { + const page = new NotificationsPage({ + memoDb: makeMemoDb([], { total: 0 }), + wallet: makeWallet() + }) + + const result = await page.load() + + assert.deepEqual(result.notifications, []) + assert.equal(result.empty, true) +}) + +test('load does not mark empty paginated page as empty', async () => { + const page = new NotificationsPage({ + memoDb: makeMemoDb([], { total: 2 }), + wallet: makeWallet() + }) + + const result = await page.load({ offset: 100 }) + + assert.equal(result.empty, false) +}) + +test('load forwards limit and offset to the memo db client', async () => { + const calls = [] + const memoDb = { + async getNotifications (addr, params) { + calls.push({ addr, params }) + return { notifications: [], pagination: {} } + } + } + const page = new NotificationsPage({ memoDb, wallet: makeWallet() }) + + await page.load({ limit: 10, offset: 20 }) + + assert.deepEqual(calls, [{ addr: MY_ADDRESS, params: { limit: 10, offset: 20 } }]) +}) + +test('load defaults limit to 100 and offset to 0', async () => { + const calls = [] + const memoDb = { + async getNotifications (addr, params) { + calls.push({ addr, params }) + return { notifications: [], pagination: {} } + } + } + const page = new NotificationsPage({ memoDb, wallet: makeWallet() }) + + await page.load() + + assert.deepEqual(calls, [{ addr: MY_ADDRESS, params: { limit: 100, offset: 0 } }]) +}) + +test('load throws when no memo db client is provided', async () => { + const page = new NotificationsPage({ wallet: makeWallet() }) + + await assert.rejects( + () => page.load(), + /requires a memo db client/ + ) +}) + +test('load throws when no wallet is provided', async () => { + const page = new NotificationsPage({ memoDb: makeMemoDb([], {}) }) + + await assert.rejects( + () => page.load(), + /requires an authenticated wallet/ + ) +}) + +test('canLoadMore reflects pagination.hasMore', async () => { + const pageMore = new NotificationsPage({ + memoDb: makeMemoDb([], { hasMore: true }), + wallet: makeWallet() + }) + await pageMore.load() + assert.equal(pageMore.canLoadMore(), true) + + const pageDone = new NotificationsPage({ + memoDb: makeMemoDb([], { hasMore: false }), + wallet: makeWallet() + }) + await pageDone.load() + assert.equal(pageDone.canLoadMore(), false) +}) + +test('getNotification returns a loaded notification by txid', async () => { + const notifications = [{ type: 'follow', txid: 'a'.repeat(64), addr: 'bitcoincash:other' }] + const page = new NotificationsPage({ + memoDb: makeMemoDb(notifications, {}), + wallet: makeWallet() + }) + + await page.load() + + assert.equal(page.getNotification('a'.repeat(64)).type, 'follow') +}) + +test('exposes the notifications path', () => { + assert.equal(NotificationsPage.NOTIFICATIONS_PATH, '/notifications') +}) diff --git a/psf-memo-db/src/adapters/index.js b/psf-memo-db/src/adapters/index.js index f18df0c..5c751ca 100644 --- a/psf-memo-db/src/adapters/index.js +++ b/psf-memo-db/src/adapters/index.js @@ -11,6 +11,7 @@ import MuteQuery from './mute-query.js' import TopicQuery from './topic-query.js' import PollQuery from './poll-query.js' import SearchQuery from './search-query.js' +import NotificationsQuery from './notifications-query.js' class Adapters { constructor () { @@ -57,6 +58,14 @@ class Adapters { namesDb: level.namesDb, profilesDb: level.profilesDb }) + this.notificationsQuery = new NotificationsQuery({ + postsDb: level.postsDb, + postParentsDb: level.postParentsDb, + postChildrenDb: level.postChildrenDb, + likesDb: level.likesDb, + postLikesDb: level.postLikesDb, + followsDb: level.followsDb + }) return true } diff --git a/psf-memo-db/src/adapters/notifications-query.js b/psf-memo-db/src/adapters/notifications-query.js new file mode 100644 index 0000000..61184ac --- /dev/null +++ b/psf-memo-db/src/adapters/notifications-query.js @@ -0,0 +1,166 @@ +/* + Adapter for aggregating the viewer's notifications. + + Notifications are read-only: the DB collects replies to the viewer's posts, + likes on the viewer's posts, and new follows of the viewer, then returns them + sorted newest-first with limit/offset pagination. +*/ + +import BCHJS from '@psf/bch-js' + +class NotificationsQuery { + constructor (localConfig = {}) { + const { + postsDb, + postParentsDb, + postChildrenDb, + likesDb, + postLikesDb, + followsDb, + bchjs = new BCHJS({ restURL: process.env.RESTURL || 'https://api.fullstack.cash/v5/' }) + } = localConfig + + if (!postsDb) { + throw new Error('postsDb required when instantiating NotificationsQuery adapter.') + } + if (!postParentsDb) { + throw new Error('postParentsDb required when instantiating NotificationsQuery adapter.') + } + if (!postChildrenDb) { + throw new Error('postChildrenDb required when instantiating NotificationsQuery adapter.') + } + if (!likesDb) { + throw new Error('likesDb required when instantiating NotificationsQuery adapter.') + } + if (!postLikesDb) { + throw new Error('postLikesDb required when instantiating NotificationsQuery adapter.') + } + if (!followsDb) { + throw new Error('followsDb required when instantiating NotificationsQuery adapter.') + } + + this.postsDb = postsDb + this.postParentsDb = postParentsDb + this.postChildrenDb = postChildrenDb + this.likesDb = likesDb + this.postLikesDb = postLikesDb + this.followsDb = followsDb + this.bchjs = bchjs + + this.listNotifications = this.listNotifications.bind(this) + this._getPostOrNull = this._getPostOrNull.bind(this) + this._collectFollowNotifications = this._collectFollowNotifications.bind(this) + this._collectLikeNotifications = this._collectLikeNotifications.bind(this) + this._collectReplyNotifications = this._collectReplyNotifications.bind(this) + this._sortNotifications = this._sortNotifications.bind(this) + } + + async _getPostOrNull (txid) { + try { + return await this.postsDb.get(txid) + } catch (err) { + if (err.notFound || err.code === 'LEVEL_NOT_FOUND') return null + throw err + } + } + + // Collect active follows where this address is the followee. + async _collectFollowNotifications (addr) { + const myHash160 = this.bchjs.Address.toHash160(addr) + const notifications = [] + + for await (const [key, record] of this.followsDb.iterator()) { + if (record.unfollow === true) continue + if (record.followeePkHash !== myHash160) continue + + const followerAddr = record.followerAddr || key.split(':')[0] + if (followerAddr === addr) continue + + notifications.push({ + type: 'follow', + txid: record.txid, + addr: followerAddr, + blockHeight: record.blockHeight ?? 0, + seen: record.seen ?? 0 + }) + } + + return notifications + } + + // Collect likes on posts authored by this address, excluding self-likes. + async _collectLikeNotifications (addr) { + const notifications = [] + + for await (const [likeTxid, like] of this.likesDb.iterator()) { + if (!like || like.addr === addr) continue + + const post = await this._getPostOrNull(like.postTxid) + if (!post || post.addr !== addr) continue + + notifications.push({ + type: 'like', + txid: likeTxid, + addr: like.addr, + postTxid: like.postTxid, + blockHeight: like.blockHeight ?? 0, + seen: like.seen ?? 0 + }) + } + + return notifications + } + + // Collect replies to posts authored by this address, excluding own replies. + async _collectReplyNotifications (addr) { + const notifications = [] + + for await (const [, child] of this.postChildrenDb.iterator()) { + const parentTxid = child?.parentTxid + const childTxid = child?.childTxid + if (!parentTxid || !childTxid) continue + + const parent = await this._getPostOrNull(parentTxid) + if (!parent || parent.addr !== addr) continue + + const childPost = await this._getPostOrNull(childTxid) + if (!childPost || childPost.addr === addr) continue + + notifications.push({ + type: 'reply', + txid: childTxid, + addr: childPost.addr, + postTxid: parentTxid, + text: childPost.text, + blockHeight: child.blockHeight ?? childPost.blockHeight ?? 0, + seen: childPost.seen ?? 0 + }) + } + + return notifications + } + + _sortNotifications (notifications) { + return notifications.sort((a, b) => { + if (b.blockHeight !== a.blockHeight) return b.blockHeight - a.blockHeight + return (b.seen ?? 0) - (a.seen ?? 0) + }) + } + + // Return paginated notifications for addr, sorted newest-first. + async listNotifications (addr, { limit, offset } = {}) { + const [follows, likes, replies] = await Promise.all([ + this._collectFollowNotifications(addr), + this._collectLikeNotifications(addr), + this._collectReplyNotifications(addr) + ]) + + const all = this._sortNotifications(follows.concat(likes).concat(replies)) + const total = all.length + const page = all.slice(offset, offset + limit) + + return { notifications: page, total } + } +} + +export default NotificationsQuery diff --git a/psf-memo-db/src/controllers/rest-api/posts/controller.js b/psf-memo-db/src/controllers/rest-api/posts/controller.js index 0332a3a..e924817 100644 --- a/psf-memo-db/src/controllers/rest-api/posts/controller.js +++ b/psf-memo-db/src/controllers/rest-api/posts/controller.js @@ -18,6 +18,7 @@ class PostsRESTControllerLib { this.getRecentPosts = this.getRecentPosts.bind(this) this.getPostsByAddr = this.getPostsByAddr.bind(this) this.getFollowingFeed = this.getFollowingFeed.bind(this) + this.getNotifications = this.getNotifications.bind(this) this.getPostThread = this.getPostThread.bind(this) this.runUseCase = this.runUseCase.bind(this) this.listPostsForAddr = this.listPostsForAddr.bind(this) @@ -145,6 +146,34 @@ class PostsRESTControllerLib { await this.listPostsForAddr(ctx, this.useCases.listFollowingFeed) } + /** + * @api {get} /posts/notifications/:addr List notifications for an address + * @apiPermission public + * @apiName GetNotifications + * @apiGroup REST Posts + * + * @apiDescription Returns replies to the viewer's posts, likes on the viewer's posts, and new follows of the viewer, sorted by block height (newest first). + * + * @apiParam {String} addr Viewer cash address + * @apiQuery {Number} [limit=100] Page size (max 100) + * @apiQuery {Number} [offset=0] Number of notifications to skip after sorting + * + * @apiExample Example usage: + * curl -X GET "localhost:5021/posts/notifications/bitcoincash:q...?limit=50&offset=0" + * + * @apiSuccess {Object[]} notifications Array of notification objects + * @apiSuccess {String} notifications.type One of reply, like, follow + * @apiSuccess {String} notifications.txid Action transaction id + * @apiSuccess {String} notifications.addr Actor cash address + * @apiSuccess {String} [notifications.postTxid] Liked/replied post txid + * @apiSuccess {String} [notifications.text] Reply text + * @apiSuccess {Number} notifications.blockHeight Block height when indexed + * @apiSuccess {Object} pagination Pagination metadata + */ + async getNotifications (ctx) { + await this.listPostsForAddr(ctx, this.useCases.listNotifications) + } + async getPostThread (ctx) { const { txid } = ctx.params await this.runUseCase(ctx, () => this.useCases.getPostThread.execute({ diff --git a/psf-memo-db/src/controllers/rest-api/posts/index.js b/psf-memo-db/src/controllers/rest-api/posts/index.js index 3d26fca..54c9981 100644 --- a/psf-memo-db/src/controllers/rest-api/posts/index.js +++ b/psf-memo-db/src/controllers/rest-api/posts/index.js @@ -27,6 +27,7 @@ class PostsRouter { this.router.get('/recent', this.postsRESTController.getRecentPosts) this.router.get('/by/:addr', this.postsRESTController.getPostsByAddr) this.router.get('/following/:addr', this.postsRESTController.getFollowingFeed) + this.router.get('/notifications/:addr', this.postsRESTController.getNotifications) this.router.get('/:txid/thread', this.postsRESTController.getPostThread) app.use(this.router.routes()) app.use(this.router.allowedMethods()) diff --git a/psf-memo-db/src/use-cases/index.js b/psf-memo-db/src/use-cases/index.js index df0a2f2..1ccc1ed 100644 --- a/psf-memo-db/src/use-cases/index.js +++ b/psf-memo-db/src/use-cases/index.js @@ -20,6 +20,7 @@ import GetPoll from './get-poll.js' import GetPollOptions from './get-poll-options.js' import GetPollVotes from './get-poll-votes.js' import SearchAll from './search-all.js' +import ListNotifications from './list-notifications.js' class UseCases { constructor (localConfig = {}) { @@ -49,6 +50,7 @@ class UseCases { this.getPollOptions = null this.getPollVotes = null this.searchAll = null + this.listNotifications = null } async start () { @@ -124,6 +126,10 @@ class UseCases { adapters: this.adapters }) + this.listNotifications = new ListNotifications({ + adapters: this.adapters + }) + console.log('Use cases initialized.') return true diff --git a/psf-memo-db/src/use-cases/list-notifications.js b/psf-memo-db/src/use-cases/list-notifications.js new file mode 100644 index 0000000..1cd397d --- /dev/null +++ b/psf-memo-db/src/use-cases/list-notifications.js @@ -0,0 +1,35 @@ +/* + Use case: list notifications for a viewer, newest first. + + Aggregates replies to the viewer's posts, likes on the viewer's posts, and + follows of the viewer, then paginates the combined result. +*/ + +import { parseLimit, parseOffset, parseRequiredString } from './lib/pagination.js' +import { ListUseCase } from './lib/use-case.js' + +class ListNotifications extends ListUseCase { + constructor (localConfig = {}) { + super(localConfig, { useCaseName: 'ListNotifications', adapterName: 'notificationsQuery' }) + } + + async execute (inObj = {}) { + const addr = parseRequiredString(inObj.addr, 'addr') + const limit = parseLimit(inObj.limit) + const offset = parseOffset(inObj.offset) + + const { notifications, total } = await this.adapters.notificationsQuery.listNotifications(addr, { limit, offset }) + + return { + notifications, + pagination: { + limit, + offset, + total, + hasMore: offset + notifications.length < total + } + } + } +} + +export default ListNotifications diff --git a/psf-memo-db/test/unit/adapters/notifications-query.unit.js b/psf-memo-db/test/unit/adapters/notifications-query.unit.js new file mode 100644 index 0000000..1ca781b --- /dev/null +++ b/psf-memo-db/test/unit/adapters/notifications-query.unit.js @@ -0,0 +1,251 @@ +import { assert } from 'chai' +import sinon from 'sinon' +import NotificationsQuery from '../../../src/adapters/notifications-query.js' + +describe('#NotificationsQuery', () => { + let sandbox + let postsDb + let postChildrenDb + let likesDb + let followsDb + let bchjs + let uut + + const MY_ADDR = 'bitcoincash:qqlrzp23w08434twtmvr4fxw672whkjy0py26r63g3d' + const THEIR_ADDR = 'bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy' + const MY_HASH160 = 'myhash160' + const THEIR_HASH160 = 'theirhash160' + + function makeIterator (items) { + return (async function * () { + for (const item of items) yield item + }()) + } + + beforeEach(() => { + sandbox = sinon.createSandbox() + + postsDb = { get: sandbox.stub() } + postChildrenDb = { iterator: sandbox.stub() } + likesDb = { iterator: sandbox.stub() } + followsDb = { iterator: sandbox.stub() } + + bchjs = { + Address: { + toHash160: sandbox.stub() + } + } + + bchjs.Address.toHash160.withArgs(MY_ADDR).returns(MY_HASH160) + bchjs.Address.toHash160.withArgs(THEIR_ADDR).returns(THEIR_HASH160) + + uut = new NotificationsQuery({ + postsDb, + postParentsDb: {}, + postChildrenDb, + likesDb, + postLikesDb: {}, + followsDb, + bchjs + }) + }) + + afterEach(() => sandbox.restore()) + + it('should throw when postsDb is missing', () => { + try { + // eslint-disable-next-line no-new + new NotificationsQuery({ postChildrenDb, likesDb, followsDb, bchjs }) + assert.fail('Expected error') + } catch (err) { + assert.include(err.message, 'postsDb required') + } + }) + + it('should throw when postChildrenDb is missing', () => { + try { + // eslint-disable-next-line no-new + new NotificationsQuery({ postsDb, postParentsDb: {}, likesDb, postLikesDb: {}, followsDb, bchjs }) + assert.fail('Expected error') + } catch (err) { + assert.include(err.message, 'postChildrenDb required') + } + }) + + it('should throw when likesDb is missing', () => { + try { + // eslint-disable-next-line no-new + new NotificationsQuery({ postsDb, postParentsDb: {}, postChildrenDb, postLikesDb: {}, followsDb, bchjs }) + assert.fail('Expected error') + } catch (err) { + assert.include(err.message, 'likesDb required') + } + }) + + it('should throw when followsDb is missing', () => { + try { + // eslint-disable-next-line no-new + new NotificationsQuery({ postsDb, postParentsDb: {}, postChildrenDb, likesDb, postLikesDb: {}, bchjs }) + assert.fail('Expected error') + } catch (err) { + assert.include(err.message, 'followsDb required') + } + }) + + it('should include a reply to my post', async () => { + const myPostTxid = 'a'.repeat(64) + const replyTxid = 'b'.repeat(64) + + postChildrenDb.iterator.returns(makeIterator([ + [`${myPostTxid}:${replyTxid}`, { parentTxid: myPostTxid, childTxid: replyTxid, blockHeight: 200 }] + ])) + likesDb.iterator.returns(makeIterator([])) + followsDb.iterator.returns(makeIterator([])) + + postsDb.get.withArgs(myPostTxid).resolves({ addr: MY_ADDR, text: 'hello' }) + postsDb.get.withArgs(replyTxid).resolves({ addr: THEIR_ADDR, text: 'nice post', blockHeight: 200 }) + + const result = await uut.listNotifications(MY_ADDR, { limit: 100, offset: 0 }) + + assert.equal(result.total, 1) + assert.equal(result.notifications.length, 1) + const n = result.notifications[0] + assert.equal(n.type, 'reply') + assert.equal(n.txid, replyTxid) + assert.equal(n.addr, THEIR_ADDR) + assert.equal(n.postTxid, myPostTxid) + assert.equal(n.text, 'nice post') + }) + + it('should include a like on my post', async () => { + const myPostTxid = 'a'.repeat(64) + const likeTxid = 'b'.repeat(64) + + postChildrenDb.iterator.returns(makeIterator([])) + followsDb.iterator.returns(makeIterator([])) + likesDb.iterator.returns(makeIterator([ + [likeTxid, { addr: THEIR_ADDR, postTxid: myPostTxid, blockHeight: 300 }] + ])) + + postsDb.get.withArgs(myPostTxid).resolves({ addr: MY_ADDR, text: 'hello' }) + + const result = await uut.listNotifications(MY_ADDR, { limit: 100, offset: 0 }) + + assert.equal(result.total, 1) + const n = result.notifications[0] + assert.equal(n.type, 'like') + assert.equal(n.txid, likeTxid) + assert.equal(n.addr, THEIR_ADDR) + assert.equal(n.postTxid, myPostTxid) + }) + + it('should include a follow of me', async () => { + postChildrenDb.iterator.returns(makeIterator([])) + likesDb.iterator.returns(makeIterator([])) + followsDb.iterator.returns(makeIterator([ + [`${THEIR_ADDR}:${MY_HASH160}`, { followerAddr: THEIR_ADDR, followeePkHash: MY_HASH160, unfollow: false, txid: 'c'.repeat(64), blockHeight: 150 }] + ])) + + const result = await uut.listNotifications(MY_ADDR, { limit: 100, offset: 0 }) + + assert.equal(result.total, 1) + const n = result.notifications[0] + assert.equal(n.type, 'follow') + assert.equal(n.txid, 'c'.repeat(64)) + assert.equal(n.addr, THEIR_ADDR) + }) + + it('should exclude my own replies', async () => { + const myPostTxid = 'a'.repeat(64) + const replyTxid = 'b'.repeat(64) + + postChildrenDb.iterator.returns(makeIterator([ + [`${myPostTxid}:${replyTxid}`, { parentTxid: myPostTxid, childTxid: replyTxid, blockHeight: 100 }] + ])) + likesDb.iterator.returns(makeIterator([])) + followsDb.iterator.returns(makeIterator([])) + + postsDb.get.withArgs(myPostTxid).resolves({ addr: MY_ADDR, text: 'hello' }) + postsDb.get.withArgs(replyTxid).resolves({ addr: MY_ADDR, text: 'my own reply' }) + + const result = await uut.listNotifications(MY_ADDR, { limit: 100, offset: 0 }) + + assert.equal(result.total, 0) + }) + + it('should exclude replies to posts by other people', async () => { + const theirPostTxid = 'a'.repeat(64) + const replyTxid = 'b'.repeat(64) + + postChildrenDb.iterator.returns(makeIterator([ + [`${theirPostTxid}:${replyTxid}`, { parentTxid: theirPostTxid, childTxid: replyTxid, blockHeight: 100 }] + ])) + likesDb.iterator.returns(makeIterator([])) + followsDb.iterator.returns(makeIterator([])) + + postsDb.get.withArgs(theirPostTxid).resolves({ addr: THEIR_ADDR, text: 'alice post' }) + postsDb.get.withArgs(replyTxid).resolves({ addr: 'bitcoincash:other', text: 'reply' }) + + const result = await uut.listNotifications(MY_ADDR, { limit: 100, offset: 0 }) + + assert.equal(result.total, 0) + }) + + it('should exclude unfollows', async () => { + postChildrenDb.iterator.returns(makeIterator([])) + likesDb.iterator.returns(makeIterator([])) + followsDb.iterator.returns(makeIterator([ + [`${THEIR_ADDR}:${MY_HASH160}`, { followerAddr: THEIR_ADDR, followeePkHash: MY_HASH160, unfollow: true, txid: 'c'.repeat(64), blockHeight: 150 }] + ])) + + const result = await uut.listNotifications(MY_ADDR, { limit: 100, offset: 0 }) + + assert.equal(result.total, 0) + }) + + it('should sort notifications by block height descending', async () => { + const myPostTxid = 'a'.repeat(64) + const replyTxid = 'b'.repeat(64) + const likeTxid = 'c'.repeat(64) + + postChildrenDb.iterator.returns(makeIterator([ + [`${myPostTxid}:${replyTxid}`, { parentTxid: myPostTxid, childTxid: replyTxid, blockHeight: 100 }] + ])) + likesDb.iterator.returns(makeIterator([ + [likeTxid, { addr: THEIR_ADDR, postTxid: myPostTxid, blockHeight: 300 }] + ])) + followsDb.iterator.returns(makeIterator([])) + + postsDb.get.withArgs(myPostTxid).resolves({ addr: MY_ADDR, text: 'hello' }) + postsDb.get.withArgs(replyTxid).resolves({ addr: THEIR_ADDR, text: 'reply' }) + + const result = await uut.listNotifications(MY_ADDR, { limit: 100, offset: 0 }) + + assert.equal(result.total, 2) + assert.equal(result.notifications[0].type, 'like') + assert.equal(result.notifications[1].type, 'reply') + }) + + it('should paginate notifications', async () => { + const myPostTxid = 'a'.repeat(64) + const replyTxid = 'b'.repeat(64) + const likeTxid = 'c'.repeat(64) + + postChildrenDb.iterator.returns(makeIterator([ + [`${myPostTxid}:${replyTxid}`, { parentTxid: myPostTxid, childTxid: replyTxid, blockHeight: 100 }] + ])) + likesDb.iterator.returns(makeIterator([ + [likeTxid, { addr: THEIR_ADDR, postTxid: myPostTxid, blockHeight: 300 }] + ])) + followsDb.iterator.returns(makeIterator([])) + + postsDb.get.withArgs(myPostTxid).resolves({ addr: MY_ADDR, text: 'hello' }) + postsDb.get.withArgs(replyTxid).resolves({ addr: THEIR_ADDR, text: 'reply' }) + + const result = await uut.listNotifications(MY_ADDR, { limit: 1, offset: 0 }) + + assert.equal(result.total, 2) + assert.equal(result.notifications.length, 1) + assert.equal(result.notifications[0].type, 'like') + }) +}) diff --git a/psf-memo-db/test/unit/controllers/posts.controller.unit.js b/psf-memo-db/test/unit/controllers/posts.controller.unit.js index 9405793..ba4a8c1 100644 --- a/psf-memo-db/test/unit/controllers/posts.controller.unit.js +++ b/psf-memo-db/test/unit/controllers/posts.controller.unit.js @@ -28,6 +28,12 @@ describe('#PostsRESTController', () => { posts: [{ txid: 'tx3', addr: 'addr-b', blockHeight: 600200 }], pagination: { limit: 100, offset: 0, total: 1, hasMore: false } }) + }, + listNotifications: { + execute: sandbox.stub().resolves({ + notifications: [{ type: 'like', txid: 'tx5', addr: 'addr-c', blockHeight: 600300 }], + pagination: { limit: 100, offset: 0, total: 1, hasMore: false } + }) } } }) @@ -120,6 +126,25 @@ describe('#PostsRESTController', () => { assert.include(ctx.throw.firstCall.args[1], 'boom') }) + it('should return notifications from use case', async () => { + const ctx = { + params: { addr: 'addr-c' }, + query: { limit: '25', offset: '0' }, + body: null, + throw: sandbox.stub() + } + await uut.getNotifications(ctx) + + assert.equal(uut.useCases.listNotifications.execute.callCount, 1) + assert.deepEqual(uut.useCases.listNotifications.execute.firstCall.args[0], { + addr: 'addr-c', + limit: '25', + offset: '0' + }) + assert.equal(ctx.body.notifications.length, 1) + assert.equal(ctx.body.notifications[0].type, 'like') + }) + it('should return a post thread from use case', async () => { const ctx = { params: { txid: 'tx4' }, diff --git a/psf-memo-db/test/unit/use-cases/index.unit.js b/psf-memo-db/test/unit/use-cases/index.unit.js index 7d0dda0..9cb4e78 100644 --- a/psf-memo-db/test/unit/use-cases/index.unit.js +++ b/psf-memo-db/test/unit/use-cases/index.unit.js @@ -21,6 +21,7 @@ describe('#UseCases', () => { }, topicQuery: {}, searchQuery: {}, + notificationsQuery: {}, pollQuery: {} } } @@ -55,6 +56,7 @@ describe('#UseCases', () => { assert.isNotNull(uut.getPollOptions) assert.isNotNull(uut.getPollVotes) assert.isNotNull(uut.searchAll) + assert.isNotNull(uut.listNotifications) }) }) }) diff --git a/psf-memo-db/test/unit/use-cases/list-notifications.unit.js b/psf-memo-db/test/unit/use-cases/list-notifications.unit.js new file mode 100644 index 0000000..84b94af --- /dev/null +++ b/psf-memo-db/test/unit/use-cases/list-notifications.unit.js @@ -0,0 +1,91 @@ +import { assert } from 'chai' +import sinon from 'sinon' +import ListNotifications from '../../../src/use-cases/list-notifications.js' + +describe('#ListNotifications', () => { + let uut + let sandbox + let notificationsQuery + + beforeEach(() => { + sandbox = sinon.createSandbox() + notificationsQuery = { + listNotifications: sandbox.stub().resolves({ + notifications: [{ type: 'follow', txid: 'tx1', addr: 'addr-a' }], + total: 1 + }) + } + uut = new ListNotifications({ adapters: { notificationsQuery } }) + }) + + afterEach(() => sandbox.restore()) + + it('should throw when adapters are missing', () => { + try { + // eslint-disable-next-line no-new + new ListNotifications({}) + assert.fail('Expected error') + } catch (err) { + assert.include(err.message, 'Adapters required') + } + }) + + it('should throw when notificationsQuery adapter is missing', () => { + try { + // eslint-disable-next-line no-new + new ListNotifications({ adapters: {} }) + assert.fail('Expected error') + } catch (err) { + assert.include(err.message, 'notificationsQuery adapter required') + } + }) + + it('should reject a missing addr', async () => { + try { + await uut.execute({}) + assert.fail('Expected error') + } catch (err) { + assert.equal(err.status, 400) + assert.include(err.message, 'addr is required') + } + }) + + it('should pass addr, limit, and offset to the query adapter', async () => { + await uut.execute({ addr: 'addr-a', limit: '10', offset: '5' }) + + assert.equal(notificationsQuery.listNotifications.callCount, 1) + assert.deepEqual(notificationsQuery.listNotifications.firstCall.args[0], 'addr-a') + assert.deepEqual(notificationsQuery.listNotifications.firstCall.args[1], { limit: 10, offset: 5 }) + }) + + it('should default limit and offset', async () => { + await uut.execute({ addr: 'addr-a' }) + + assert.deepEqual(notificationsQuery.listNotifications.firstCall.args[1], { limit: 100, offset: 0 }) + }) + + it('should reject limit over 100', async () => { + try { + await uut.execute({ addr: 'addr-a', limit: '101' }) + assert.fail('Expected error') + } catch (err) { + assert.equal(err.status, 400) + assert.include(err.message, 'limit cannot exceed') + } + }) + + it('should attach pagination metadata', async () => { + notificationsQuery.listNotifications.resolves({ + notifications: [{ type: 'like', txid: 'tx1', addr: 'addr-a' }], + total: 2 + }) + + const result = await uut.execute({ addr: 'addr-a', limit: '1', offset: '0' }) + + assert.equal(result.notifications.length, 1) + assert.equal(result.pagination.limit, 1) + assert.equal(result.pagination.offset, 0) + assert.equal(result.pagination.total, 2) + assert.equal(result.pagination.hasMore, true) + }) +}) diff --git a/specifier-prompt.md b/specifier-prompt.md index 6d0b574..24bd49d 100644 --- a/specifier-prompt.md +++ b/specifier-prompt.md @@ -368,5 +368,5 @@ At the end of each session, update this file: - Note the current `master` HEAD commit. - State the next feature to work on. -Current `master` HEAD: `6d94372` (task `following-feed` — P6.6 Following Feed — spec'd and handed off to the coder. Search `20328ea`/`96956f3` was already verified.). -Next action: **await the coder → refactorer → architect pipeline for task `following-feed`**, then merge the architect branch into `master` and verify the affected components (client build/test/lint, DB test/lint). Send money (P5.1) was skipped by user decision (no clear use case). Future candidates after following-feed lands: P5.2–5.5 (MIP-0009 token exchange), P6.1 Repost, P6.2 Ranked feed, P6.3 Notifications, P6.5 Tags/hashtags. +Current `master` HEAD: `4422372` (merge of the architect's second-pass hardening of the Following feed — P6.6 — verified: client build/test/lint and DB test/lint all pass). +Next action: **P6.3 Notifications** — the Gherkin spec is drafted at `psf-memo-client/specs/notifications.feature` (8 scenarios, parsed clean, DRY-checked) and is awaiting user approval before handoff to the coder. After approval, commit it (`By specifier.`), invent the stable task name `notifications`, and send the `git_handoff` to the coder. Send money (P5.1) was skipped by user decision (no clear use case). Remaining candidates after notifications: P5.2–5.5 (MIP-0009 token exchange), P6.1 Repost, P6.2 Ranked feed, P6.5 Tags/hashtags. diff --git a/specs/feature-backlog.md b/specs/feature-backlog.md index 51bec1e..c9002d8 100644 --- a/specs/feature-backlog.md +++ b/specs/feature-backlog.md @@ -207,15 +207,15 @@ Polls require a new data model and rendering. The indexer has no handler yet. | 6.3 | Notifications | C, D | replies / likes / follows to my posts | | 6.4 | Search | C, D | posts / profiles / topics — ✅ shipped (task `search`) | | 6.5 | Tags / hashtags | C, D | link + filter by tag | -| 6.6 | Following feed | C, D | feed filtered to followed users — 🔜 in pipeline (task `following-feed`) | +| 6.6 | Following feed | C, D | feed filtered to followed users — ✅ shipped (task `following-feed`) | --- ## Suggested next spec -**Following feed (P6.6)** — 🔜 spec'd and handed off to the coder (task `following-feed`, commit `6d94372`). Following Feed is a read-only aggregation: the viewer sees top-level posts (replies excluded) authored only by profiles they follow, newest first, never their own posts. -- `psf-memo-db`: new `GET /posts/following/:addr?limit=&offset=` returning `{ posts, pagination }`, joining the follows index (`FollowQuery.listFollowing`) with the posts index. Empty following or no posts → empty result set. -- `psf-memo-client`: a "Following" nav page at `/posts/following` that reads the viewer's wallet address, calls the new endpoint (a `MemoDb.getFollowingFeed`), and renders posts like the recent feed; shows "you aren't following anyone" when following nobody. +**Following feed (P6.6)** — ✅ shipped (task `following-feed`), merged from the pipeline and verified (client build/test/lint, DB test/lint). A second-pass hardening merge from the architect (2026-09-02) extracted `parseRequiredString` into `pagination.js` (shared by the addr/room-scoped list use cases) and added client + DB composition-root hardening tests; merged to `master` at `4422372` and re-verified. Following Feed is a read-only aggregation: the viewer sees top-level posts (replies excluded) authored only by profiles they follow, newest first, never their own posts. +- `psf-memo-db`: `GET /posts/following/:addr?limit=&offset=` returning `{ posts, pagination }`, joining the follows index (`FollowQuery.listFollowing`) with the posts index (`PostQuery.scanFollowingFeedTxidsAndCount`). Empty following or no posts → empty result set. +- `psf-memo-client`: a "Following" nav page at `/posts/following` that reads the viewer's wallet address, calls `MemoDb.getFollowingFeed`, and renders posts like the recent feed; shows "You are not following anyone." when following nobody and "No posts from profiles you follow." when followed profiles have no posts. - Spec: `psf-memo-client/specs/following-feed.feature`. **Search (P6.4)** — ✅ shipped (task `search`), merged from the pipeline and verified (client build/test/lint, DB test/lint): diff --git a/swarmforge/roles/architect.prompt b/swarmforge/roles/architect.prompt index 13e1005..324c2dd 100644 --- a/swarmforge/roles/architect.prompt +++ b/swarmforge/roles/architect.prompt @@ -17,6 +17,7 @@ You are the architect. - Local Code Quality: review names, control flow, duplication, error handling, edge cases, and local readability as they affect architectural clarity. ## Startup Tools +- At startup, read `docs/architect-process-notes.md` (architect-only working notes on process exceptions and tooling behavior) and keep it current as you discover new exceptions. - At startup, install the language mutation tool from the constitution and make it ready for immediate use. Use it to cover the uncovered, and kill survivors. - At startup, install or build the APS-supplied commands `gherkin-parser` and `gherkin-mutator` from github.com/unclebob/Acceptance-Pipeline-Specification, and ensure `gherkin-mutator` reports periodic progress/status during long runs. - Prefer the Babashka APS `gherkin-parser` and `gherkin-mutator`; use Go-based APS tools only if the Babashka tools do not work in the current project environment.