| name | add-endpoint |
| description | Scaffold a new Express API endpoint with controller, route (OpenAPI annotations), response type, and barrel exports. |
Add API Endpoint
Scaffold a complete Express.js API endpoint following the project's established patterns: controller, route with @openapi annotations, response type, and barrel exports.
Arguments
$0 - Domain/feature area (e.g., drep, overview, proposal, or a new domain like spo)
$1 - HTTP method: get or post (default: get)
$2 - URL path suffix (e.g., stats, :id/votes, trigger-cleanup)
$3 - Short description of the endpoint (e.g., "Get aggregate SPO statistics")
Instructions
Step 1: Check if domain exists
Look for existing files:
src/controllers/{$0}/index.ts
src/routes/{$0}.route.ts
If the domain is new, you'll also need Steps 6 and 7. If it exists, skip those steps.
Step 2: Create the controller
Create src/controllers/{$0}/{handlerName}.ts:
import { Request, Response } from "express";
import { prisma } from "../../services";
export const {handlerName} = async (req: Request, res: Response) => {
try {
const data = {};
res.json(data);
} catch (error) {
console.error("Error in {handlerName}", error);
res.status(500).json({
error: "Failed to {$3 lowercase}",
message: error instanceof Error ? error.message : "Unknown error",
});
}
};
Handler naming conventions:
- GET endpoints:
get{Resource} (e.g., getSPOStats, getDRepVotes)
- POST endpoints:
post{Action} (e.g., postTriggerSync, postIngestProposal)
For paginated endpoints, add this query param parsing at the top:
const page = Math.max(1, parseInt(req.query.page as string) || 1);
const pageSize = Math.min(100, Math.max(1, parseInt(req.query.pageSize as string) || 20));
const skip = (page - 1) * pageSize;
For BigInt fields, always convert before JSON response:
const votingPowerStr = drep.votingPower.toString();
function lovelaceToAda(lovelace: bigint): string {
return (Number(lovelace) / 1_000_000).toFixed(6);
}
Step 3: Export from controller barrel
Add to src/controllers/{$0}/index.ts:
export * from "./{handlerFileName}";
Step 4: Add route with OpenAPI annotation
Add to src/routes/{$0}.route.ts:
router.{$1}("/{$2}", {$0}Controller.{handlerName});
Step 5: Define response type
Add to src/responses/{$0}.response.ts (create if new domain):
export interface {ResponseTypeName} {
}
Then export from src/responses/index.ts:
export * from "./{$0}.response";
Step 6: (New domain only) Create route file
Create src/routes/{$0}.route.ts:
import express from "express";
import { {$0}Controller } from "../controllers";
const router = express.Router();
export default router;
Step 7: (New domain only) Mount in app
Add to src/index.ts:
import {$0}Router from "./routes/{$0}.route";
app.use("/{$0}", apiKeyAuth, {$0}Router);
And add the controller barrel export to src/controllers/index.ts:
export * as {$0}Controller from "./{$0}";
Checklist
Common Patterns
Vote/relation count aggregation
When an endpoint needs counts from a related model (e.g., vote count per DRep), Prisma doesn't support filtered _count in findMany. Use groupBy + in-memory join:
const dreps = await prisma.drep.findMany({ ... });
const drepIds = dreps.map((d) => d.drepId);
const voteCounts = await prisma.onchainVote.groupBy({
by: ["drepId"],
where: { drepId: { in: drepIds }, voterType: VoterType.DREP },
_count: { id: true },
});
const voteCountMap = new Map<string, number>();
for (const vc of voteCounts) {
if (vc.drepId) voteCountMap.set(vc.drepId, vc._count.id);
}
const summaries = dreps.map((d) => ({
...d,
totalVotesCast: voteCountMap.get(d.drepId) || 0,
}));
doNotList filtering
DReps with doNotList: true should be excluded. Since the field is nullable, always use:
const whereClause = {
OR: [{ doNotList: false }, { doNotList: null }],
};
In-memory sorting for computed fields
When sorting by a field not in the DB (e.g., totalVotes):
if (sortBy === "totalVotes") {
results.sort((a, b) => {
const diff = a.totalVotesCast - b.totalVotesCast;
return sortOrder === "asc" ? diff : -diff;
});
}
Note: pagination still works correctly for DB-column sorts (votingPower, name) but for computed-field sorts the page boundary may shift.
Aggregate stats with _sum
const aggregateResult = await prisma.drep.aggregate({
where: { OR: [{ doNotList: false }, { doNotList: null }] },
_sum: { votingPower: true, delegatorCount: true },
});
const total = aggregateResult._sum.votingPower ?? BigInt(0);
BigInt percentage calculations
When computing percentages with BigInt (e.g., vote power ratios), use scaled arithmetic to preserve precision:
const turnoutPct = totalPower > 0n
? Number((activePower * 10000n) / totalPower) / 100
: null;
Latest vote per voter (deduplication)
For voters who can change their vote (e.g., CC members), always order by timestamp and dedupe:
const votes = await prisma.onchainVote.findMany({
where: { voterType: VoterType.CC },
orderBy: [{ votedAt: "desc" }, { createdAt: "desc" }],
});
const seenVotes = new Set<string>();
for (const vote of votes) {
const key = `${vote.ccId}-${vote.proposalId}`;
if (!seenVotes.has(key)) {
seenVotes.add(key);
}
}
Epoch time mapping
For wall-clock calculations from epoch numbers, build a lookup map:
const epochTimestamps = await prisma.epochTotals.findMany({
where: { epoch: { in: Array.from(epochs) } },
select: { epoch: true, startTime: true, endTime: true },
});
const epochTimeMap = new Map<number, Date>();
for (const et of epochTimestamps) {
if (et.startTime && et.endTime) {
const midpoint = new Date((et.startTime.getTime() + et.endTime.getTime()) / 2);
epochTimeMap.set(et.epoch, midpoint);
}
}
Analytics metrics
Gini coefficient for decentralization (0 = equal, 1 = concentrated):
const sorted = [...values].sort((a, b) => a < b ? -1 : a > b ? 1 : 0);
let sum = 0n, weightedSum = 0n;
for (let i = 0; i < sorted.length; i++) {
sum += sorted[i];
weightedSum += BigInt(i + 1) * sorted[i];
}
const gini = Number((2n * weightedSum - BigInt(n + 1) * sum) * 10000n / (BigInt(n) * sum)) / 10000;
HHI (Herfindahl-Hirschman Index) for concentration (0-10000):
let hhi = 0;
for (const [, data] of groupPower) {
const sharePct = Number((data.power * 10000n) / totalPower) / 100;
hhi += sharePct * sharePct;
}
Contention score (0-100, higher = more contentious):
const diff = Math.abs(yesPct - noPct);
const contentionScore = 100 - diff;
const isContentious = diff < 20;
After Creation
- Run
npm run build to verify TypeScript compiles
- Run
npm run swagger:generate to update API docs
- Test with
curl http://localhost:3000/{$0}/{$2}