diff --git a/docs/api-endpoints.md b/docs/api-endpoints.md index d6b25bd1..ebc54ad4 100644 --- a/docs/api-endpoints.md +++ b/docs/api-endpoints.md @@ -740,7 +740,9 @@ Cached for 60 seconds. "success": true, "data": { "total_balance": "1200000000", - "per_source_breakdown": [{ "source": "trading", "balance": "1500000000" }], + "per_source_breakdown": [ + { "source": "trading", "balance": "1500000000" } + ], "last_updated": "2026-10-21T10:00:00.000Z" } } @@ -804,6 +806,28 @@ serialised as strings. Response is cached in Redis for 10 seconds. - `400 Bad Request` — `quantity` is missing, non-integer, ≤ 0, or > 1 000 000 - `404 Not Found` — key does not exist +### Community Polls + +Polls are indexed from `PollCreated`, `PollVoteCast`, and `PollClosed` contract +events. Results report vote counts and summed vote weight per option. Responses +are cached for 30 seconds and invalidated when a poll event for the key is +indexed. + +#### GET /keys/:keyId/polls + +Lists all active and closed polls for a key with expiry, total votes, total +voting weight, and per-option weight tallies. + +#### GET /keys/:keyId/polls/:pollId + +Returns poll details and per-option vote counts and weights in `optionResults`. + +#### GET /keys/:keyId/polls/:pollId/votes/:wallet + +Returns whether the wallet voted and, if so, its selected option and indexed +vote weight. Invalid Stellar wallet addresses return `400`; unknown keys or +polls return `404`. + --- ## Common Headers diff --git a/docs/badge-minting.md b/docs/badge-minting.md index 8e78afdb..465c7ab8 100644 --- a/docs/badge-minting.md +++ b/docs/badge-minting.md @@ -21,15 +21,17 @@ A single swap can earn both `FIRST_BUYER` and `WHALE_HOLDER`. ## Components -| File | Responsibility | -| --------------------------------------------- | -------------------------------------------------------------- | -| `src/modules/badges/badge-trigger.service.ts` | `evaluateEvent` (pure rules) + `processQualifyingEvent`. | -| `src/modules/badges/badge-mint.service.ts` | `mintBadge`: dedup guard → mint w/ retry → audit log. | -| `src/modules/badges/badge-mint.client.ts` | Default client: submits `mint_badge` via the contract service. | -| `src/modules/badges/badge-mint.builder.ts` | Builds + signs the `mint_badge(wallet, badge_type)` envelope. | -| `src/modules/badges/badge.constants.ts` | Thresholds (whale/hold/rank) and attempt limits. | -| `src/modules/badges/badge.types.ts` | Badge types, event shapes, client/result interfaces. | -| `prisma/schema/badge-mint.prisma` | `BadgeMintLog` audit table. | +| File | Responsibility | +| ----------------------------------------------------- | -------------------------------------------------------------- | +| `src/modules/badges/badge-trigger.service.ts` | `evaluateEvent` (pure rules) + `processQualifyingEvent`. | +| `src/modules/badges/badge-mint.service.ts` | `mintBadge`: dedup guard → mint w/ retry → audit log. | +| `src/modules/badges/badge-mint.client.ts` | Default client: submits `mint_badge` via the contract service. | +| `src/modules/badges/badge-mint.builder.ts` | Builds + signs the `mint_badge(wallet, badge_type)` envelope. | +| `src/modules/badges/badge.constants.ts` | Thresholds (whale/hold/rank) and attempt limits. | +| `src/modules/badges/badge.types.ts` | Badge types, event shapes, client/result interfaces. | +| `src/modules/indexer/badge-minted-indexer.service.ts` | Indexes confirmed `BadgeMinted` events into `wallet_badges`. | +| `src/modules/badges/badge-read.service.ts` | Wallet badge collection/summary with five-minute cache. | +| `prisma/schema/badge-mint.prisma` | `BadgeMintLog` audit table. | ## Flow @@ -87,6 +89,18 @@ await processQualifyingEvent({ await processQualifyingEvent({ kind: 'vote', wallet, proposalId, txHash }); ``` +Confirmed `BadgeMinted` events are independently indexed into `wallet_badges` +by the shared indexer pipeline. The cursor advances only after this projection +is persisted. Wallet badge reads are available at: + +- `GET /api/v1/wallets/:wallet/badges` — earned badges and the remaining locked + badge types, with mint timestamps for earned badges. +- `GET /api/v1/wallets/:wallet/badges/summary` — total earned and a count for + each of the five badge types. + +Both read responses are cached for five minutes. A newly indexed badge mint +invalidates both cached responses for that wallet. + ## Configuration | Variable | Default | Description | diff --git a/prisma/schema/badge-mint.prisma b/prisma/schema/badge-mint.prisma index 5e6e62bb..ade7fa3f 100644 --- a/prisma/schema/badge-mint.prisma +++ b/prisma/schema/badge-mint.prisma @@ -37,3 +37,19 @@ model BadgeMintLog { @@index([status]) @@map("badge_mint_log") } + +/// On-chain badge mints indexed by their original chain position. +model WalletBadge { + id String @id @default(cuid()) + wallet String + badgeType String + mintedAt DateTime + txHash String + eventIndex Int + ledger Int + + @@unique([wallet, badgeType]) + @@unique([txHash, eventIndex]) + @@index([wallet, mintedAt]) + @@map("wallet_badges") +} diff --git a/prisma/schema/community-poll.prisma b/prisma/schema/community-poll.prisma new file mode 100644 index 00000000..4b1c3f3b --- /dev/null +++ b/prisma/schema/community-poll.prisma @@ -0,0 +1,34 @@ +model KeyPoll { + id String @id @default(cuid()) + keyId String + pollId String + question String + options Json + expiresAt DateTime + status String @default("active") + closedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([keyId, pollId]) + @@index([keyId, status]) + @@index([keyId, expiresAt]) + @@map("key_polls") +} + +model KeyPollVote { + id String @id @default(cuid()) + keyId String + pollId String + wallet String + optionIndex Int + weight Decimal @db.Decimal(30, 7) + txHash String + eventIndex Int + castAt DateTime @default(now()) + + @@unique([keyId, pollId, wallet]) + @@unique([txHash, eventIndex]) + @@index([keyId, pollId, optionIndex]) + @@map("poll_votes") +} diff --git a/prisma/schema/migrations/20261021140000_add_wallet_badges/migration.sql b/prisma/schema/migrations/20261021140000_add_wallet_badges/migration.sql new file mode 100644 index 00000000..359adc75 --- /dev/null +++ b/prisma/schema/migrations/20261021140000_add_wallet_badges/migration.sql @@ -0,0 +1,18 @@ +CREATE TABLE "wallet_badges" ( + "id" TEXT NOT NULL, + "wallet" TEXT NOT NULL, + "badgeType" TEXT NOT NULL, + "mintedAt" TIMESTAMP(3) NOT NULL, + "txHash" TEXT NOT NULL, + "eventIndex" INTEGER NOT NULL, + "ledger" INTEGER NOT NULL, + + CONSTRAINT "wallet_badges_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "wallet_badges_wallet_badgeType_key" + ON "wallet_badges"("wallet", "badgeType"); +CREATE UNIQUE INDEX "wallet_badges_txHash_eventIndex_key" + ON "wallet_badges"("txHash", "eventIndex"); +CREATE INDEX "wallet_badges_wallet_mintedAt_idx" + ON "wallet_badges"("wallet", "mintedAt"); diff --git a/prisma/schema/migrations/20261022100000_add_community_polls/migration.sql b/prisma/schema/migrations/20261022100000_add_community_polls/migration.sql new file mode 100644 index 00000000..23865a26 --- /dev/null +++ b/prisma/schema/migrations/20261022100000_add_community_polls/migration.sql @@ -0,0 +1,42 @@ +CREATE TABLE "key_polls" ( + "id" TEXT NOT NULL, + "keyId" TEXT NOT NULL, + "pollId" TEXT NOT NULL, + "question" TEXT NOT NULL, + "options" JSONB NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "status" TEXT NOT NULL DEFAULT 'active', + "closedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "key_polls_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "key_polls_keyId_pollId_key" +ON "key_polls"("keyId", "pollId"); +CREATE INDEX "key_polls_keyId_status_idx" +ON "key_polls"("keyId", "status"); +CREATE INDEX "key_polls_keyId_expiresAt_idx" +ON "key_polls"("keyId", "expiresAt"); + +CREATE TABLE "poll_votes" ( + "id" TEXT NOT NULL, + "keyId" TEXT NOT NULL, + "pollId" TEXT NOT NULL, + "wallet" TEXT NOT NULL, + "optionIndex" INTEGER NOT NULL, + "weight" DECIMAL(30,7) NOT NULL, + "txHash" TEXT NOT NULL, + "eventIndex" INTEGER NOT NULL, + "castAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "poll_votes_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "poll_votes_keyId_pollId_wallet_key" +ON "poll_votes"("keyId", "pollId", "wallet"); +CREATE UNIQUE INDEX "poll_votes_txHash_eventIndex_key" +ON "poll_votes"("txHash", "eventIndex"); +CREATE INDEX "poll_votes_keyId_pollId_optionIndex_idx" +ON "poll_votes"("keyId", "pollId", "optionIndex"); diff --git a/src/modules/badges/badge-read.service.test.ts b/src/modules/badges/badge-read.service.test.ts new file mode 100644 index 00000000..28f30676 --- /dev/null +++ b/src/modules/badges/badge-read.service.test.ts @@ -0,0 +1,98 @@ +import { + getWalletBadgeCollection, + getWalletBadgeSummary, + WALLET_BADGES_CACHE_TTL_SECONDS, +} from './badge-read.service'; +import { prisma } from '../../utils/prisma.utils'; +import { cacheGetJson, cacheSetJson } from '../../utils/redis.utils'; + +jest.mock('../../utils/redis.utils', () => ({ + cacheGetJson: jest.fn().mockResolvedValue(null), + cacheSetJson: jest.fn().mockResolvedValue(undefined), + cacheInvalidate: jest.fn().mockResolvedValue(undefined), +})); + +jest.mock('../../utils/prisma.utils', () => ({ + prisma: { + walletBadge: { + findMany: jest.fn(), + groupBy: jest.fn(), + }, + }, +})); + +const wallet = `G${'A'.repeat(55)}`; + +beforeEach(() => { + jest.clearAllMocks(); + (cacheGetJson as jest.Mock).mockResolvedValue(null); + (cacheSetJson as jest.Mock).mockResolvedValue(undefined); +}); + +describe('wallet badge read models', () => { + it('returns earned badges and computes all unearned types as locked', async () => { + (prisma.walletBadge.findMany as jest.Mock).mockResolvedValue([ + { + badgeType: 'FIRST_BUYER', + mintedAt: new Date('2026-10-20T12:00:00.000Z'), + }, + { + badgeType: 'TOP_TRADER', + mintedAt: new Date('2026-10-21T12:00:00.000Z'), + }, + ]); + + const result = await getWalletBadgeCollection(wallet); + + expect(result.earned).toEqual([ + { + type: 'FIRST_BUYER', + name: 'First Buyer', + mintedAt: '2026-10-20T12:00:00.000Z', + }, + { + type: 'TOP_TRADER', + name: 'Top Trader', + mintedAt: '2026-10-21T12:00:00.000Z', + }, + ]); + expect(result.locked.map(badge => badge.type)).toEqual([ + 'WHALE_HOLDER', + 'GOVERNANCE_VOTER', + 'DIAMOND_HANDS', + ]); + expect(result.earned.length + result.locked.length).toBe(5); + expect(result.totalEarned).toBe(2); + expect(cacheSetJson).toHaveBeenCalledWith( + `wallet-badges:v1:${wallet}:collection`, + result, + WALLET_BADGES_CACHE_TTL_SECONDS + ); + }); + + it('includes a count for each badge type, including zero-count types', async () => { + (prisma.walletBadge.groupBy as jest.Mock).mockResolvedValue([ + { badgeType: 'FIRST_BUYER', _count: { _all: 2 } }, + { badgeType: 'TOP_TRADER', _count: { _all: 1 } }, + ]); + + const result = await getWalletBadgeSummary(wallet); + + expect(result.totalEarned).toBe(3); + expect(result.byType).toHaveLength(5); + expect(result.byType.map(badge => badge.count)).toEqual([2, 0, 0, 0, 1]); + expect(cacheSetJson).toHaveBeenCalledWith( + `wallet-badges:v1:${wallet}:summary`, + result, + WALLET_BADGES_CACHE_TTL_SECONDS + ); + }); + + it('serves cached collection data without querying the database', async () => { + const cached = { earned: [], locked: [], totalEarned: 0 }; + (cacheGetJson as jest.Mock).mockResolvedValue(cached); + + await expect(getWalletBadgeCollection(wallet)).resolves.toBe(cached); + expect(prisma.walletBadge.findMany).not.toHaveBeenCalled(); + }); +}); diff --git a/src/modules/badges/badge-read.service.ts b/src/modules/badges/badge-read.service.ts new file mode 100644 index 00000000..5a49e5bf --- /dev/null +++ b/src/modules/badges/badge-read.service.ts @@ -0,0 +1,106 @@ +import { BADGE_TYPES, type BadgeType } from './badge.types'; +import { prisma } from '../../utils/prisma.utils'; +import { + cacheGetJson, + cacheInvalidate, + cacheSetJson, +} from '../../utils/redis.utils'; + +export const WALLET_BADGES_CACHE_TTL_SECONDS = 5 * 60; + +const BADGE_NAMES: Record = { + FIRST_BUYER: 'First Buyer', + WHALE_HOLDER: 'Whale Holder', + GOVERNANCE_VOTER: 'Governance Voter', + DIAMOND_HANDS: 'Diamond Hands', + TOP_TRADER: 'Top Trader', +}; + +export interface WalletBadgeItem { + type: BadgeType; + name: string; + mintedAt: string | null; +} + +export interface WalletBadgeCollection { + earned: WalletBadgeItem[]; + locked: WalletBadgeItem[]; + totalEarned: number; +} + +export interface WalletBadgeSummary { + totalEarned: number; + byType: Array<{ type: BadgeType; name: string; count: number }>; +} + +const collectionCacheKey = (wallet: string): string => + `wallet-badges:v1:${wallet}:collection`; +const summaryCacheKey = (wallet: string): string => + `wallet-badges:v1:${wallet}:summary`; + +export async function getWalletBadgeCollection( + wallet: string +): Promise { + const key = collectionCacheKey(wallet); + const cached = await cacheGetJson(key); + if (cached) return cached; + + const rows = await prisma.walletBadge.findMany({ + where: { wallet }, + select: { badgeType: true, mintedAt: true }, + orderBy: { mintedAt: 'desc' }, + }); + const earnedTypes = new Set(rows.map(row => row.badgeType)); + const mintedAtByType = new Map( + rows.map(row => [row.badgeType, row.mintedAt.toISOString()]) + ); + + const earned: WalletBadgeItem[] = []; + const locked: WalletBadgeItem[] = []; + for (const type of BADGE_TYPES) { + const item = { + type, + name: BADGE_NAMES[type], + mintedAt: mintedAtByType.get(type) ?? null, + }; + if (earnedTypes.has(type)) earned.push(item); + else locked.push(item); + } + + const result = { earned, locked, totalEarned: earned.length }; + await cacheSetJson(key, result, WALLET_BADGES_CACHE_TTL_SECONDS); + return result; +} + +export async function getWalletBadgeSummary( + wallet: string +): Promise { + const key = summaryCacheKey(wallet); + const cached = await cacheGetJson(key); + if (cached) return cached; + + const rows = await prisma.walletBadge.groupBy({ + by: ['badgeType'], + where: { wallet }, + _count: { _all: true }, + }); + const counts = new Map(rows.map(row => [row.badgeType, row._count._all])); + const byType = BADGE_TYPES.map(type => ({ + type, + name: BADGE_NAMES[type], + count: counts.get(type) ?? 0, + })); + const result = { + totalEarned: byType.reduce((total, badge) => total + badge.count, 0), + byType, + }; + + await cacheSetJson(key, result, WALLET_BADGES_CACHE_TTL_SECONDS); + return result; +} + +export async function invalidateWalletBadgeCache( + wallet: string +): Promise { + await cacheInvalidate(collectionCacheKey(wallet), summaryCacheKey(wallet)); +} diff --git a/src/modules/indexer/badge-minted-indexer.service.test.ts b/src/modules/indexer/badge-minted-indexer.service.test.ts new file mode 100644 index 00000000..b6a9c1ac --- /dev/null +++ b/src/modules/indexer/badge-minted-indexer.service.test.ts @@ -0,0 +1,79 @@ +import { processBadgeMintedEvents } from './badge-minted-indexer.service'; +import { prisma } from '../../utils/prisma.utils'; +import { cacheInvalidate } from '../../utils/redis.utils'; + +jest.mock('../../utils/logger.utils', () => ({ + logger: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + }, +})); + +jest.mock('../../utils/redis.utils', () => ({ + cacheInvalidate: jest.fn().mockResolvedValue(undefined), +})); + +jest.mock('../../utils/prisma.utils', () => ({ + prisma: { + walletBadge: { + createMany: jest.fn().mockResolvedValue({ count: 1 }), + }, + }, +})); + +const wallet = `G${'A'.repeat(55)}`; +const mintedAt = new Date('2026-10-20T12:00:00.000Z'); +const event = { + txHash: 'tx-badge-mint', + eventIndex: 3, + ledger: 800, + timestamp: mintedAt.getTime() / 1000, + eventType: 'BadgeMinted', + wallet, + badge_type: 'FIRST_BUYER', +}; + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe('processBadgeMintedEvents', () => { + it('persists each confirmed mint with its chain timestamp and invalidates wallet caches', async () => { + await processBadgeMintedEvents([event]); + + expect(prisma.walletBadge.createMany).toHaveBeenCalledWith({ + data: [ + { + wallet, + badgeType: 'FIRST_BUYER', + mintedAt, + txHash: 'tx-badge-mint', + eventIndex: 3, + ledger: 800, + }, + ], + skipDuplicates: true, + }); + expect(cacheInvalidate).toHaveBeenCalledWith( + 'wallet-badges:v1:' + wallet + ':collection', + 'wallet-badges:v1:' + wallet + ':summary' + ); + }); + + it('rejects malformed mint events so the indexer cursor cannot pass them', async () => { + await expect( + processBadgeMintedEvents([{ ...event, badge_type: 'UNKNOWN' }]) + ).rejects.toThrow('Invalid BadgeMinted event payload'); + + expect(prisma.walletBadge.createMany).not.toHaveBeenCalled(); + expect(cacheInvalidate).not.toHaveBeenCalled(); + }); + + it('ignores unrelated event types', async () => { + await processBadgeMintedEvents([{ ...event, eventType: 'KEY_BOUGHT' }]); + + expect(prisma.walletBadge.createMany).not.toHaveBeenCalled(); + }); +}); diff --git a/src/modules/indexer/badge-minted-indexer.service.ts b/src/modules/indexer/badge-minted-indexer.service.ts new file mode 100644 index 00000000..db4c4890 --- /dev/null +++ b/src/modules/indexer/badge-minted-indexer.service.ts @@ -0,0 +1,99 @@ +import { z } from 'zod'; +import { prisma } from '../../utils/prisma.utils'; +import { logger } from '../../utils/logger.utils'; +import { + IndexerChainEvent, + processIndexerChainEvents, +} from '../../utils/indexer-event-processor.utils'; +import { + chainPositionSchema, + eventIdentifierSchema, + eventOccurredAt, +} from '../../utils/indexer-event-fields.utils'; +import { isBadgeType } from '../badges/badge.types'; +import { invalidateWalletBadgeCache } from '../badges/badge-read.service'; +import { StellarAddressSchema } from '../wallet/wallet.schemas'; + +export const BADGE_MINTED_EVENT_TYPES = new Set([ + 'BadgeMinted', + 'BADGE_MINTED', +]); + +const badgeMintedPayloadSchema = z.object({ + wallet: eventIdentifierSchema, + badgeType: eventIdentifierSchema, +}); + +export async function processBadgeMintedEvents( + events: IndexerChainEvent[] +): Promise { + await processIndexerChainEvents(events, async event => { + if (!BADGE_MINTED_EVENT_TYPES.has(event.eventType)) return; + + const position = chainPositionSchema.safeParse(event); + const payload = badgeMintedPayloadSchema.safeParse({ + wallet: + event.wallet ?? + event.recipient ?? + event.to ?? + event.owner ?? + event.actor, + badgeType: event.badgeType ?? event.badge_type ?? event.type, + }); + const wallet = payload.success + ? StellarAddressSchema.safeParse(payload.data.wallet) + : null; + const badgeType = + payload.success && isBadgeType(payload.data.badgeType) + ? payload.data.badgeType + : null; + const rawTimestamp = + event.mintedAt ?? event.timestamp ?? event.ledgerClosedAt; + + if ( + !position.success || + !payload.success || + !wallet?.success || + !badgeType || + rawTimestamp === undefined || + rawTimestamp === null + ) { + logger.error( + { + eventId: `${event.txHash}:${event.eventIndex}`, + eventType: event.eventType, + }, + 'BadgeMinted event is missing valid chain position, wallet, type, or timestamp' + ); + throw new Error('Invalid BadgeMinted event payload'); + } + + let mintedAt: Date; + try { + mintedAt = eventOccurredAt({ ...event, timestamp: rawTimestamp }); + } catch (error) { + logger.error( + { + eventId: `${event.txHash}:${event.eventIndex}`, + error: error instanceof Error ? error.message : String(error), + }, + 'BadgeMinted event has an invalid timestamp' + ); + throw error; + } + + await prisma.walletBadge.createMany({ + data: [ + { + wallet: wallet.data, + badgeType, + mintedAt, + ...position.data, + }, + ], + skipDuplicates: true, + }); + + await invalidateWalletBadgeCache(wallet.data); + }); +} diff --git a/src/modules/indexer/indexer-pipeline-dispatch.integration.test.ts b/src/modules/indexer/indexer-pipeline-dispatch.integration.test.ts index 54f2969e..0ecdf071 100644 --- a/src/modules/indexer/indexer-pipeline-dispatch.integration.test.ts +++ b/src/modules/indexer/indexer-pipeline-dispatch.integration.test.ts @@ -10,6 +10,7 @@ import { import { processDividendEvents } from './dividend-indexer.service'; import { processRevenueShareEvents } from './revenue-share-indexer.service'; import { processGovernanceProposalEvents } from './governance-proposal-indexer.service'; +import { processBadgeMintedEvents } from './badge-minted-indexer.service'; import { upsertPriceSnapshot } from './price-snapshot.service'; import { prisma } from '../../utils/prisma.utils'; import { IndexerChainEvent } from '../../utils/indexer-event-processor.utils'; @@ -35,6 +36,13 @@ jest.mock('./lp-indexer.service', () => { jest.mock('./governance-proposal-indexer.service', () => ({ processGovernanceProposalEvents: jest.fn(), })); +jest.mock('./key-poll-indexer.service', () => ({ + processKeyPollEvents: jest.fn(), +})); +jest.mock('./badge-minted-indexer.service', () => ({ + BADGE_MINTED_EVENT_TYPES: new Set(['BadgeMinted', 'BADGE_MINTED']), + processBadgeMintedEvents: jest.fn(), +})); jest.mock('../ownership/ownership.service', () => ({ updateOwnership: jest.fn(), @@ -87,6 +95,9 @@ const mockSocialHandler = processSocialMetadataEvents as jest.Mock; const mockDividendHandler = processDividendEvents as jest.Mock; const mockRevenueShareHandler = processRevenueShareEvents as jest.Mock; const mockGovernanceHandler = processGovernanceProposalEvents as jest.Mock; +const mockKeyPollHandler = jest.requireMock('./key-poll-indexer.service') + .processKeyPollEvents as jest.Mock; +const mockBadgeMintHandler = processBadgeMintedEvents as jest.Mock; const mockUpsertPriceSnapshot = upsertPriceSnapshot as jest.Mock; const mockPrisma = prisma as unknown as { activity: { create: jest.Mock }; @@ -166,6 +177,24 @@ describe('processIndexerEvents', () => { ); }); + it('routes community poll lifecycle events to their indexer', async () => { + const pollEvent: IndexerChainEvent = { + eventType: 'PollVoteCast', + txHash: 'tx-poll-vote', + eventIndex: 0, + ledger: 720, + keyId: 'key-1', + pollId: 'poll-1', + wallet: 'G123', + optionIndex: 0, + weight: '3', + }; + + await processIndexerEvents([pollEvent]); + + expect(mockKeyPollHandler).toHaveBeenCalledWith([pollEvent]); + }); + it('advances the cursor for a batch containing only social events', async () => { await processIndexerEvents([socialEvent({ ledger: 800 })]); @@ -201,6 +230,39 @@ describe('processIndexerEvents', () => { expect(mockPrisma.indexedLedger.upsert).toHaveBeenCalledTimes(1); }); + it('routes BadgeMinted events before advancing the shared cursor', async () => { + const event: IndexerChainEvent = { + txHash: 'tx-badge', + eventIndex: 1, + eventType: 'BadgeMinted', + ledger: 720, + }; + + await processIndexerEvents([event]); + + expect(mockBadgeMintHandler).toHaveBeenCalledWith([event]); + expect(mockPrisma.indexedLedger.upsert).toHaveBeenCalledTimes(1); + }); + + it('does not advance the cursor when BadgeMinted projection fails', async () => { + mockBadgeMintHandler.mockRejectedValueOnce( + new Error('wallet badge write failed') + ); + + await expect( + processIndexerEvents([ + { + txHash: 'tx-badge-failed', + eventIndex: 1, + eventType: 'BadgeMinted', + ledger: 721, + }, + ]) + ).rejects.toThrow('wallet badge write failed'); + + expect(mockPrisma.indexedLedger.upsert).not.toHaveBeenCalled(); + }); + it('routes creator revenue distribution and claim events before advancing the cursor', async () => { const distributionEvent = socialEvent({ txHash: 'tx-revenue-distributed', diff --git a/src/modules/indexer/indexer-pipeline.service.ts b/src/modules/indexer/indexer-pipeline.service.ts index 99f53198..e804f5d3 100644 --- a/src/modules/indexer/indexer-pipeline.service.ts +++ b/src/modules/indexer/indexer-pipeline.service.ts @@ -43,6 +43,17 @@ function isGovernanceProposalEvent(event: IndexerChainEvent): boolean { ); } +function isKeyPollEvent(event: IndexerChainEvent): boolean { + return [ + 'PollCreated', + 'POLL_CREATED', + 'PollVoteCast', + 'POLL_VOTE_CAST', + 'PollClosed', + 'POLL_CLOSED', + ].includes(event.eventType); +} + export interface TradeEventProcessingOptions { /** * Advance the shared ledger cursor after processing the batch. @@ -449,6 +460,15 @@ export async function processIndexerEvents( await processGovernanceProposalEvents(governanceEvents); } + // Community poll events are processed in chain order before advancing the + // shared cursor, so a vote or close cannot be skipped after a failed write. + const keyPollEvents = events.filter(isKeyPollEvent); + if (keyPollEvents.length > 0) { + const { processKeyPollEvents } = + await import('./key-poll-indexer.service'); + await processKeyPollEvents(keyPollEvents); + } + const { CURVE_UPDATE_EVENT_TYPES, processCurveUpdateEvents } = await import('./curve-update-indexer.service'); const curveUpdateEvents = events.filter(event => @@ -600,6 +620,15 @@ export async function processIndexerEvents( await processHealthScoreEvents(healthScoreEvents); } + const { BADGE_MINTED_EVENT_TYPES, processBadgeMintedEvents } = + await import('./badge-minted-indexer.service'); + const badgeMintedEvents = events.filter(event => + BADGE_MINTED_EVENT_TYPES.has(event.eventType) + ); + if (badgeMintedEvents.length > 0) { + await processBadgeMintedEvents(badgeMintedEvents); + } + if (options.advanceCursor !== false) await advanceIndexerCursorForBatch(events); } diff --git a/src/modules/indexer/key-poll-indexer.service.test.ts b/src/modules/indexer/key-poll-indexer.service.test.ts new file mode 100644 index 00000000..fcb6a17a --- /dev/null +++ b/src/modules/indexer/key-poll-indexer.service.test.ts @@ -0,0 +1,134 @@ +import { processKeyPollEvents } from './key-poll-indexer.service'; +import { IndexerChainEvent } from '../../utils/indexer-event-processor.utils'; +import { prisma } from '../../utils/prisma.utils'; +import { cacheInvalidate } from '../../utils/redis.utils'; + +jest.mock('../../utils/prisma.utils', () => ({ + prisma: { + keyPoll: { + create: jest.fn(), + findUnique: jest.fn(), + update: jest.fn(), + }, + keyPollVote: { create: jest.fn() }, + }, +})); + +jest.mock('../../utils/redis.utils', () => ({ + cacheInvalidate: jest.fn(), +})); + +jest.mock('../../utils/logger.utils', () => ({ + logger: { + error: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), + }, +})); + +const mockPrisma = prisma as unknown as { + keyPoll: { + create: jest.Mock; + findUnique: jest.Mock; + update: jest.Mock; + }; + keyPollVote: { create: jest.Mock }; +}; + +describe('community poll indexer', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockPrisma.keyPoll.create.mockResolvedValue({}); + mockPrisma.keyPoll.findUnique.mockImplementation(({ select }) => + select?.status + ? { status: 'active' } + : { options: ['First', 'Second'] } + ); + mockPrisma.keyPollVote.create.mockResolvedValue({}); + mockPrisma.keyPoll.update.mockResolvedValue({}); + }); + + it('indexes poll creation, votes, and closure and invalidates that key cache', async () => { + const events: IndexerChainEvent[] = [ + { + eventType: 'PollCreated', + txHash: 'create-tx', + eventIndex: 0, + keyId: 'key-1', + pollId: 'poll-1', + question: 'Choose', + options: ['First', 'Second'], + expiresAt: '2026-10-30T00:00:00.000Z', + }, + { + eventType: 'PollVoteCast', + txHash: 'vote-tx', + eventIndex: 1, + keyId: 'key-1', + pollId: 'poll-1', + voter: 'G123', + optionIndex: 1, + weight: '12.5', + }, + { + eventType: 'PollClosed', + txHash: 'close-tx', + eventIndex: 2, + keyId: 'key-1', + pollId: 'poll-1', + closedAt: '2026-10-25T00:00:00.000Z', + }, + ]; + + await processKeyPollEvents(events); + + expect(mockPrisma.keyPoll.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + keyId: 'key-1', + pollId: 'poll-1', + question: 'Choose', + options: ['First', 'Second'], + }), + }); + expect(mockPrisma.keyPollVote.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + keyId: 'key-1', + pollId: 'poll-1', + wallet: 'G123', + optionIndex: 1, + txHash: 'vote-tx', + eventIndex: 1, + }), + }); + expect(mockPrisma.keyPoll.update).toHaveBeenCalledWith({ + where: { keyId_pollId: { keyId: 'key-1', pollId: 'poll-1' } }, + data: { + status: 'closed', + closedAt: new Date('2026-10-25T00:00:00.000Z'), + }, + }); + expect(cacheInvalidate).toHaveBeenCalledTimes(3); + expect(cacheInvalidate).toHaveBeenCalledWith('key:poll*:key-1:*'); + }); + + it('does not advance past a vote whose poll has not been indexed', async () => { + mockPrisma.keyPoll.findUnique.mockResolvedValue(null); + await expect( + processKeyPollEvents([ + { + eventType: 'PollVoteCast', + txHash: 'vote-tx', + eventIndex: 0, + keyId: 'key-1', + pollId: 'missing', + wallet: 'G123', + optionIndex: 0, + weight: '1', + }, + ]) + ).rejects.toThrow('PollVoteCast references unknown poll'); + + expect(cacheInvalidate).not.toHaveBeenCalled(); + }); +}); diff --git a/src/modules/indexer/key-poll-indexer.service.ts b/src/modules/indexer/key-poll-indexer.service.ts new file mode 100644 index 00000000..ea76717f --- /dev/null +++ b/src/modules/indexer/key-poll-indexer.service.ts @@ -0,0 +1,274 @@ +import { Decimal } from '@prisma/client/runtime/library'; +import { prisma } from '../../utils/prisma.utils'; +import { logger } from '../../utils/logger.utils'; +import { + processIndexerChainEvents, + IndexerChainEvent, +} from '../../utils/indexer-event-processor.utils'; +import { cacheInvalidate } from '../../utils/redis.utils'; + +export const POLL_CREATED_EVENT_TYPES = new Set([ + 'PollCreated', + 'POLL_CREATED', +]); +export const POLL_VOTE_CAST_EVENT_TYPES = new Set([ + 'PollVoteCast', + 'POLL_VOTE_CAST', +]); +export const POLL_CLOSED_EVENT_TYPES = new Set(['PollClosed', 'POLL_CLOSED']); + +export const KEY_POLL_EVENT_TYPES = new Set([ + ...POLL_CREATED_EVENT_TYPES, + ...POLL_VOTE_CAST_EVENT_TYPES, + ...POLL_CLOSED_EVENT_TYPES, +]); + +function eventId(event: IndexerChainEvent): string { + return `${event.txHash}:${event.eventIndex}`; +} + +function stringValue(value: unknown): string | null { + if (typeof value === 'string' && value.trim() !== '') { + return value.trim(); + } + if (typeof value === 'number' || typeof value === 'bigint') { + return String(value); + } + return null; +} + +function eventDate(...values: unknown[]): Date | null { + for (const value of values) { + if (value instanceof Date && !Number.isNaN(value.getTime())) { + return value; + } + if (typeof value === 'number' && Number.isFinite(value)) { + const milliseconds = value < 1_000_000_000_000 ? value * 1000 : value; + const date = new Date(milliseconds); + if (!Number.isNaN(date.getTime())) return date; + } + if (typeof value === 'string' && value.trim() !== '') { + const numeric = Number(value); + if (Number.isFinite(numeric)) { + const milliseconds = + numeric < 1_000_000_000_000 ? numeric * 1000 : numeric; + const date = new Date(milliseconds); + if (!Number.isNaN(date.getTime())) return date; + } + const date = new Date(value); + if (!Number.isNaN(date.getTime())) return date; + } + } + return null; +} + +function normalizeOptions(value: unknown): string[] { + let options: unknown = value; + if (typeof value === 'string') { + try { + options = JSON.parse(value); + } catch { + options = value.split(','); + } + } + if (!Array.isArray(options)) return []; + return options + .map(option => stringValue(option)) + .filter((option): option is string => option !== null); +} + +function requiredEventValue( + event: IndexerChainEvent, + name: string, + ...values: unknown[] +): string { + const value = values.map(stringValue).find(candidate => candidate !== null); + if (value) return value; + logger.error( + { eventId: eventId(event), missingField: name }, + 'Cannot index community poll event with missing required data' + ); + throw new Error(`Poll event is missing required ${name}`); +} + +function isUniqueConstraintError(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + error.code === 'P2002' + ); +} + +async function invalidatePollCache(keyId: string): Promise { + await cacheInvalidate(`key:poll*:${keyId}:*`); +} + +async function applyPollCreated(event: IndexerChainEvent): Promise { + const keyId = requiredEventValue(event, 'keyId', event.keyId, event.key_id); + const pollId = requiredEventValue( + event, + 'pollId', + event.pollId, + event.poll_id + ); + const options = normalizeOptions(event.options); + const expiresAt = eventDate( + event.expiresAt, + event.expires_at, + event.expiry, + event.deadline, + event.endTime, + event.end_time + ); + if (options.length === 0 || !expiresAt) { + logger.error( + { + eventId: eventId(event), + keyId, + pollId, + optionsCount: options.length, + hasExpiry: expiresAt !== null, + }, + 'Cannot index PollCreated event with invalid options or expiry' + ); + throw new Error('PollCreated event requires options and expiry'); + } + + try { + await prisma.keyPoll.create({ + data: { + keyId, + pollId, + question: + stringValue(event.question) ?? stringValue(event.title) ?? '', + options, + expiresAt, + }, + }); + } catch (error) { + if (!isUniqueConstraintError(error)) throw error; + logger.info( + { eventId: eventId(event), keyId, pollId }, + 'PollCreated already indexed; skipping duplicate event' + ); + } + await invalidatePollCache(keyId); +} + +async function applyPollVoteCast(event: IndexerChainEvent): Promise { + const keyId = requiredEventValue(event, 'keyId', event.keyId, event.key_id); + const pollId = requiredEventValue( + event, + 'pollId', + event.pollId, + event.poll_id + ); + const wallet = requiredEventValue( + event, + 'wallet', + event.wallet, + event.voter, + event.voterAddress, + event.voter_address + ); + const optionIndex = Number(event.optionIndex ?? event.option_index); + const rawWeight = stringValue( + event.weight ?? event.voteWeight ?? event.vote_weight + ); + if ( + !Number.isInteger(optionIndex) || + optionIndex < 0 || + rawWeight === null || + !/^\d+(?:\.\d{1,7})?$/.test(rawWeight) + ) { + logger.error( + { eventId: eventId(event), keyId, pollId, optionIndex, rawWeight }, + 'Cannot index PollVoteCast event with invalid option or weight' + ); + throw new Error('PollVoteCast event has invalid option or weight'); + } + + const poll = await prisma.keyPoll.findUnique({ + where: { keyId_pollId: { keyId, pollId } }, + select: { options: true }, + }); + if (!poll) { + throw new Error( + `PollVoteCast references unknown poll ${keyId}/${pollId}` + ); + } + const options = normalizeOptions(poll.options); + if (optionIndex >= options.length) { + throw new Error( + `PollVoteCast option index is out of range for ${pollId}` + ); + } + + try { + await prisma.keyPollVote.create({ + data: { + keyId, + pollId, + wallet, + optionIndex, + weight: new Decimal(rawWeight), + txHash: event.txHash, + eventIndex: event.eventIndex, + castAt: + eventDate(event.occurredAt, event.createdAt, event.timestamp) ?? + new Date(), + }, + }); + } catch (error) { + if (!isUniqueConstraintError(error)) throw error; + logger.info( + { eventId: eventId(event), keyId, pollId, wallet }, + 'PollVoteCast already indexed; skipping duplicate event' + ); + } + await invalidatePollCache(keyId); +} + +async function applyPollClosed(event: IndexerChainEvent): Promise { + const keyId = requiredEventValue(event, 'keyId', event.keyId, event.key_id); + const pollId = requiredEventValue( + event, + 'pollId', + event.pollId, + event.poll_id + ); + const poll = await prisma.keyPoll.findUnique({ + where: { keyId_pollId: { keyId, pollId } }, + select: { status: true }, + }); + if (!poll) { + throw new Error(`PollClosed references unknown poll ${keyId}/${pollId}`); + } + if (poll.status !== 'closed') { + await prisma.keyPoll.update({ + where: { keyId_pollId: { keyId, pollId } }, + data: { + status: 'closed', + closedAt: + eventDate(event.closedAt, event.closed_at, event.occurredAt) ?? + new Date(), + }, + }); + } + await invalidatePollCache(keyId); +} + +export async function processKeyPollEvents( + events: IndexerChainEvent[] +): Promise { + await processIndexerChainEvents(events, async event => { + if (POLL_CREATED_EVENT_TYPES.has(event.eventType)) { + await applyPollCreated(event); + } else if (POLL_VOTE_CAST_EVENT_TYPES.has(event.eventType)) { + await applyPollVoteCast(event); + } else if (POLL_CLOSED_EVENT_TYPES.has(event.eventType)) { + await applyPollClosed(event); + } + }); +} diff --git a/src/modules/keys/key-poll-weight.routes.test.ts b/src/modules/keys/key-poll-weight.routes.test.ts index 5cadbf77..dc660b1c 100644 --- a/src/modules/keys/key-poll-weight.routes.test.ts +++ b/src/modules/keys/key-poll-weight.routes.test.ts @@ -110,7 +110,7 @@ describe('GET /api/v1/keys/:keyId/polls/:pollId/weight/:wallet', () => { snapshot_block: 4821, }); expect(mockGetPollVoteWeight).toHaveBeenCalledTimes(1); - expect(mockTtls).toEqual([60]); + expect(mockTtls).toEqual([30]); expect(Date.now() - startedAt).toBeLessThan(200); expect(mockCacheStore.has(pollWeightCacheKey(KEY_ID, POLL_ID, WALLET))).toBe( true diff --git a/src/modules/keys/key-poll-weight.service.ts b/src/modules/keys/key-poll-weight.service.ts index 08b2a846..b3b7916c 100644 --- a/src/modules/keys/key-poll-weight.service.ts +++ b/src/modules/keys/key-poll-weight.service.ts @@ -19,7 +19,7 @@ export interface PollVoteWeight { } /** Cache TTL for a wallet/poll weight pair, in seconds. */ -export const POLL_WEIGHT_CACHE_SECONDS = 60; +export const POLL_WEIGHT_CACHE_SECONDS = 30; /** * Redis key for the cached weight of one wallet in one poll. Scoped by key so diff --git a/src/modules/keys/key-polls.routes.test.ts b/src/modules/keys/key-polls.routes.test.ts new file mode 100644 index 00000000..d59d0c12 --- /dev/null +++ b/src/modules/keys/key-polls.routes.test.ts @@ -0,0 +1,83 @@ +import express from 'express'; +import request from 'supertest'; +import keyPollsRouter from './key-polls.routes'; +import { getKeyPoll, getKeyPollVote, getKeyPolls } from './key-polls.service'; + +jest.mock('./key-polls.service', () => ({ + KeyPollNotFoundError: class KeyPollNotFoundError extends Error {}, + getKeyPoll: jest.fn(), + getKeyPollVote: jest.fn(), + getKeyPolls: jest.fn(), +})); + +const mockGetKeyPolls = getKeyPolls as jest.Mock; +const mockGetKeyPoll = getKeyPoll as jest.Mock; +const mockGetKeyPollVote = getKeyPollVote as jest.Mock; +const app = express(); +app.use('/api/v1/keys', keyPollsRouter); + +const wallet = `G${'A'.repeat(55)}`; + +describe('community poll routes', () => { + beforeEach(() => jest.clearAllMocks()); + + it('returns the key poll list', async () => { + mockGetKeyPolls.mockResolvedValue([ + { pollId: 'poll-1', status: 'active' }, + ]); + const response = await request(app).get('/api/v1/keys/key-1/polls'); + + expect(response.status).toBe(200); + expect(response.body.data).toEqual([ + { pollId: 'poll-1', status: 'active' }, + ]); + expect(mockGetKeyPolls).toHaveBeenCalledWith('key-1'); + }); + + it('returns poll detail and per-option results', async () => { + mockGetKeyPoll.mockResolvedValue({ + pollId: 'poll-1', + optionResults: [ + { optionIndex: 0, option: 'Yes', votes: 2, weight: '8' }, + ], + }); + const response = await request(app).get( + '/api/v1/keys/key-1/polls/poll-1' + ); + + expect(response.status).toBe(200); + expect(response.body.data.optionResults[0].weight).toBe('8'); + }); + + it('returns the wallet vote and weight', async () => { + mockGetKeyPollVote.mockResolvedValue({ + wallet, + voted: true, + vote: { optionIndex: 1, option: 'No', weight: '5' }, + }); + const response = await request(app).get( + `/api/v1/keys/key-1/polls/poll-1/votes/${wallet}` + ); + + expect(response.status).toBe(200); + expect(response.body.data.vote).toEqual({ + optionIndex: 1, + option: 'No', + weight: '5', + }); + expect(mockGetKeyPollVote).toHaveBeenCalledWith( + 'key-1', + 'poll-1', + wallet + ); + }); + + it('rejects malformed wallet addresses', async () => { + const response = await request(app).get( + '/api/v1/keys/key-1/polls/poll-1/votes/not-a-wallet' + ); + + expect(response.status).toBe(400); + expect(mockGetKeyPollVote).not.toHaveBeenCalled(); + }); +}); diff --git a/src/modules/keys/key-polls.routes.ts b/src/modules/keys/key-polls.routes.ts new file mode 100644 index 00000000..814f7f8c --- /dev/null +++ b/src/modules/keys/key-polls.routes.ts @@ -0,0 +1,76 @@ +import { Router } from 'express'; +import { + sendNotFound, + sendSuccess, + sendValidationError, + zodIssuesToDetails, +} from '../../utils/api-response.utils'; +import { StellarAddressSchema } from '../wallet/wallet.schemas'; +import { + getKeyPoll, + getKeyPollVote, + getKeyPolls, + KeyPollNotFoundError, +} from './key-polls.service'; + +const router = Router(); + +const voteParamsSchema = StellarAddressSchema; + +router.get('/:keyId/polls', async (req, res, next) => { + try { + const polls = await getKeyPolls(String(req.params.keyId)); + sendSuccess(res, polls); + } catch (error) { + if (error instanceof KeyPollNotFoundError) { + sendNotFound(res, error.resource); + return; + } + next(error); + } +}); + +router.get('/:keyId/polls/:pollId', async (req, res, next) => { + try { + const poll = await getKeyPoll( + String(req.params.keyId), + String(req.params.pollId) + ); + sendSuccess(res, poll); + } catch (error) { + if (error instanceof KeyPollNotFoundError) { + sendNotFound(res, error.resource); + return; + } + next(error); + } +}); + +router.get('/:keyId/polls/:pollId/votes/:wallet', async (req, res, next) => { + const parsed = voteParamsSchema.safeParse(req.params.wallet); + if (!parsed.success) { + sendValidationError( + res, + 'Invalid wallet address', + zodIssuesToDetails(parsed.error.issues) + ); + return; + } + + try { + const result = await getKeyPollVote( + String(req.params.keyId), + String(req.params.pollId), + parsed.data + ); + sendSuccess(res, result); + } catch (error) { + if (error instanceof KeyPollNotFoundError) { + sendNotFound(res, error.resource); + return; + } + next(error); + } +}); + +export default router; diff --git a/src/modules/keys/key-polls.service.test.ts b/src/modules/keys/key-polls.service.test.ts new file mode 100644 index 00000000..18b4584b --- /dev/null +++ b/src/modules/keys/key-polls.service.test.ts @@ -0,0 +1,95 @@ +import { + getKeyPoll, + getKeyPollVote, + getKeyPolls, + KEY_POLLS_CACHE_SECONDS, +} from './key-polls.service'; +import { prisma } from '../../utils/prisma.utils'; +import { cacheGetJson, cacheSetJson } from '../../utils/redis.utils'; + +jest.mock('../../utils/prisma.utils', () => ({ + prisma: { + creatorProfile: { findUnique: jest.fn() }, + keyPoll: { findMany: jest.fn(), findUnique: jest.fn() }, + keyPollVote: { findUnique: jest.fn(), groupBy: jest.fn() }, + }, +})); + +jest.mock('../../utils/redis.utils', () => ({ + cacheGetJson: jest.fn(), + cacheSetJson: jest.fn(), +})); + +const mockPrisma = prisma as unknown as { + creatorProfile: { findUnique: jest.Mock }; + keyPoll: { findMany: jest.Mock; findUnique: jest.Mock }; + keyPollVote: { findUnique: jest.Mock; groupBy: jest.Mock }; +}; + +const poll = { + keyId: 'key-1', + pollId: 'poll-1', + question: 'Choose', + options: ['A', 'B'], + status: 'active', + expiresAt: new Date('2027-01-01T00:00:00.000Z'), + closedAt: null, +}; + +describe('community poll read service', () => { + beforeEach(() => { + jest.clearAllMocks(); + (cacheGetJson as jest.Mock).mockResolvedValue(null); + mockPrisma.creatorProfile.findUnique.mockResolvedValue({ id: 'key-1' }); + mockPrisma.keyPoll.findMany.mockResolvedValue([poll]); + mockPrisma.keyPoll.findUnique.mockResolvedValue(poll); + mockPrisma.keyPollVote.groupBy.mockResolvedValue([ + { + optionIndex: 0, + _count: { _all: 2 }, + _sum: { weight: { toString: () => '7.5' } }, + }, + ]); + mockPrisma.keyPollVote.findUnique.mockResolvedValue({ + optionIndex: 1, + weight: { toString: () => '3.25' }, + }); + }); + + it('returns accurate totals and caches poll lists for 30 seconds', async () => { + const result = await getKeyPolls('key-1'); + + expect(result[0]).toMatchObject({ + pollId: 'poll-1', + totalVotes: 2, + totalVotingWeight: '7.5', + results: { A: '7.5', B: '0' }, + status: 'active', + }); + expect(cacheSetJson).toHaveBeenCalledWith( + 'key:polls:key-1:list', + result, + KEY_POLLS_CACHE_SECONDS + ); + expect(KEY_POLLS_CACHE_SECONDS).toBe(30); + }); + + it('returns per-option results for poll details', async () => { + const result = await getKeyPoll('key-1', 'poll-1'); + + expect(result.optionResults).toEqual([ + { optionIndex: 0, option: 'A', votes: 2, weight: '7.5' }, + { optionIndex: 1, option: 'B', votes: 0, weight: '0' }, + ]); + }); + + it('returns the selected option and indexed vote weight for a wallet', async () => { + const result = await getKeyPollVote('key-1', 'poll-1', 'G123'); + + expect(result).toEqual({ + wallet: 'G123', + voted: true, + vote: { optionIndex: 1, option: 'B', weight: '3.25' }, + }); + }); +}); diff --git a/src/modules/keys/key-polls.service.ts b/src/modules/keys/key-polls.service.ts new file mode 100644 index 00000000..b805ff95 --- /dev/null +++ b/src/modules/keys/key-polls.service.ts @@ -0,0 +1,186 @@ +import { prisma } from '../../utils/prisma.utils'; +import { cacheGetJson, cacheSetJson } from '../../utils/redis.utils'; +import { Decimal } from '@prisma/client/runtime/library'; + +export const KEY_POLLS_CACHE_SECONDS = 30; + +export class KeyPollNotFoundError extends Error { + readonly resource: 'Key' | 'Poll'; + + constructor(keyId: string, pollId?: string) { + super( + pollId + ? `Poll not found: ${keyId}/${pollId}` + : `Key not found: ${keyId}` + ); + this.resource = pollId ? 'Poll' : 'Key'; + this.name = pollId ? 'PollNotFoundError' : 'KeyNotFoundError'; + } +} + +interface PollVoteStatus { + wallet: string; + voted: boolean; + vote: { optionIndex: number; option: string; weight: string } | null; +} + +interface PollResult { + pollId: string; + question: string; + options: string[]; + status: 'active' | 'closed'; + totalVotes: number; + totalVotingWeight: string; + results: Record; + expiresAt: Date; + closedAt: Date | null; +} + +interface PollDetail extends PollResult { + optionResults: Array<{ + optionIndex: number; + option: string; + votes: number; + weight: string; + }>; +} + +function normalizeOptions(value: unknown): string[] { + return Array.isArray(value) + ? value.filter((option): option is string => typeof option === 'string') + : []; +} + +async function getPollResults( + poll: { + keyId: string; + pollId: string; + question: string; + options: unknown; + status: string; + expiresAt: Date; + closedAt: Date | null; + }, + includeOptionResults: boolean +): Promise { + const options = normalizeOptions(poll.options); + const groupedVotes = await prisma.keyPollVote.groupBy({ + by: ['optionIndex'], + where: { keyId: poll.keyId, pollId: poll.pollId }, + _count: { _all: true }, + _sum: { weight: true }, + }); + const results: Record = {}; + const optionResults = options.map((option, optionIndex) => { + const grouped = groupedVotes.find(row => row.optionIndex === optionIndex); + const weight = grouped?._sum.weight?.toString() ?? '0'; + results[option] = weight; + return { + optionIndex, + option, + votes: grouped?._count._all ?? 0, + weight, + }; + }); + const totalWeight = optionResults.reduce( + (total, result) => total.plus(result.weight), + new Decimal(0) + ); + const response: PollResult = { + pollId: poll.pollId, + question: poll.question, + options, + status: + poll.status === 'closed' || poll.expiresAt.getTime() <= Date.now() + ? 'closed' + : 'active', + totalVotes: optionResults.reduce( + (total, result) => total + result.votes, + 0 + ), + totalVotingWeight: totalWeight.toString(), + results, + expiresAt: poll.expiresAt, + closedAt: poll.closedAt, + }; + return includeOptionResults ? { ...response, optionResults } : response; +} + +async function requireKey(keyId: string): Promise { + const key = await prisma.creatorProfile.findUnique({ + where: { id: keyId }, + select: { id: true }, + }); + if (!key) throw new KeyPollNotFoundError(keyId); +} + +export async function getKeyPolls(keyId: string): Promise { + const cacheKey = `key:polls:${keyId}:list`; + const cached = await cacheGetJson(cacheKey); + if (cached !== null) return cached; + + await requireKey(keyId); + const polls = await prisma.keyPoll.findMany({ + where: { keyId }, + orderBy: [{ expiresAt: 'desc' }, { pollId: 'asc' }], + }); + const results = await Promise.all( + polls.map(poll => getPollResults(poll, false) as Promise) + ); + await cacheSetJson(cacheKey, results, KEY_POLLS_CACHE_SECONDS); + return results; +} + +export async function getKeyPoll( + keyId: string, + pollId: string +): Promise { + const cacheKey = `key:polls:${keyId}:${pollId}:detail`; + const cached = await cacheGetJson(cacheKey); + if (cached !== null) return cached; + + await requireKey(keyId); + const poll = await prisma.keyPoll.findUnique({ + where: { keyId_pollId: { keyId, pollId } }, + }); + if (!poll) throw new KeyPollNotFoundError(keyId, pollId); + const result = (await getPollResults(poll, true)) as PollDetail; + await cacheSetJson(cacheKey, result, KEY_POLLS_CACHE_SECONDS); + return result; +} + +export async function getKeyPollVote( + keyId: string, + pollId: string, + wallet: string +): Promise { + const cacheKey = `key:polls:${keyId}:${pollId}:votes:${wallet}`; + const cached = await cacheGetJson(cacheKey); + if (cached !== null) return cached; + + await requireKey(keyId); + const poll = await prisma.keyPoll.findUnique({ + where: { keyId_pollId: { keyId, pollId } }, + select: { options: true }, + }); + if (!poll) throw new KeyPollNotFoundError(keyId, pollId); + const vote = await prisma.keyPollVote.findUnique({ + where: { keyId_pollId_wallet: { keyId, pollId, wallet } }, + select: { optionIndex: true, weight: true }, + }); + const options = normalizeOptions(poll.options); + const result: PollVoteStatus = { + wallet, + voted: vote !== null, + vote: + vote && options[vote.optionIndex] !== undefined + ? { + optionIndex: vote.optionIndex, + option: options[vote.optionIndex], + weight: vote.weight.toString(), + } + : null, + }; + await cacheSetJson(cacheKey, result, KEY_POLLS_CACHE_SECONDS); + return result; +} diff --git a/src/modules/keys/keys.routes.ts b/src/modules/keys/keys.routes.ts index 934b9b7d..953ded50 100644 --- a/src/modules/keys/keys.routes.ts +++ b/src/modules/keys/keys.routes.ts @@ -52,6 +52,7 @@ import coCreatorsRouter from '../co-creators/co-creators.routes'; import reservesRouter from '../reserves/reserves.routes'; import contentAccessRouter from '../content-access/content-access.routes'; import tipsRouter from '../tips/tips.routes'; +import keyPollsRouter from './key-polls.routes'; import { requireJwtAuth, requireKeyCreator, @@ -2095,6 +2096,7 @@ router.use('/', contentAccessRouter); // Mount tip history routes (#1132) router.use('/', tipsRouter); +router.use('/', keyPollsRouter); // ── POST /:keyId/burn ───────────────────────────────────────── diff --git a/src/modules/wallets/wallet-badges.controllers.ts b/src/modules/wallets/wallet-badges.controllers.ts new file mode 100644 index 00000000..26bd7c6c --- /dev/null +++ b/src/modules/wallets/wallet-badges.controllers.ts @@ -0,0 +1,61 @@ +import { Request, Response, NextFunction } from 'express'; +import { z } from 'zod'; +import { StellarAddressSchema } from '../wallet/wallet.schemas'; +import { + getWalletBadgeCollection, + getWalletBadgeSummary, +} from '../badges/badge-read.service'; +import { + sendSuccess, + sendValidationError, +} from '../../utils/api-response.utils'; + +const WalletBadgesParamsSchema = z.object({ + wallet: StellarAddressSchema, +}); + +async function parseWallet( + req: Request, + res: Response +): Promise { + const parsed = WalletBadgesParamsSchema.safeParse(req.params); + if (parsed.success) return parsed.data.wallet; + + sendValidationError( + res, + 'Invalid wallet address', + parsed.error.issues.map(issue => ({ + field: 'wallet', + message: issue.message, + })) + ); + return null; +} + +export async function httpGetWalletBadges( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const wallet = await parseWallet(req, res); + if (!wallet) return; + sendSuccess(res, await getWalletBadgeCollection(wallet)); + } catch (error) { + next(error); + } +} + +export async function httpGetWalletBadgeSummary( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const wallet = await parseWallet(req, res); + if (!wallet) return; + sendSuccess(res, await getWalletBadgeSummary(wallet)); + } catch (error) { + next(error); + } +} diff --git a/src/modules/wallets/wallets.routes.ts b/src/modules/wallets/wallets.routes.ts index 4d620c77..dcbc608f 100644 --- a/src/modules/wallets/wallets.routes.ts +++ b/src/modules/wallets/wallets.routes.ts @@ -3,6 +3,10 @@ import { httpGetWalletActivity } from "./wallet-activity.controllers"; import { httpGetWalletHoldings } from "./wallet-holdings.controllers"; import { httpGetWalletFollowing } from "./wallet-following.controllers"; import { httpGetWalletFeed } from "./wallet-feed.controllers"; +import { + httpGetWalletBadges, + httpGetWalletBadgeSummary, +} from "./wallet-badges.controllers"; import { cacheControl } from "../../middlewares/cache-control.middleware"; import { ACTIVITY_FEED_CACHE_PRESET } from "../../constants/activity-feed-cache.constants"; import { requireWalletParamMatch } from "../../middlewares/jwt-auth.middleware"; @@ -10,6 +14,20 @@ import { jwtAuth } from "../../middlewares/jwt.middleware"; const walletsRouter = Router(); +/** + * GET /api/v1/wallets/:wallet/badges + * + * Returns all earned and locked badge types for a Stellar wallet. + */ +walletsRouter.get("/:wallet/badges", httpGetWalletBadges); + +/** + * GET /api/v1/wallets/:wallet/badges/summary + * + * Returns earned badge counts for every badge type. + */ +walletsRouter.get("/:wallet/badges/summary", httpGetWalletBadgeSummary); + /** * GET /api/v1/wallets/:address/activity *