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/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/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 new file mode 100644 index 00000000..3d3b8979 --- /dev/null +++ b/src/modules/campaigns/campaigns.routes.ts @@ -0,0 +1,50 @@ +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 { CAMPAIGN_LEADERBOARD_CACHE_TTL_SECONDS } from './campaign-leaderboard.cache'; +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: 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 { + 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..08a74846 100644 --- a/src/modules/index.ts +++ b/src/modules/index.ts @@ -53,6 +53,8 @@ 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 analyticsRouter from './analytics/analytics.routes'; import { BASE as CREATORS_BASE } from '../constants/creator.constants'; const router = Router(); @@ -133,5 +135,7 @@ 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); +router.use('/analytics', routeBodySizeLimit('default'), analyticsRouter); export default router; 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,