Skip to content

Commit fdd1257

Browse files
authored
Merge branch 'master' into feat/oracle-health-monitoring
2 parents 2b4e8be + 076dcec commit fdd1257

21 files changed

Lines changed: 826 additions & 148 deletions

backend/README.md

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,93 @@ done
116116

117117
---
118118

119+
## Health Check
120+
121+
### GET /health
122+
123+
Returns the live status of all backend dependencies. No authentication required.
124+
125+
```bash
126+
curl http://localhost:3001/health
127+
```
128+
129+
**Response — all healthy (HTTP 200):**
130+
131+
```json
132+
{
133+
"status": "ok",
134+
"indexer": "ok",
135+
"supabase": "ok",
136+
"timestamp": "2026-04-23T11:00:00.000Z"
137+
}
138+
```
139+
140+
**Response — dependency down (HTTP 503):**
141+
142+
```json
143+
{
144+
"status": "degraded",
145+
"indexer": "error",
146+
"supabase": "ok",
147+
"timestamp": "2026-04-23T11:00:00.000Z"
148+
}
149+
```
150+
151+
| Field | Values | Description |
152+
| ----------- | ------------------- | ------------------------------------------------ |
153+
| `status` | `ok` / `degraded` | Overall health — `degraded` if any check fails |
154+
| `indexer` | `ok` / `error` | Reachability of tikka-indexer `/health` |
155+
| `supabase` | `ok` / `error` | Reachability of Supabase REST endpoint |
156+
| `timestamp` | ISO 8601 string | Time the check was performed |
157+
158+
The endpoint returns **HTTP 503** when `status` is `degraded`, so orchestrators (Kubernetes, Railway, Fly.io) can detect unhealthy instances automatically.
159+
160+
---
161+
162+
## Environment Variables
163+
164+
Copy `.env.example` to `.env` and fill in the required values before starting the server.
165+
166+
```bash
167+
cp .env.example .env
168+
```
169+
170+
The app validates all variables at startup using Zod. Missing or invalid required vars cause an immediate startup failure with a clear error message listing every invalid field.
171+
172+
### Required
173+
174+
These must be set or the app will refuse to start:
175+
176+
| Variable | Description |
177+
| -------------------------- | ------------------------------------------------------------ |
178+
| `SUPABASE_URL` | Full URL of your Supabase project (e.g. `https://xyz.supabase.co`) |
179+
| `SUPABASE_SERVICE_ROLE_KEY`| Supabase service role key (not the anon key) |
180+
| `JWT_SECRET` | Secret for signing JWTs — **minimum 32 characters** |
181+
| `VITE_FRONTEND_URL` | Frontend origin allowed by CORS (e.g. `https://app.tikka.io`) |
182+
| `ADMIN_TOKEN` | Bearer token for `/admin/*` endpoints |
183+
184+
### Optional (with defaults)
185+
186+
| Variable | Default | Description |
187+
| -------------------------- | -------------------------- | ------------------------------------------------ |
188+
| `PORT` | `3001` | HTTP port the server listens on |
189+
| `INDEXER_URL` | `http://localhost:3002` | Base URL of the tikka-indexer internal API |
190+
| `INDEXER_TIMEOUT_MS` | `5000` | HTTP timeout for indexer requests (ms) |
191+
| `JWT_EXPIRES_IN` | `7d` | JWT expiry duration (e.g. `1h`, `7d`) |
192+
| `SIWS_DOMAIN` | `tikka.io` | Domain shown in the SIWS sign-in message |
193+
| `ADMIN_IP_ALLOWLIST` | `""` (allow all) | Comma-separated CIDRs/IPs for admin access |
194+
| `FCM_ENABLED` | `false` | Enable Firebase Cloud Messaging push notifications |
195+
| `FCM_SERVICE_ACCOUNT_JSON` || FCM service account JSON string (for CI/secrets) |
196+
| `FCM_SERVICE_ACCOUNT_PATH` || Path to FCM service account JSON file |
197+
| `THROTTLE_DEFAULT_LIMIT` | `100` | Max requests per window for public endpoints |
198+
| `THROTTLE_DEFAULT_TTL` | `60` | Rate-limit window size in seconds |
199+
| `THROTTLE_AUTH_LIMIT` | `10` | Max requests per window for `POST /auth/verify` |
200+
| `THROTTLE_AUTH_TTL` | `60` | Rate-limit window for auth tier (seconds) |
201+
| `THROTTLE_NONCE_LIMIT` | `30` | Max requests per window for `GET /auth/nonce` |
202+
| `THROTTLE_NONCE_TTL` | `60` | Rate-limit window for nonce tier (seconds) |
203+
204+
---
205+
119206
## Structure
120207

