From 302a181f47dd8530c395cb8efaebda7ed702f9cc Mon Sep 17 00:00:00 2001 From: Dee Date: Sun, 25 Oct 2026 12:46:16 +0100 Subject: [PATCH 1/4] Add campaign eligibility and leaderboard APIs --- src/modules/campaigns/campaigns.routes.ts | 49 +++++++++++++++++++++++ src/modules/index.ts | 2 + 2 files changed, 51 insertions(+) create mode 100644 src/modules/campaigns/campaigns.routes.ts diff --git a/src/modules/campaigns/campaigns.routes.ts b/src/modules/campaigns/campaigns.routes.ts new file mode 100644 index 00000000..d7f384d8 --- /dev/null +++ b/src/modules/campaigns/campaigns.routes.ts @@ -0,0 +1,49 @@ +import { Router } from 'express'; +import { z } from 'zod'; +import { prisma } from '../../utils/prisma.utils'; +import { sendError, sendSuccess, sendValidationError, zodIssuesToDetails } from '../../utils/api-response.utils'; +import { ErrorCode } from '../../constants/error.constants'; +import { cacheControl } from '../../middlewares/cache-control.middleware'; + +const router = Router(); +const limitSchema = z.coerce.number().int().min(1).max(100).default(25); + +router.get('/', cacheControl({ maxAge: 60, type: 'public' }), async (_req, res, next) => { + try { + const campaigns = await prisma.campaign.findMany({ + orderBy: { startDate: 'desc' }, + select: { id: true, name: true, description: true, status: true, criteriaType: true, threshold: true, criteria: true, startDate: true, endDate: true }, + }); + sendSuccess(res, { items: campaigns }); + } catch (error) { next(error); } +}); + +router.get('/:campaignId/eligibility/:wallet', cacheControl({ maxAge: 60, type: 'public' }), async (req, res, next) => { + try { + const campaign = await prisma.campaign.findUnique({ where: { id: req.params.campaignId }, select: { id: true, threshold: true, status: true } }); + if (!campaign) { sendError(res, 404, ErrorCode.NOT_FOUND, 'Campaign not found'); return; } + const qualification = await prisma.campaignQualificationLog.findUnique({ where: { campaignId_wallet: { campaignId: campaign.id, wallet: req.params.wallet } } }); + sendSuccess(res, { + campaignId: campaign.id, + wallet: req.params.wallet, + qualified: qualification?.status === 'QUALIFIED', + criteriaProgress: qualification?.metricValue ?? 0, + threshold: campaign.threshold, + claimableAmount: qualification?.status === 'QUALIFIED' ? qualification.metricValue ?? 0 : 0, + status: qualification?.status ?? 'PENDING', + }); + } catch (error) { next(error); } +}); + +router.get('/:campaignId/leaderboard', cacheControl({ maxAge: 300, type: 'public' }), async (req, res, next) => { + const parsed = z.object({ limit: limitSchema }).safeParse(req.query); + if (!parsed.success) { sendValidationError(res, 'Invalid campaign leaderboard query', zodIssuesToDetails(parsed.error.issues)); return; } + try { + const campaign = await prisma.campaign.findUnique({ where: { id: req.params.campaignId }, select: { id: true } }); + if (!campaign) { sendError(res, 404, ErrorCode.NOT_FOUND, 'Campaign not found'); return; } + const rows = await prisma.campaignQualificationLog.findMany({ where: { campaignId: campaign.id, status: 'QUALIFIED' }, orderBy: [{ metricValue: 'desc' }, { qualifiedAt: 'asc' }], take: parsed.data.limit, select: { wallet: true, metricValue: true, status: true } }); + sendSuccess(res, { items: rows.map((row, index) => ({ rank: index + 1, wallet: row.wallet, score: row.metricValue ?? 0, criteriaProgress: row.metricValue ?? 0, qualified: row.status === 'QUALIFIED' })) }); + } catch (error) { next(error); } +}); + +export default router; diff --git a/src/modules/index.ts b/src/modules/index.ts index ccd47126..a8d3efe3 100644 --- a/src/modules/index.ts +++ b/src/modules/index.ts @@ -53,6 +53,7 @@ import lpRouter from './lp/lp.routes'; import auctionRouter from './auctions/auction.routes'; import holdersRouter from './dividends/holders.routes'; import multisigRouter from './multisig/multisig.routes'; +import campaignsRouter from './campaigns/campaigns.routes'; import { BASE as CREATORS_BASE } from '../constants/creator.constants'; const router = Router(); @@ -133,5 +134,6 @@ router.use('/lp', routeBodySizeLimit('default'), lpRouter); router.use('/auctions', routeBodySizeLimit('default'), auctionRouter); router.use('/holders', routeBodySizeLimit('default'), holdersRouter); router.use('/multisig', routeBodySizeLimit('default'), multisigRouter); +router.use('/campaigns', routeBodySizeLimit('default'), campaignsRouter); export default router; From 08dc765e64d4640f20eb567f31448ba4def91eed Mon Sep 17 00:00:00 2001 From: Dee Date: Sun, 25 Oct 2026 12:46:23 +0100 Subject: [PATCH 2/4] Define campaign leaderboard cache contract --- src/modules/campaigns/campaign-leaderboard.cache.ts | 6 ++++++ src/modules/campaigns/campaigns.routes.ts | 3 ++- 2 files changed, 8 insertions(+), 1 deletion(-) create mode 100644 src/modules/campaigns/campaign-leaderboard.cache.ts diff --git a/src/modules/campaigns/campaign-leaderboard.cache.ts b/src/modules/campaigns/campaign-leaderboard.cache.ts new file mode 100644 index 00000000..3f78f71d --- /dev/null +++ b/src/modules/campaigns/campaign-leaderboard.cache.ts @@ -0,0 +1,6 @@ +export const CAMPAIGN_LEADERBOARD_CACHE_TTL_SECONDS = 300; + +/** Shared cache key used by reads and WalletQualified invalidation hooks. */ +export function campaignLeaderboardCacheKey(campaignId: string, limit: number): string { + return `campaign:${campaignId}:leaderboard:${limit}`; +} diff --git a/src/modules/campaigns/campaigns.routes.ts b/src/modules/campaigns/campaigns.routes.ts index d7f384d8..3d3b8979 100644 --- a/src/modules/campaigns/campaigns.routes.ts +++ b/src/modules/campaigns/campaigns.routes.ts @@ -3,6 +3,7 @@ import { z } from 'zod'; import { prisma } from '../../utils/prisma.utils'; import { sendError, sendSuccess, sendValidationError, zodIssuesToDetails } from '../../utils/api-response.utils'; import { ErrorCode } from '../../constants/error.constants'; +import { CAMPAIGN_LEADERBOARD_CACHE_TTL_SECONDS } from './campaign-leaderboard.cache'; import { cacheControl } from '../../middlewares/cache-control.middleware'; const router = Router(); @@ -35,7 +36,7 @@ router.get('/:campaignId/eligibility/:wallet', cacheControl({ maxAge: 60, type: } catch (error) { next(error); } }); -router.get('/:campaignId/leaderboard', cacheControl({ maxAge: 300, type: 'public' }), async (req, res, next) => { +router.get('/:campaignId/leaderboard', cacheControl({ maxAge: CAMPAIGN_LEADERBOARD_CACHE_TTL_SECONDS, type: 'public' }), async (req, res, next) => { const parsed = z.object({ limit: limitSchema }).safeParse(req.query); if (!parsed.success) { sendValidationError(res, 'Invalid campaign leaderboard query', zodIssuesToDetails(parsed.error.issues)); return; } try { From 86bea8588363b2d624eb55a7d2ef333e1a24f940 Mon Sep 17 00:00:00 2001 From: Dee Date: Sun, 25 Oct 2026 12:46:50 +0100 Subject: [PATCH 3/4] Add platform key analytics leaderboard API --- src/modules/analytics/analytics.routes.ts | 29 +++++++++++++++++++++++ src/modules/index.ts | 2 ++ 2 files changed, 31 insertions(+) create mode 100644 src/modules/analytics/analytics.routes.ts diff --git a/src/modules/analytics/analytics.routes.ts b/src/modules/analytics/analytics.routes.ts new file mode 100644 index 00000000..c981c717 --- /dev/null +++ b/src/modules/analytics/analytics.routes.ts @@ -0,0 +1,29 @@ +import { Router } from 'express'; +import { z } from 'zod'; +import { cacheControl } from '../../middlewares/cache-control.middleware'; +import { sendError, sendSuccess, sendValidationError, zodIssuesToDetails } from '../../utils/api-response.utils'; +import { prisma } from '../../utils/prisma.utils'; +import { getKeyLeaderboard } from '../keys/key-leaderboard.service'; + +const router = Router(); +const querySchema = z.object({ metric: z.enum(['volume', 'holders', 'reputation']).default('volume'), limit: z.coerce.number().int().min(1).max(100).default(25) }); + +router.get('/keys/leaderboard', cacheControl({ maxAge: 300, type: 'public' }), async (req, res, next) => { + const parsed = querySchema.safeParse(req.query); + if (!parsed.success) { sendValidationError(res, 'Invalid leaderboard query', zodIssuesToDetails(parsed.error.issues)); return; } + try { + const { metric, limit } = parsed.data; + const sortBy = metric === 'holders' ? 'holder_count' : 'volume_24h'; + const base = await getKeyLeaderboard(sortBy, 100); + let ranked = base; + let reputationScores = new Map(); + if (metric === 'reputation') { + const reputation = await prisma.creatorReputation.findMany({ where: { creatorId: { in: base.map(item => item.keyId) } }, select: { creatorId: true, currentScore: true } }); + reputationScores = new Map(reputation.map(row => [row.creatorId, row.currentScore])); + ranked = [...base].sort((a, b) => (reputationScores.get(b.keyId) ?? 0) - (reputationScores.get(a.keyId) ?? 0)); + } + sendSuccess(res, { items: ranked.slice(0, limit).map((entry, index) => ({ rank: index + 1, key_id: entry.keyId, key_name: entry.handle, creator: entry.creatorName, metric_value: metric === 'holders' ? entry.holder_count : metric === 'reputation' ? reputationScores.get(entry.keyId) ?? 0 : entry.volume_24h, '24h_change': entry.price_change })) }); + } catch (error) { next(error); } +}); + +export default router; diff --git a/src/modules/index.ts b/src/modules/index.ts index a8d3efe3..08a74846 100644 --- a/src/modules/index.ts +++ b/src/modules/index.ts @@ -54,6 +54,7 @@ import auctionRouter from './auctions/auction.routes'; import holdersRouter from './dividends/holders.routes'; import multisigRouter from './multisig/multisig.routes'; import campaignsRouter from './campaigns/campaigns.routes'; +import analyticsRouter from './analytics/analytics.routes'; import { BASE as CREATORS_BASE } from '../constants/creator.constants'; const router = Router(); @@ -135,5 +136,6 @@ router.use('/auctions', routeBodySizeLimit('default'), auctionRouter); router.use('/holders', routeBodySizeLimit('default'), holdersRouter); router.use('/multisig', routeBodySizeLimit('default'), multisigRouter); router.use('/campaigns', routeBodySizeLimit('default'), campaignsRouter); +router.use('/analytics', routeBodySizeLimit('default'), analyticsRouter); export default router; From 814a6d4a517a312873409518fc9715dfd2d385a3 Mon Sep 17 00:00:00 2001 From: Dee Date: Sun, 25 Oct 2026 12:47:15 +0100 Subject: [PATCH 4/4] Add invoice secondary price history APIs --- prisma/schema/invoice.prisma | 15 +++++++++++++++ .../migration.sql | 11 +++++++++++ src/modules/invoice/invoice-market.routes.ts | 18 ++++++++++++++++++ 3 files changed, 44 insertions(+) create mode 100644 prisma/schema/migrations/20261025000000_add_invoice_secondary_trade_prices/migration.sql diff --git a/prisma/schema/invoice.prisma b/prisma/schema/invoice.prisma index 3a20aa08..0ac89e4f 100644 --- a/prisma/schema/invoice.prisma +++ b/prisma/schema/invoice.prisma @@ -15,7 +15,22 @@ model Invoice { updatedAt DateTime @updatedAt watchlists InvestorWatchlist[] + secondaryTrades InvoiceSecondaryTradePrice[] @@index([sellerWallet]) @@index([status]) } + +model InvoiceSecondaryTradePrice { + id String @id @default(cuid()) + invoiceId String + price Decimal + amount Decimal + tradedAt DateTime @default(now()) + txHash String? + + invoice Invoice @relation(fields: [invoiceId], references: [id], onDelete: Cascade) + + @@index([invoiceId, tradedAt(sort: Desc)]) + @@map("invoice_secondary_trade_prices") +} diff --git a/prisma/schema/migrations/20261025000000_add_invoice_secondary_trade_prices/migration.sql b/prisma/schema/migrations/20261025000000_add_invoice_secondary_trade_prices/migration.sql new file mode 100644 index 00000000..3caf7b53 --- /dev/null +++ b/prisma/schema/migrations/20261025000000_add_invoice_secondary_trade_prices/migration.sql @@ -0,0 +1,11 @@ +CREATE TABLE "invoice_secondary_trade_prices" ( + "id" TEXT NOT NULL, + "invoiceId" TEXT NOT NULL, + "price" DECIMAL(65,30) NOT NULL, + "amount" DECIMAL(65,30) NOT NULL, + "tradedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "txHash" TEXT, + CONSTRAINT "invoice_secondary_trade_prices_pkey" PRIMARY KEY ("id") +); +CREATE INDEX "invoice_secondary_trade_prices_invoiceId_tradedAt_idx" ON "invoice_secondary_trade_prices"("invoiceId", "tradedAt" DESC); +ALTER TABLE "invoice_secondary_trade_prices" ADD CONSTRAINT "invoice_secondary_trade_prices_invoiceId_fkey" FOREIGN KEY ("invoiceId") REFERENCES "Invoice"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/src/modules/invoice/invoice-market.routes.ts b/src/modules/invoice/invoice-market.routes.ts index 29bec75c..45ed4428 100644 --- a/src/modules/invoice/invoice-market.routes.ts +++ b/src/modules/invoice/invoice-market.routes.ts @@ -5,6 +5,8 @@ import { requireJwtAuth, } from '../../middlewares/jwt-auth.middleware'; import { sendSuccess } from '../../utils/api-response.utils'; +import { prisma } from '../../utils/prisma.utils'; +import { cacheControl } from '../../middlewares/cache-control.middleware'; import { protocolHandler } from './invoice-protocol.routes'; import { buyInvoice, @@ -16,6 +18,22 @@ const body = z .object({ transaction_xdr: z.string().min(1).max(100000) }) .strict(); export const invoiceMarketRouter = Router(); +const priceLimit = z.coerce.number().int().min(1).max(100).default(50); + +invoiceMarketRouter.get('/:invoice_id/secondary-prices', cacheControl({ maxAge: 30, type: 'public' }), async (req, res, next) => { + try { + const limit = priceLimit.parse(req.query.limit); + const items = await prisma.invoiceSecondaryTradePrice.findMany({ where: { invoiceId: String(req.params.invoice_id) }, orderBy: { tradedAt: 'desc' }, take: limit, select: { price: true, amount: true, tradedAt: true, txHash: true } }); + sendSuccess(res, { items }); + } catch (error) { next(error); } +}); + +invoiceMarketRouter.get('/:invoice_id/secondary-prices/last', cacheControl({ maxAge: 30, type: 'public' }), async (req, res, next) => { + try { + const item = await prisma.invoiceSecondaryTradePrice.findFirst({ where: { invoiceId: String(req.params.invoice_id) }, orderBy: { tradedAt: 'desc' }, select: { price: true, amount: true, tradedAt: true, txHash: true } }); + sendSuccess(res, { item }); + } catch (error) { next(error); } +}); invoiceMarketRouter.post( '/:invoice_id/buy', requireJwtAuth,