Skip to content

Commit 7c38df5

Browse files
authored
Merge pull request #378 from Jessepriase/feat/indexer-rest-api
feat: implement internal REST API in indexer
2 parents cbb8c79 + e5f4351 commit 7c38df5

6 files changed

Lines changed: 428 additions & 0 deletions

File tree

indexer/src/api/api-key.guard.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import {
2+
CanActivate,
3+
ExecutionContext,
4+
Injectable,
5+
UnauthorizedException,
6+
} from "@nestjs/common";
7+
import { ConfigService } from "@nestjs/config";
8+
import { Request } from "express";
9+
10+
/**
11+
* Optional internal-only API key guard.
12+
* Activated when the INTERNAL_API_KEY env var is set.
13+
* Clients must send: x-api-key: <INTERNAL_API_KEY>
14+
* When the env var is absent the guard is a no-op (open access).
15+
*/
16+
@Injectable()
17+
export class ApiKeyGuard implements CanActivate {
18+
private readonly apiKey: string | undefined;
19+
20+
constructor(private readonly configService: ConfigService) {
21+
this.apiKey = this.configService.get<string>("INTERNAL_API_KEY");
22+
}
23+
24+
canActivate(context: ExecutionContext): boolean {
25+
if (!this.apiKey) return true; // guard disabled — no key configured
26+
27+
const request = context.switchToHttp().getRequest<Request>();
28+
const provided = request.headers["x-api-key"];
29+
30+
if (provided !== this.apiKey) {
31+
throw new UnauthorizedException("Invalid or missing API key");
32+
}
33+
34+
return true;
35+
}
36+
}