121208
- `src/api/rest/` - raffles, users, leaderboard, stats, search, notifications

backend/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,20 +13,23 @@
1313
"test:e2e": "jest --config ./test/jest-e2e.json"
1414
},
1515
"dependencies": {
16+
"@fastify/helmet": "^13.0.2",
1617
"@fastify/multipart": "^9.0.0",
1718
"@nestjs/common": "^10.4.0",
1819
"@nestjs/config": "^4.0.3",
1920
"@nestjs/core": "^10.4.0",
2021
"@nestjs/jwt": "^10.2.0",
2122
"@nestjs/passport": "^10.0.3",
2223
"@nestjs/platform-fastify": "^11.1.17",
24+
"@nestjs/swagger": "^11.4.1",
2325
"@nestjs/throttler": "^6.4.0",
2426
"@nestjs/typeorm": "^11.0.0",
2527
"@stellar/stellar-sdk": "^14.4.0",
2628
"@supabase/supabase-js": "^2.45.0",
2729
"class-transformer": "^0.5.1",
2830
"class-validator": "^0.15.1",
2931
"fastify": "^5.0.0",
32+
"firebase-admin": "^13.8.0",
3033
"passport": "^0.7.0",
3134
"passport-jwt": "^4.0.1",
3235
"pg": "^8.18.0",

backend/src/api/rest/raffles/pipes/zod-validation.pipe.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import {
33
ArgumentMetadata,
44
BadRequestException,
55
} from "@nestjs/common";
6-
import { ZodSchema, ZodError } from "zod";
6+
import { ZodSchema, ZodTypeDef, ZodError } from "zod";
77

88
/**
99
* Creates a validation pipe using a Zod schema.
@@ -27,9 +27,9 @@ import { ZodSchema, ZodError } from "zod";
2727
* @returns PipeTransform class that validates and transforms data
2828
* @throws BadRequestException when validation fails
2929
*/
30-
export function createZodPipe<T>(schema: ZodSchema<T>) {
30+
export function createZodPipe<Output, Input = Output>(schema: ZodSchema<Output, ZodTypeDef, Input>) {
3131
return class implements PipeTransform {
32-
transform(value: unknown, _metadata: ArgumentMetadata): T {
32+
transform(value: unknown, _metadata: ArgumentMetadata): Output {
3333
const result = schema.safeParse(value);
3434
if (!result.success) {
3535
const msg = result.error.errors.map((e) => e.message).join("; ");
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import { NotFoundException } from '@nestjs/common';
2+
import { RafflesService } from './raffles.service';
3+
import { IndexerService, IndexerRaffleData } from '../../../services/indexer.service';
4+
import { MetadataService, RaffleMetadata } from '../../../services/metadata.service';
5+
6+
const mockRaffle: IndexerRaffleData = {
7+
id: 1,
8+
creator: 'GABC123',
9+
status: 'open',
10+
ticket_price: '10',
11+
asset: 'XLM',
12+
max_tickets: 100,
13+
tickets_sold: 5,
14+
end_time: '2026-12-31T00:00:00Z',
15+
winner: null,
16+
prize_amount: null,
17+
created_ledger: 1000,
18+
finalized_ledger: null,
19+
metadata_cid: null,
20+
created_at: '2026-01-01T00:00:00Z',
21+
};
22+
23+
const mockMetadata: RaffleMetadata = {
24+
raffle_id: 1,
25+
title: 'Test Raffle',
26+
description: 'A test raffle',
27+
image_url: 'https://example.com/img.png',
28+
category: 'art',
29+
metadata_cid: 'ipfs://abc',
30+
created_at: '2026-01-01T00:00:00Z',
31+
updated_at: '2026-01-01T00:00:00Z',
32+
};
33+
34+
describe('RafflesService', () => {
35+
let service: RafflesService;
36+
let indexerService: jest.Mocked<Pick<IndexerService, 'listRaffles' | 'getRaffle'>>;
37+
let metadataService: jest.Mocked<Pick<MetadataService, 'getMetadata' | 'getBatchMetadata' | 'upsertMetadata'>>;
38+
39+
beforeEach(() => {
40+
indexerService = {
41+
listRaffles: jest.fn().mockResolvedValue({ raffles: [], total: 0 }),
42+
getRaffle: jest.fn().mockResolvedValue(null),
43+
};
44+
metadataService = {
45+
getMetadata: jest.fn().mockResolvedValue(null),
46+
getBatchMetadata: jest.fn().mockResolvedValue(new Map()),
47+
upsertMetadata: jest.fn(),
48+
};
49+
50+
service = new RafflesService(
51+
metadataService as unknown as MetadataService,
52+
indexerService as unknown as IndexerService,
53+
);
54+
});
55+
56+
describe('list', () => {
57+
it('delegates to indexerService.listRaffles with filters', async () => {
58+
const filters = { status: 'open', limit: 10, offset: 0 };
59+
indexerService.listRaffles.mockResolvedValue({ raffles: [mockRaffle], total: 1 });
60+
61+
const result = await service.list(filters);
62+
63+
expect(indexerService.listRaffles).toHaveBeenCalledWith(filters);
64+
expect(result).toEqual({ raffles: [mockRaffle], total: 1 });
65+
});
66+
67+
it('calls listRaffles with empty filters by default', async () => {
68+
await service.list();
69+
70+
expect(indexerService.listRaffles).toHaveBeenCalledWith({});
71+
});
72+
});
73+
74+
describe('getById', () => {
75+
it('merges indexer data and metadata into a single response', async () => {
76+
indexerService.getRaffle.mockResolvedValue(mockRaffle);
77+
metadataService.getMetadata.mockResolvedValue(mockMetadata);
78+
79+
const result = await service.getById(1);
80+
81+
expect(result).toMatchObject({
82+
id: 1,
83+
creator: 'GABC123',
84+
status: 'open',
85+
title: 'Test Raffle',
86+
description: 'A test raffle',
87+
image_url: 'https://example.com/img.png',
88+
category: 'art',
89+
metadata_cid: 'ipfs://abc',
90+
});
91+
});
92+
93+
it('returns indexer data when metadata is absent', async () => {
94+
indexerService.getRaffle.mockResolvedValue(mockRaffle);
95+
metadataService.getMetadata.mockResolvedValue(null);
96+
97+
const result = await service.getById(1);
98+
99+
expect(result.id).toBe(1);
100+
expect(result.creator).toBe('GABC123');
101+
expect(result.title).toBeUndefined();
102+
});
103+
104+
it('returns metadata when indexer data is absent', async () => {
105+
indexerService.getRaffle.mockResolvedValue(null);
106+
metadataService.getMetadata.mockResolvedValue(mockMetadata);
107+
108+
const result = await service.getById(1);
109+
110+
expect(result.id).toBe(1);
111+
expect(result.title).toBe('Test Raffle');
112+
expect(result.creator).toBeUndefined();
113+
});
114+
115+
it('throws NotFoundException when both indexer and metadata return null', async () => {
116+
indexerService.getRaffle.mockResolvedValue(null);
117+
metadataService.getMetadata.mockResolvedValue(null);
118+
119+
await expect(service.getById(99)).rejects.toThrow(NotFoundException);
120+
});
121+
122+
it('prefers metadata_cid from contract when both sources have it', async () => {
123+
const raffleWithCid = { ...mockRaffle, metadata_cid: 'ipfs://contract-cid' };
124+
indexerService.getRaffle.mockResolvedValue(raffleWithCid);
125+
metadataService.getMetadata.mockResolvedValue(mockMetadata);
126+
127+
const result = await service.getById(1);
128+
129+
expect(result.metadata_cid).toBe('ipfs://contract-cid');
130+
});
131+
132+
it('falls back to metadata_cid from Supabase when contract has none', async () => {
133+
indexerService.getRaffle.mockResolvedValue(mockRaffle); // metadata_cid: null
134+
metadataService.getMetadata.mockResolvedValue(mockMetadata);
135+
136+
const result = await service.getById(1);
137+
138+
expect(result.metadata_cid).toBe('ipfs://abc');
139+
});
140+
});
141+
142+
describe('getBatchMetadata', () => {
143+
it('returns array of metadata from the map', async () => {
144+
const map = new Map([[1, mockMetadata]]);
145+
metadataService.getBatchMetadata.mockResolvedValue(map);
146+
147+
const result = await service.getBatchMetadata([1]);
148+
149+
expect(result).toEqual([mockMetadata]);
150+
expect(metadataService.getBatchMetadata).toHaveBeenCalledWith([1]);
151+
});
152+
});
153+
154+
describe('upsertMetadata', () => {
155+
it('delegates to metadataService.upsertMetadata', async () => {
156+
const payload = { title: 'New Title' };
157+
metadataService.upsertMetadata.mockResolvedValue({ ...mockMetadata, title: 'New Title' });
158+
159+
await service.upsertMetadata(1, payload);
160+
161+
expect(metadataService.upsertMetadata).toHaveBeenCalledWith(1, payload);
162+
});
163+
});
164+
});

backend/src/api/rest/search/search.controller.spec.ts

Lines changed: 13 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -2,34 +2,7 @@ import { SearchController } from './search.controller';
22
import { SearchService } from '../../../services/search.service';
33

44
describe('SearchController', () => {
5-
let controller: SearchController;
6-
let searchService: { search: jest.Mock };
7-
8-
beforeEach(() => {
9-
searchService = {
10-
search: jest.fn(),
11-
};
12-
13-
controller = new SearchController(searchService as unknown as SearchService);
14-
});
15-
16-
it('passes a trimmed category filter to the search service', async () => {
17-
searchService.search.mockResolvedValue([]);
18-
19-
await controller.search('raffle', ' Art ');
20-
21-
expect(searchService.search).toHaveBeenCalledWith('raffle', 'Art');
22-
});
23-
24-
it('treats an empty category as no filter', async () => {
25-
searchService.search.mockResolvedValue([]);
26-
27-
await controller.search('raffle', ' ');
28-
29-
expect(searchService.search).toHaveBeenCalledWith('raffle', undefined);
30-
31-
describe('SearchController', () => {
32-
it('forwards q, limit, and offset and returns the service total', async () => {
5+
it('forwards q, limit, and offset and returns the service result', async () => {
336
const searchService = {
347
search: jest.fn().mockResolvedValue({
358
raffles: [
@@ -45,7 +18,7 @@ describe('SearchController', () => {
4518
}),
4619
};
4720

48-
const controller = new SearchController(searchService as any);
21+
const controller = new SearchController(searchService as unknown as SearchService);
4922

5023
await expect(
5124
(controller as any).search({ q: 'rare', limit: 1, offset: 5 }),
@@ -64,4 +37,15 @@ describe('SearchController', () => {
6437

6538
expect(searchService.search).toHaveBeenCalledWith('rare', 1, 5);
6639
});
40+
41+
it('returns empty result when query is too short', async () => {
42+
const searchService = { search: jest.fn() };
43+
const controller = new SearchController(searchService as unknown as SearchService);
44+
45+
await expect(
46+
(controller as any).search({ q: 'a' }),
47+
).resolves.toEqual({ raffles: [], total: 0 });
48+
49+
expect(searchService.search).not.toHaveBeenCalled();
50+
});
6751
});

0 commit comments

Comments
 (0)