Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions prisma/schema/invoice.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Original file line number Diff line number Diff line change
@@ -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;
29 changes: 29 additions & 0 deletions src/modules/analytics/analytics.routes.ts
Original file line number Diff line number Diff line change
@@ -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';

Check failure on line 4 in src/modules/analytics/analytics.routes.ts

View workflow job for this annotation

GitHub Actions / verify

'sendError' is defined but never used. Allowed unused vars must match /^_/u
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<string, number>();
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;
6 changes: 6 additions & 0 deletions src/modules/campaigns/campaign-leaderboard.cache.ts
Original file line number Diff line number Diff line change
@@ -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}`;
}
50 changes: 50 additions & 0 deletions src/modules/campaigns/campaigns.routes.ts
Original file line number Diff line number Diff line change
@@ -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;
4 changes: 4 additions & 0 deletions src/modules/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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;
18 changes: 18 additions & 0 deletions src/modules/invoice/invoice-market.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
Loading