diff --git a/services/mana-analytics/CLAUDE.md b/services/mana-analytics/CLAUDE.md index b12acdb80..21c3c7b5e 100644 --- a/services/mana-analytics/CLAUDE.md +++ b/services/mana-analytics/CLAUDE.md @@ -1,29 +1,105 @@ # mana-analytics -Feedback and analytics service. Extracted from mana-auth. +Public-Community-Feedback-Hub. Backend fΓΌr `@mana/feedback`. Hostet +sowohl die auth-required Submission/React/Admin-Surface als auch die +anonymous Public-Mirror-Endpoints fΓΌr `community.mana.how` und +`mana.how/community`. -## Port: 3064 +## Port: 3064 (prod port via cloudflared tunnel: `community.mana.how`) -## API Endpoints (JWT auth) +## API Endpoints + +### Authenticated (`/api/v1/feedback/*`, JWT via JWKS from mana-auth) | Method | Path | Description | |--------|------|-------------| -| POST | `/api/v1/feedback` | Submit feedback | -| GET | `/api/v1/feedback/public` | List public feedback | -| GET | `/api/v1/feedback/me` | My feedback | -| POST | `/api/v1/feedback/:id/vote` | Upvote | -| DELETE | `/api/v1/feedback/:id/vote` | Remove vote | -| DELETE | `/api/v1/feedback/:id` | Delete my feedback | +| POST | `/api/v1/feedback` | Submit feedback (top-level wish or reply via `parentId`). Auto-titles via mana-llm if no `title` given. Stamps `display_hash` + `display_name`. Triggers fire-and-forget +5-Credit-grant via mana-credits when β‰₯20 chars + not founder + within 24h rate-limit | +| GET | `/api/v1/feedback/public` | Auth-enriched feed: each item carries `myReactions[]` for highlight + `realName` if author opted in | +| GET | `/api/v1/feedback/me` | Items the user authored (across all isPublic states) | +| GET | `/api/v1/feedback/me/reacted` | Items the user reacted on, redacted to PublicFeedbackItem (excludes own) | +| GET | `/api/v1/feedback/me/notifications?unread_only=true&limit=N` | Inbox | +| POST | `/api/v1/feedback/me/notifications/:id/read` | Mark single notification read (scoped to caller) | +| POST | `/api/v1/feedback/me/notifications/read-all` | Mark all caller's unread notifications read | +| GET | `/api/v1/feedback/:id/replies` | 1-level threading replies | +| POST | `/api/v1/feedback/:id/react` | Toggle a single emoji reaction (πŸ‘ ❀️ πŸš€ πŸ€” πŸŽ‰). Increments author karma on +1 / decrements on toggle-off (+0 for self-react) | +| DELETE | `/api/v1/feedback/:id` | Delete own feedback | +| GET | `/api/v1/feedback/admin?…` | List ALL feedback incl. private (founder/admin role only) | +| PATCH | `/api/v1/feedback/admin/:id` | Update status / adminResponse / isPublic. Status-transition triggers: notify-author-always, notify-reactioners-on-completed, +500 + +25 credit-grants on completed | -## Database: `mana_analytics` +### Anonymous Public (`/api/v1/public/feedback/*`, NO auth) -Tables: user_feedback, feedback_votes +| Method | Path | Description | +|--------|------|-------------| +| GET | `/api/v1/public/feedback/feed?…` | Top-level public items, redacted (no userId, no realName even if opted-in) | +| GET | `/api/v1/public/feedback/eule/:hash` | Eulen-Profil: alle Posts unter dem display_hash + Karma | +| GET | `/api/v1/public/feedback/:id` | Single item + replies, redacted | + +## Database + +Lives in `mana_platform.feedback` (alongside the other services' schemas). + +Tables: +- `user_feedback` β€” top-level wishes + replies (parent_id), with cached `reactions jsonb` + `score int` for sort +- `feedback_reactions` β€” Slack-pattern (feedback_id, user_id, emoji) unique +- `feedback_notifications` β€” Per-user inbox, ON DELETE CASCADE on user_feedback +- `feedback_grant_log` β€” Sliding-window rate-limit log (10/user/24h) + +The `feedback_category` + `feedback_status` enums live in `public` schema (Drizzle's pgEnum quirk β€” see repo memory). `auth.users` is JOINed cross-schema for karma + Klarname-opt-in (read-only β€” mana-auth owns those columns). + +Migrations are hand-authored under `drizzle/`: +- `0001_align-feedback-enums.sql` β€” Status rename + 'praise' + 'onboarding-wish' +- `0002_public-community-foundation.sql` β€” Pseudonym + reactions + score +- `0003_grant_log_drop_vote_count.sql` β€” Rate-limit log + drop legacy +- `0004_feedback_notifications.sql` β€” Inbox table + +Apply manually with `psql -f` before next `db:push` (drizzle-kit can't safely do enum-renames or cross-schema CASCADE drops). ## Environment Variables ```env PORT=3064 -DATABASE_URL=postgresql://mana:devpassword@localhost:5432/mana_analytics -MANA_AUTH_URL=http://localhost:3001 -MANA_LLM_URL=http://localhost:3025 +DATABASE_URL=postgresql://... # mana_platform +MANA_AUTH_URL=http://mana-auth:3001 # JWKS lookup +MANA_LLM_URL=http://mana-llm:3025 # auto-title generation +MANA_CREDITS_URL=http://mana-credits:3002 # internal grant calls (prod port; dev 3061) +MANA_SERVICE_KEY=... # X-Service-Key for /internal/credits/grant +FEEDBACK_PSEUDONYM_SECRET=... # SHA256(userId+secret) β†’ display_hash +FEEDBACK_FOUNDER_USER_IDS=…,… # Comma-separated; bypass +5/+500 grants +CORS_ORIGINS=https://mana.how,https://community.mana.how ``` + +## Tests + +```bash +# Unit-only (pseudonym, redact privacy-boundary, avatar). 16 tests, ~50ms. +bun test + +# Full suite incl. DB-backed integration tests against local mana_platform. +# Cleans up via afterAll DELETE β€” userIds prefixed with `test-`. +pnpm test:integration + +# Or with explicit DB: +TEST_DATABASE_URL=postgres://mana:devpassword@localhost:5432/mana_platform bun test +``` + +Integration tests live next to the service files (`*.integration.test.ts`). +They mock `globalThis.fetch` so calls to mana-credits are captured locally β€” +no need for that service to be running. The whole integration-suite is +`describe.skip`-gated on `TEST_DATABASE_URL` so a fresh checkout's +`bun test` doesn't fail without a Postgres. + +## Reward Loop (Phase 3) + +Quick reference for the credit + karma side-effects: + +| Trigger | Effect | +|---|---| +| Top-level submission β‰₯20 chars, non-founder, under rate-limit | +5 Credits, log row in feedback_grant_log | +| Status transition (any) | Author-Notification enqueued | +| Status β†’ 'completed' (fresh) | Author +500 Credits + Notification, each πŸ‘/πŸš€-Reactioner +25 Credits + Notification (one per user, idempotent via referenceId) | +| AdminResponse edit | Author admin_response-Notification | +| User reacts on someone else's post | Author karma +1 | +| User unreacts | Author karma -1, floor-clamped at 0 | +| User reacts on own post | No karma change (self-promotion guard) | + +See [`docs/plans/feedback-rewards-and-identity.md`](../../docs/plans/feedback-rewards-and-identity.md) for the full Phase-3 plan. diff --git a/services/mana-analytics/package.json b/services/mana-analytics/package.json index 3ffd4637a..a97d40065 100644 --- a/services/mana-analytics/package.json +++ b/services/mana-analytics/package.json @@ -7,7 +7,9 @@ "dev": "bun run --watch src/index.ts", "start": "bun run src/index.ts", "db:push": "drizzle-kit push", - "db:studio": "drizzle-kit studio" + "db:studio": "drizzle-kit studio", + "test": "bun test", + "test:integration": "TEST_DATABASE_URL=\"${TEST_DATABASE_URL:-postgres://mana:devpassword@localhost:5432/mana_platform}\" bun test" }, "dependencies": { "@mana/shared-hono": "workspace:*", diff --git a/services/mana-analytics/src/db/schema/index.ts b/services/mana-analytics/src/db/schema/index.ts index 3c6cb93bb..623ab7a84 100644 --- a/services/mana-analytics/src/db/schema/index.ts +++ b/services/mana-analytics/src/db/schema/index.ts @@ -1 +1,2 @@ export * from './feedback'; +export * from './auth-users'; diff --git a/services/mana-analytics/src/services/feedback-credits.integration.test.ts b/services/mana-analytics/src/services/feedback-credits.integration.test.ts new file mode 100644 index 000000000..ad25349f9 --- /dev/null +++ b/services/mana-analytics/src/services/feedback-credits.integration.test.ts @@ -0,0 +1,318 @@ +/** + * Integration tests for the credit-grant flows. + * + * Skipped unless TEST_DATABASE_URL is set. Run via: + * TEST_DATABASE_URL=postgres://… bun test src/services/feedback-credits.integration.test.ts + * + * Hits a real Postgres for cross-schema correctness (auth.users JOIN, + * feedback_grant_log rate-limit window, etc.) but mocks the HTTP call + * to mana-credits so the suite doesn't need that service running. + */ + +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'bun:test'; +import { eq, sql } from 'drizzle-orm'; +import { FeedbackService, REWARD } from './feedback'; +import { + HAVE_TEST_DB, + TEST_DATABASE_URL, + connectTestDb, + seedUser, + cleanupTestData, + mockCreditsFetch, + type TestDb, + type FetchMock, +} from '../test-helpers/db'; +import { feedbackGrantLog, userFeedback, feedbackReactions } from '../db/schema/feedback'; + +const maybeDescribe = HAVE_TEST_DB ? describe : describe.skip; + +maybeDescribe('feedback service β€” credit-grant integration', () => { + let client: ReturnType; + let db!: TestDb; + let service!: FeedbackService; + let fetchMock!: FetchMock; + + beforeAll(() => { + void TEST_DATABASE_URL; + const conn = connectTestDb(); + client = conn.client; + db = conn.db; + }); + + beforeEach(() => { + fetchMock = mockCreditsFetch(); + // Each test gets a fresh service instance, founder-whitelist + // configurable per test via re-instantiation. + service = new FeedbackService( + db, + '', // llmUrl unset β†’ auto-title falls back to slice + 'test-pseudonym-secret', + 'http://mock-credits', + 'test-service-key', + new Set() + ); + }); + + afterAll(async () => { + await cleanupTestData(db); + await client.end(); + }); + + describe('submit bonus', () => { + it('grants +5 Credits when a 20+ char top-level wish is submitted', async () => { + const author = await seedUser(db); + + await service.createFeedback(author.id, { + appId: 'mana', + feedbackText: 'I would really like X please, this would help me a lot', + }); + + // Fire-and-forget β€” give the void-promise a chance to land. + await new Promise((r) => setTimeout(r, 50)); + + expect(fetchMock.calls).toHaveLength(1); + expect(fetchMock.calls[0].userId).toBe(author.id); + expect(fetchMock.calls[0].amount).toBe(REWARD.submit); + expect(fetchMock.calls[0].reason).toBe('feedback_submit'); + }); + + it('does NOT grant when feedback is too short (<20 chars)', async () => { + const author = await seedUser(db); + + await service.createFeedback(author.id, { + appId: 'mana', + feedbackText: 'too short', + }); + + await new Promise((r) => setTimeout(r, 50)); + expect(fetchMock.calls).toHaveLength(0); + }); + + it('does NOT grant for replies (parentId set)', async () => { + const author = await seedUser(db); + + // Create the parent first (top-level). + const parent = await service.createFeedback(author.id, { + appId: 'mana', + feedbackText: 'I would like X please, top-level wish here', + }); + await new Promise((r) => setTimeout(r, 50)); + fetchMock.calls.length = 0; // clear + + // Now post a reply β€” should not bonus. + await service.createFeedback(author.id, { + appId: 'mana', + feedbackText: 'agreed, this would be helpful for me too', + parentId: parent.id, + }); + + await new Promise((r) => setTimeout(r, 50)); + expect(fetchMock.calls).toHaveLength(0); + }); + + it('does NOT grant for founder-whitelisted users', async () => { + const founder = await seedUser(db); + const founderService = new FeedbackService( + db, + '', + 'test-pseudonym-secret', + 'http://mock-credits', + 'test-service-key', + new Set([founder.id]) + ); + + await founderService.createFeedback(founder.id, { + appId: 'mana', + feedbackText: 'I am the founder posting a long enough wish here', + }); + + await new Promise((r) => setTimeout(r, 50)); + expect(fetchMock.calls).toHaveLength(0); + }); + + it('rate-limits at 10 grants per 24h via feedback_grant_log', async () => { + const author = await seedUser(db); + + // Pre-fill the grant-log with 10 entries to simulate 10 prior grants. + for (let i = 0; i < 10; i++) { + await db.insert(feedbackGrantLog).values({ userId: author.id, reason: 'feedback_submit' }); + } + + // 11th submit should NOT trigger a fetch. + await service.createFeedback(author.id, { + appId: 'mana', + feedbackText: 'eleventh wish today should hit the rate limit', + }); + + await new Promise((r) => setTimeout(r, 50)); + expect(fetchMock.calls).toHaveLength(0); + }); + + it('does not log to grant_log when alreadyGranted is true (idempotent re-attempt)', async () => { + const author = await seedUser(db); + fetchMock.makeAlreadyGranted(); + + await service.createFeedback(author.id, { + appId: 'mana', + feedbackText: 'first submission of a long wish that landed already', + }); + await new Promise((r) => setTimeout(r, 50)); + + // fetch was called, but the rate-limit-log row should NOT have been + // written because the grant was a no-op (alreadyGranted=true). + const [logCount] = await db + .select({ ct: sql`count(*)::int` }) + .from(feedbackGrantLog) + .where(eq(feedbackGrantLog.userId, author.id)); + expect(logCount.ct).toBe(0); + }); + }); + + describe('ship bonus', () => { + it('grants +500 to author + +25 to πŸ‘/πŸš€ reactioners on completed transition', async () => { + const author = await seedUser(db); + const supporterA = await seedUser(db); + const supporterB = await seedUser(db); + const thinker = await seedUser(db); + + const wish = await service.createFeedback(author.id, { + appId: 'mana', + feedbackText: 'a substantial wish that several people support strongly', + }); + await new Promise((r) => setTimeout(r, 30)); + fetchMock.calls.length = 0; // ignore submit grant + + // Three supporters react with eligible emojis, one with πŸ€” (excluded). + await service.toggleReaction(wish.id, supporterA.id, 'πŸ‘'); + await service.toggleReaction(wish.id, supporterB.id, 'πŸš€'); + await service.toggleReaction(wish.id, thinker.id, 'πŸ€”'); + + await service.adminUpdate(wish.id, { status: 'completed' }); + await new Promise((r) => setTimeout(r, 100)); + + const grantsByUser = new Map(); + for (const c of fetchMock.calls) grantsByUser.set(c.userId, c.amount); + expect(grantsByUser.get(author.id)).toBe(REWARD.shipped); + expect(grantsByUser.get(supporterA.id)).toBe(REWARD.reactionMatch); + expect(grantsByUser.get(supporterB.id)).toBe(REWARD.reactionMatch); + expect(grantsByUser.has(thinker.id)).toBe(false); // πŸ€” excluded + }); + + it('does not double-pay on status flapping (completed β†’ in_progress β†’ completed)', async () => { + const author = await seedUser(db); + const wish = await service.createFeedback(author.id, { + appId: 'mana', + feedbackText: 'a wish that we will toggle through statuses repeatedly', + }); + await new Promise((r) => setTimeout(r, 30)); + fetchMock.calls.length = 0; + + await service.adminUpdate(wish.id, { status: 'completed' }); + await new Promise((r) => setTimeout(r, 50)); + const firstCallCount = fetchMock.calls.length; + + // Flap: back to in_progress, then back to completed. + await service.adminUpdate(wish.id, { status: 'in_progress' }); + await service.adminUpdate(wish.id, { status: 'completed' }); + await new Promise((r) => setTimeout(r, 50)); + + // The second 'completed' transition fires the trigger again, + // but the grant is idempotent via referenceId β€” mock signals + // 'alreadyGranted' on second call, so net author bonus is one. + // The fetch count goes up (the call was made), but the LOG + // stays at one because alreadyGranted=true skips the log row. + expect(fetchMock.calls.length).toBeGreaterThanOrEqual(firstCallCount); + // Verify the second call had the same referenceId as the first + // for the author bonus (idempotency-key shape). + const authorCalls = fetchMock.calls.filter( + (c) => c.userId === author.id && c.reason === 'feedback_shipped' + ); + const refIds = new Set(authorCalls.map((c) => c.referenceId)); + expect(refIds.size).toBe(1); // same `${id}_shipped` for both + }); + + it('skips ship bonus for founder-authored wishes', async () => { + const founder = await seedUser(db); + const founderService = new FeedbackService( + db, + '', + 'test-pseudonym-secret', + 'http://mock-credits', + 'test-service-key', + new Set([founder.id]) + ); + + const wish = await founderService.createFeedback(founder.id, { + appId: 'mana', + feedbackText: 'founder-authored wish that ships and should NOT pay back', + }); + await new Promise((r) => setTimeout(r, 30)); + fetchMock.calls.length = 0; + + await founderService.adminUpdate(wish.id, { status: 'completed' }); + await new Promise((r) => setTimeout(r, 50)); + + const founderGrants = fetchMock.calls.filter((c) => c.userId === founder.id); + expect(founderGrants).toHaveLength(0); + }); + }); + + describe('reaction-bonus', () => { + it('does not pay an author who reacted on their own item', async () => { + const author = await seedUser(db); + const supporter = await seedUser(db); + + const wish = await service.createFeedback(author.id, { + appId: 'mana', + feedbackText: 'a wish that the author also reacts to themselves yay', + }); + await new Promise((r) => setTimeout(r, 30)); + + await service.toggleReaction(wish.id, author.id, 'πŸ‘'); // self-react + await service.toggleReaction(wish.id, supporter.id, 'πŸš€'); + + fetchMock.calls.length = 0; + await service.adminUpdate(wish.id, { status: 'completed' }); + await new Promise((r) => setTimeout(r, 100)); + + // Author gets +500 from the ship bonus, but NOT a separate + // +25 reaction-match (would be double-dipping). Supporter gets +25. + const calls = fetchMock.calls; + const authorReactionCalls = calls.filter( + (c) => c.userId === author.id && c.reason === 'feedback_reaction_match' + ); + expect(authorReactionCalls).toHaveLength(0); + + const supporterReactionCalls = calls.filter( + (c) => c.userId === supporter.id && c.reason === 'feedback_reaction_match' + ); + expect(supporterReactionCalls).toHaveLength(1); + }); + + it('only pays each reactioner once even if they used multiple eligible emojis', async () => { + const author = await seedUser(db); + const enthusiast = await seedUser(db); + + const wish = await service.createFeedback(author.id, { + appId: 'mana', + feedbackText: 'a wish that one supporter reacts to with multiple emojis', + }); + await new Promise((r) => setTimeout(r, 30)); + + await service.toggleReaction(wish.id, enthusiast.id, 'πŸ‘'); + await service.toggleReaction(wish.id, enthusiast.id, 'πŸš€'); + + fetchMock.calls.length = 0; + await service.adminUpdate(wish.id, { status: 'completed' }); + await new Promise((r) => setTimeout(r, 100)); + + const enthusiastReactionCalls = fetchMock.calls.filter( + (c) => c.userId === enthusiast.id && c.reason === 'feedback_reaction_match' + ); + expect(enthusiastReactionCalls).toHaveLength(1); + }); + }); + + void userFeedback; // keep imports tree-shake-safe + void feedbackReactions; +}); diff --git a/services/mana-analytics/src/services/feedback-karma.integration.test.ts b/services/mana-analytics/src/services/feedback-karma.integration.test.ts new file mode 100644 index 000000000..a8e56f358 --- /dev/null +++ b/services/mana-analytics/src/services/feedback-karma.integration.test.ts @@ -0,0 +1,140 @@ +/** + * Integration tests for the cross-schema karma flow. + * + * Karma lives on auth.users.community_karma; mana-analytics increments + * it inside toggleReaction. Tests verify the SQL path, the self-react + * skip, and the floor-at-zero clamp. + */ + +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'bun:test'; +import { FeedbackService } from './feedback'; +import { + HAVE_TEST_DB, + connectTestDb, + seedUser, + cleanupTestData, + getKarma, + mockCreditsFetch, + type TestDb, + type FetchMock, +} from '../test-helpers/db'; + +const maybeDescribe = HAVE_TEST_DB ? describe : describe.skip; + +maybeDescribe('feedback service β€” karma integration', () => { + let client: ReturnType; + let db!: TestDb; + let service!: FeedbackService; + let fetchMock!: FetchMock; + + beforeAll(() => { + const conn = connectTestDb(); + client = conn.client; + db = conn.db; + }); + + beforeEach(() => { + fetchMock = mockCreditsFetch(); + service = new FeedbackService( + db, + '', + 'test-pseudonym-secret', + 'http://mock-credits', + 'test-service-key', + new Set() + ); + }); + + afterAll(async () => { + await cleanupTestData(db); + await client.end(); + }); + + it('increments author karma by 1 when a different user reacts', async () => { + const author = await seedUser(db); + const reactor = await seedUser(db); + + const wish = await service.createFeedback(author.id, { + appId: 'mana', + feedbackText: 'a wish that another user will react to with a thumbs up', + }); + await new Promise((r) => setTimeout(r, 30)); + + expect(await getKarma(db, author.id)).toBe(0); + await service.toggleReaction(wish.id, reactor.id, 'πŸ‘'); + expect(await getKarma(db, author.id)).toBe(1); + }); + + it('decrements karma when the same user un-reacts', async () => { + const author = await seedUser(db); + const reactor = await seedUser(db); + const wish = await service.createFeedback(author.id, { + appId: 'mana', + feedbackText: 'a wish to react and un-react against, twenty plus chars', + }); + await new Promise((r) => setTimeout(r, 30)); + + await service.toggleReaction(wish.id, reactor.id, 'πŸ‘'); // +1 + expect(await getKarma(db, author.id)).toBe(1); + + await service.toggleReaction(wish.id, reactor.id, 'πŸ‘'); // toggle off β†’ -1 + expect(await getKarma(db, author.id)).toBe(0); + }); + + it('does NOT change karma when the author reacts on their own post', async () => { + const author = await seedUser(db); + const wish = await service.createFeedback(author.id, { + appId: 'mana', + feedbackText: 'an item the author reacts to themselves which should not karma-up', + }); + await new Promise((r) => setTimeout(r, 30)); + + await service.toggleReaction(wish.id, author.id, 'πŸ‘'); + expect(await getKarma(db, author.id)).toBe(0); + + await service.toggleReaction(wish.id, author.id, 'πŸš€'); + expect(await getKarma(db, author.id)).toBe(0); + }); + + it('floor-clamps at 0 even with concurrent unreact noise', async () => { + const author = await seedUser(db); + const reactor = await seedUser(db); + const wish = await service.createFeedback(author.id, { + appId: 'mana', + feedbackText: 'tests the floor-at-zero clamp on the karma counter please', + }); + await new Promise((r) => setTimeout(r, 30)); + + // Manually push karma to 0 via the service, then attempt an extra + // "unreact" β€” the row doesn't exist, so the toggle inserts it and + // karma = +1. The floor only matters if our SQL goes negative, + // which we test directly via the GREATEST() guard. + await service.toggleReaction(wish.id, reactor.id, 'πŸ‘'); // +1 + await service.toggleReaction(wish.id, reactor.id, 'πŸ‘'); // -1 β†’ 0 + expect(await getKarma(db, author.id)).toBe(0); + + // Re-toggle: should go back to +1, never below 0. + await service.toggleReaction(wish.id, reactor.id, 'πŸ‘'); + expect(await getKarma(db, author.id)).toBe(1); + }); + + it('counts each emoji separately on the same item', async () => { + const author = await seedUser(db); + const reactor = await seedUser(db); + const wish = await service.createFeedback(author.id, { + appId: 'mana', + feedbackText: 'wish that one user reacts to with multiple distinct emojis', + }); + await new Promise((r) => setTimeout(r, 30)); + + await service.toggleReaction(wish.id, reactor.id, 'πŸ‘'); // +1 + await service.toggleReaction(wish.id, reactor.id, '❀️'); // +1 + await service.toggleReaction(wish.id, reactor.id, 'πŸš€'); // +1 + expect(await getKarma(db, author.id)).toBe(3); + + await service.toggleReaction(wish.id, reactor.id, 'πŸš€'); // -1 + expect(await getKarma(db, author.id)).toBe(2); + }); + + void fetchMock; // mock is set up just to swallow grant-calls +}); diff --git a/services/mana-analytics/src/services/feedback-notifications.integration.test.ts b/services/mana-analytics/src/services/feedback-notifications.integration.test.ts new file mode 100644 index 000000000..df0cef9e4 --- /dev/null +++ b/services/mana-analytics/src/services/feedback-notifications.integration.test.ts @@ -0,0 +1,192 @@ +/** + * Integration tests for the loop-closure notifications. + * + * Verify that adminUpdate enqueues the right notifications, that + * mark-read scopes correctly to the requesting user, and that + * Reactioner-Bonus notifications land alongside the credit grants. + */ + +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'bun:test'; +import { eq } from 'drizzle-orm'; +import { FeedbackService } from './feedback'; +import { feedbackNotifications } from '../db/schema/feedback'; +import { + HAVE_TEST_DB, + connectTestDb, + seedUser, + cleanupTestData, + mockCreditsFetch, + type TestDb, + type FetchMock, +} from '../test-helpers/db'; + +const maybeDescribe = HAVE_TEST_DB ? describe : describe.skip; + +maybeDescribe('feedback service β€” notification integration', () => { + let client: ReturnType; + let db!: TestDb; + let service!: FeedbackService; + let fetchMock!: FetchMock; + + beforeAll(() => { + const conn = connectTestDb(); + client = conn.client; + db = conn.db; + }); + + beforeEach(() => { + fetchMock = mockCreditsFetch(); + service = new FeedbackService( + db, + '', + 'test-pseudonym-secret', + 'http://mock-credits', + 'test-service-key', + new Set() + ); + }); + + afterAll(async () => { + await cleanupTestData(db); + await client.end(); + }); + + it('enqueues an author notification on every status transition', async () => { + const author = await seedUser(db); + const wish = await service.createFeedback(author.id, { + appId: 'mana', + feedbackText: 'a wish to walk through every single status transition with', + }); + await new Promise((r) => setTimeout(r, 30)); + + await service.adminUpdate(wish.id, { status: 'planned' }); + await new Promise((r) => setTimeout(r, 30)); + await service.adminUpdate(wish.id, { status: 'in_progress' }); + await new Promise((r) => setTimeout(r, 30)); + await service.adminUpdate(wish.id, { status: 'completed' }); + await new Promise((r) => setTimeout(r, 50)); + + const notifs = await service.getNotifications(author.id); + const kinds = notifs.map((n) => n.kind); + expect(kinds).toContain('status_planned'); + expect(kinds).toContain('status_in_progress'); + expect(kinds).toContain('status_completed'); + }); + + it('does NOT enqueue when status is unchanged', async () => { + const author = await seedUser(db); + const wish = await service.createFeedback(author.id, { + appId: 'mana', + feedbackText: 'a wish where adminUpdate touches non-status fields only', + }); + await new Promise((r) => setTimeout(r, 30)); + + await service.adminUpdate(wish.id, { isPublic: false }); + await new Promise((r) => setTimeout(r, 30)); + + const notifs = await service.getNotifications(author.id); + expect(notifs.filter((n) => n.kind.startsWith('status_'))).toHaveLength(0); + }); + + it('enqueues an admin_response notification when adminResponse is added', async () => { + const author = await seedUser(db); + const wish = await service.createFeedback(author.id, { + appId: 'mana', + feedbackText: 'a wish that the admin will respond to with a real reply text', + }); + await new Promise((r) => setTimeout(r, 30)); + + await service.adminUpdate(wish.id, { + adminResponse: 'Danke fΓΌr den Hinweis β€” wir schauen uns das an.', + }); + await new Promise((r) => setTimeout(r, 30)); + + const notifs = await service.getNotifications(author.id); + const responseNotifs = notifs.filter((n) => n.kind === 'admin_response'); + expect(responseNotifs).toHaveLength(1); + expect(responseNotifs[0].body).toContain('Danke fΓΌr den Hinweis'); + }); + + it('enqueues a reactioner_bonus notification on completed transition', async () => { + const author = await seedUser(db); + const supporter = await seedUser(db); + const wish = await service.createFeedback(author.id, { + appId: 'mana', + feedbackText: 'a wish that one supporter reacts to and watches it ship', + }); + await new Promise((r) => setTimeout(r, 30)); + + await service.toggleReaction(wish.id, supporter.id, 'πŸš€'); + await service.adminUpdate(wish.id, { status: 'completed' }); + await new Promise((r) => setTimeout(r, 100)); + + const supporterNotifs = await service.getNotifications(supporter.id); + const bonusNotifs = supporterNotifs.filter((n) => n.kind === 'reactioner_bonus'); + expect(bonusNotifs).toHaveLength(1); + expect(bonusNotifs[0].creditsAwarded).toBe(25); + }); + + it('markNotificationRead scopes to the requesting user (cannot read someone else)', async () => { + const owner = await seedUser(db); + const stranger = await seedUser(db); + const wish = await service.createFeedback(owner.id, { + appId: 'mana', + feedbackText: 'wish whose notification we will try to read as a stranger', + }); + await new Promise((r) => setTimeout(r, 30)); + + await service.adminUpdate(wish.id, { status: 'planned' }); + await new Promise((r) => setTimeout(r, 30)); + + const ownerNotifs = await service.getNotifications(owner.id); + expect(ownerNotifs).toHaveLength(1); + const notif = ownerNotifs[0]; + expect(notif.readAt).toBeNull(); + + // Stranger tries to mark the owner's notification β€” must be a no-op. + await service.markNotificationRead(notif.id, stranger.id); + const [unchanged] = await db + .select() + .from(feedbackNotifications) + .where(eq(feedbackNotifications.id, notif.id)) + .limit(1); + expect(unchanged.readAt).toBeNull(); + + // Owner marks it β€” readAt is set. + await service.markNotificationRead(notif.id, owner.id); + const [marked] = await db + .select() + .from(feedbackNotifications) + .where(eq(feedbackNotifications.id, notif.id)) + .limit(1); + expect(marked.readAt).not.toBeNull(); + }); + + it('markAllNotificationsRead only touches the requesting user’s rows', async () => { + const userA = await seedUser(db); + const userB = await seedUser(db); + const wishA = await service.createFeedback(userA.id, { + appId: 'mana', + feedbackText: 'wish authored by user A that gets a status notification', + }); + const wishB = await service.createFeedback(userB.id, { + appId: 'mana', + feedbackText: 'wish authored by user B that gets a status notification', + }); + await new Promise((r) => setTimeout(r, 30)); + + await service.adminUpdate(wishA.id, { status: 'planned' }); + await service.adminUpdate(wishB.id, { status: 'planned' }); + await new Promise((r) => setTimeout(r, 30)); + + const result = await service.markAllNotificationsRead(userA.id); + expect(result.count).toBe(1); + + const aNotifs = await service.getNotifications(userA.id); + const bNotifs = await service.getNotifications(userB.id); + expect(aNotifs.every((n) => n.readAt)).toBe(true); + expect(bNotifs.every((n) => n.readAt === null)).toBe(true); + }); + + void fetchMock; // mock swallows grant calls so they don't fail tests +}); diff --git a/services/mana-analytics/src/test-helpers/db.ts b/services/mana-analytics/src/test-helpers/db.ts new file mode 100644 index 000000000..e910ad343 --- /dev/null +++ b/services/mana-analytics/src/test-helpers/db.ts @@ -0,0 +1,157 @@ +/** + * Integration-test scaffolding for mana-analytics. + * + * Connects to TEST_DATABASE_URL, exposes helpers to seed + clean up + * test data, and patches globalThis.fetch so calls to mana-credits + * are captured locally instead of hitting a real service. The whole + * suite skips itself when TEST_DATABASE_URL is unset so a fresh + * `bun test` doesn't fail in environments without a Postgres. + */ + +import postgres from 'postgres'; +import { drizzle } from 'drizzle-orm/postgres-js'; +import { eq, sql } from 'drizzle-orm'; +import { randomUUID } from 'crypto'; +import { authUsers } from '../db/schema/auth-users'; +import { + userFeedback, + feedbackReactions, + feedbackNotifications, + feedbackGrantLog, +} from '../db/schema/feedback'; +import * as schema from '../db/schema'; + +export const TEST_DATABASE_URL = process.env.TEST_DATABASE_URL ?? ''; +export const HAVE_TEST_DB = TEST_DATABASE_URL.length > 0; + +export type TestDb = ReturnType>; + +export function connectTestDb() { + const client = postgres(TEST_DATABASE_URL, { max: 3 }); + const db = drizzle(client, { schema }); + return { client, db }; +} + +export interface SeededUser { + id: string; + email: string; + name: string; +} + +let seededIds = new Set(); + +/** + * Insert a fresh row in auth.users for a test, returns the userId. + * Always namespaced with `test-` prefix so a missed cleanup never + * collides with real production data. + */ +export async function seedUser( + db: TestDb, + overrides: Partial<{ name: string; communityShowRealName: boolean; communityKarma: number }> = {} +): Promise { + const id = `test-${randomUUID()}`; + const email = `${id}@test.local`; + const name = overrides.name ?? `Test User ${id.slice(5, 10)}`; + + // Use a raw SQL insert because the cross-schema authUsers Drizzle + // model only declares the columns mana-analytics READS β€” auth.users + // has additional NOT NULL columns (email, etc.) we'd otherwise miss. + await db.execute(sql` + INSERT INTO auth.users (id, email, name, community_show_real_name, community_karma) + VALUES ( + ${id}, + ${email}, + ${name}, + ${overrides.communityShowRealName ?? false}, + ${overrides.communityKarma ?? 0} + ) + ON CONFLICT (id) DO NOTHING + `); + seededIds.add(id); + return { id, email, name }; +} + +/** Read auth.users.community_karma for a test user. */ +export async function getKarma(db: TestDb, userId: string): Promise { + const [row] = await db + .select({ karma: authUsers.communityKarma }) + .from(authUsers) + .where(eq(authUsers.id, userId)) + .limit(1); + return row?.karma ?? 0; +} + +/** Truncate test-namespaced rows after a suite. */ +export async function cleanupTestData(db: TestDb): Promise { + if (seededIds.size === 0) return; + const ids = Array.from(seededIds); + + // Delete in dependency-aware order. + for (const id of ids) { + await db.delete(feedbackNotifications).where(sql`user_id = ${id}`); + await db.delete(feedbackReactions).where(sql`user_id = ${id}`); + await db.delete(feedbackGrantLog).where(sql`user_id = ${id}`); + await db.delete(userFeedback).where(sql`user_id = ${id}`); + } + for (const id of ids) { + await db.delete(authUsers).where(eq(authUsers.id, id)); + } + seededIds.clear(); +} + +/** + * Replace globalThis.fetch with a recorder. Returns the captured calls + * + a `restore()` to put the original fetch back. The mock returns a + * fixed `{ ok: true, alreadyGranted: false, newBalance: }` + * response for /credits/grant β€” enough to keep grantCredits happy. + */ +export interface CreditGrantCall { + userId: string; + amount: number; + reason: string; + referenceId: string; + description?: string; +} + +export interface FetchMock { + calls: CreditGrantCall[]; + restore: () => void; + makeAlreadyGranted: () => void; +} + +export function mockCreditsFetch(): FetchMock { + const original = globalThis.fetch; + const calls: CreditGrantCall[] = []; + let alreadyGrantedNext = false; + + globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => { + const u = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url; + if (u.includes('/internal/credits/grant')) { + const body = init?.body ? (typeof init.body === 'string' ? JSON.parse(init.body) : {}) : {}; + calls.push(body as CreditGrantCall); + const resp = { + ok: true, + alreadyGranted: alreadyGrantedNext, + newBalance: alreadyGrantedNext ? 0 : (body as CreditGrantCall).amount, + transactionId: `mock-tx-${calls.length}`, + }; + alreadyGrantedNext = false; + return new Response(JSON.stringify(resp), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + } + // Pass-through for non-credits calls. + return original(url, init); + }) as typeof fetch; + + return { + calls, + restore: () => { + globalThis.fetch = original; + }, + makeAlreadyGranted: () => { + alreadyGrantedNext = true; + }, + }; +}