indexer/src/api/api.module.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { Module } from "@nestjs/common";
2+
import { TypeOrmModule } from "@nestjs/typeorm";
3+
import { RafflesController } from "./controllers/raffles.controller";
4+
import { UsersController } from "./controllers/users.controller";
5+
import { StatsController } from "./controllers/stats.controller";
6+
import { ApiKeyGuard } from "./api-key.guard";
7+
import { RaffleEntity } from "../database/entities/raffle.entity";
8+
import { TicketEntity } from "../database/entities/ticket.entity";
9+
import { UserEntity } from "../database/entities/user.entity";
10+
import { PlatformStatEntity } from "../database/entities/platform-stat.entity";
11+
import { CacheModule } from "../cache/cache.module";
12+
13+
@Module({
14+
imports: [
15+
TypeOrmModule.forFeature([
16+
RaffleEntity,
17+
TicketEntity,
18+
UserEntity,
19+
PlatformStatEntity,
20+
]),
21+
CacheModule,
22+
],
23+
controllers: [RafflesController, UsersController, StatsController],
24+
providers: [ApiKeyGuard],
25+
})
26+
export class ApiModule {}
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import {
2+
Controller,
3+
Get,
4+
Param,
5+
Query,
6+
NotFoundException,
7+
ParseIntPipe,
8+
UseGuards,
9+
} from "@nestjs/common";
10+
import { InjectRepository } from "@nestjs/typeorm";
11+
import { Repository } from "typeorm";
12+
import { CacheService } from "../../cache/cache.service";
13+
import { RaffleEntity } from "../../database/entities/raffle.entity";
14+
import { TicketEntity } from "../../database/entities/ticket.entity";
15+
import { ApiKeyGuard } from "../api-key.guard";
16+
17+
export interface RaffleListQuery {
18+
status?: string;
19+
creator?: string;
20+
asset?: string;
21+
category?: string;
22+
limit?: string;
23+
offset?: string;
24+
}
25+
26+
@UseGuards(ApiKeyGuard)
27+
@Controller("raffles")
28+
export class RafflesController {
29+
constructor(
30+
@InjectRepository(RaffleEntity)
31+
private readonly raffleRepo: Repository<RaffleEntity>,
32+
@InjectRepository(TicketEntity)
33+
private readonly ticketRepo: Repository<TicketEntity>,
34+
private readonly cacheService: CacheService,
35+
) {}
36+
37+
/**
38+
* GET /raffles
39+
* List raffles with optional filters and pagination.
40+
* Uses cache for the active-raffle list; falls back to PostgreSQL.
41+
*/
42+
@Get()
43+
async list(@Query() query: RaffleListQuery) {
44+
const limit = Math.min(parseInt(query.limit ?? "20", 10), 100);
45+
const offset = parseInt(query.offset ?? "0", 10);
46+
47+
// Serve from cache when querying active raffles with no other filters
48+
const isActiveOnlyQuery =
49+
(!query.status || query.status === "open") &&
50+
!query.creator &&
51+
!query.asset &&
52+
!query.category &&
53+
offset === 0 &&
54+
limit === 20;
55+
56+
if (isActiveOnlyQuery) {
57+
const cached = await this.cacheService.getActiveRaffles();
58+
if (cached) return cached;
59+
}
60+
61+
const qb = this.raffleRepo
62+
.createQueryBuilder("r")
63+
.orderBy("r.createdAt", "DESC")
64+
.limit(limit)
65+
.offset(offset);
66+
67+
if (query.status) qb.andWhere("r.status = :status", { status: query.status });
68+
if (query.creator) qb.andWhere("r.creator = :creator", { creator: query.creator });
69+
if (query.asset) qb.andWhere("r.asset = :asset", { asset: query.asset });
70+
71+
const [items, total] = await qb.getManyAndCount();
72+
73+
const result = {
74+
data: items.map(this.formatRaffle),
75+
total,
76+
limit,
77+
offset,
78+
};
79+
80+
if (isActiveOnlyQuery) {
81+
await this.cacheService.setActiveRaffles(result);
82+
}
83+
84+
return result;
85+
}
86+
87+
/**
88+
* GET /raffles/:id
89+
* Raffle detail — cache-first, PostgreSQL fallback.
90+
*/
91+
@Get(":id")
92+
async detail(@Param("id", ParseIntPipe) id: number) {
93+
const cached = await this.cacheService.getRaffleDetail(String(id));
94+
if (cached) return cached;
95+
96+
const raffle = await this.raffleRepo.findOne({ where: { id } });
97+
if (!raffle) throw new NotFoundException(`Raffle ${id} not found`);
98+
99+
const ticketCount = await this.ticketRepo.count({ where: { raffleId: id } });
100+
101+
const result = {
102+
...this.formatRaffle(raffle),
103+
participant_count: ticketCount,
104+
};
105+
106+
await this.cacheService.setRaffleDetail(String(id), result);
107+
return result;
108+
}
109+
110+
private formatRaffle(r: RaffleEntity) {
111+
return {
112+
id: r.id,
113+
creator: r.creator,
114+
status: r.status,
115+
ticket_price: r.ticketPrice,
116+
asset: r.asset,
117+
max_tickets: r.maxTickets,
118+
tickets_sold: r.ticketsSold,
119+
end_time: r.endTime,
120+
winner: r.winner,
121+
prize_amount: r.prizeAmount,
122+
created_ledger: r.createdLedger,
123+
finalized_ledger: r.finalizedLedger,
124+
metadata_cid: r.metadataCid,
125+
created_at: r.createdAt,
126+
};
127+
}
128+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { Controller, Get, UseGuards } from "@nestjs/common";
2+
import { InjectRepository } from "@nestjs/typeorm";
3+
import { Repository } from "typeorm";
4+
import { CacheService } from "../../cache/cache.service";
5+
import { PlatformStatEntity } from "../../database/entities/platform-stat.entity";
6+
import { RaffleEntity, RaffleStatus } from "../../database/entities/raffle.entity";
7+
import { UserEntity } from "../../database/entities/user.entity";
8+
import { ApiKeyGuard } from "../api-key.guard";
9+
10+
@UseGuards(ApiKeyGuard)
11+
@Controller("stats")
12+
export class StatsController {
13+
constructor(
14+
@InjectRepository(PlatformStatEntity)
15+
private readonly statRepo: Repository<PlatformStatEntity>,
16+
@InjectRepository(RaffleEntity)
17+
private readonly raffleRepo: Repository<RaffleEntity>,
18+
@InjectRepository(UserEntity)
19+
private readonly userRepo: Repository<UserEntity>,
20+
private readonly cacheService: CacheService,
21+
) {}
22+
23+
/**
24+
* GET /stats/platform
25+
* Aggregated platform-wide stats — cache-first (5 min TTL), PostgreSQL fallback.
26+
* Merges the latest daily roll-up row with live counts for active raffles.
27+
*/
28+
@Get("platform")
29+
async platform() {
30+
const cached = await this.cacheService.getPlatformStats();
31+
if (cached) return cached;
32+
33+
// Latest daily roll-up
34+
const latest = await this.statRepo
35+
.createQueryBuilder("s")
36+
.orderBy("s.date", "DESC")
37+
.getOne();
38+
39+
// Live counts that change frequently
40+
const [activeRaffles, totalUsers] = await Promise.all([
41+
this.raffleRepo.count({ where: { status: RaffleStatus.OPEN } }),
42+
this.userRepo.count(),
43+
]);
44+
45+
const result = {
46+
date: latest?.date ?? null,
47+
total_raffles: latest?.totalRaffles ?? 0,
48+
total_tickets: latest?.totalTickets ?? 0,
49+
total_volume_xlm: latest?.totalVolumeXlm ?? "0",
50+
unique_participants: latest?.uniqueParticipants ?? 0,
51+
prizes_distributed_xlm: latest?.prizesDistributedXlm ?? "0",
52+
active_raffles: activeRaffles,
53+
total_users: totalUsers,
54+
};
55+
56+
await this.cacheService.setPlatformStats(result);
57+
return result;
58+
}
59+
}

0 commit comments

Comments
 (0)