Skip to content

Commit 719e5e6

Browse files
Merge pull request #731 from PHADAR6/fix/referrals-audit-627
feat: add /api/referrals endpoints with per-endpoint audit logging
2 parents da8c57d + 1fc560f commit 719e5e6

6 files changed

Lines changed: 714 additions & 0 deletions

File tree

drizzle/0004_add_referrals.sql

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
CREATE TABLE "referrals" (
2+
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
3+
"user_id" uuid NOT NULL,
4+
"referral_code" text NOT NULL,
5+
"campaign_id" text,
6+
"referred_user" text,
7+
"status" text DEFAULT 'pending' NOT NULL,
8+
"created_at" timestamp with time zone DEFAULT now() NOT NULL
9+
);
10+
--> statement-breakpoint
11+
ALTER TABLE "referrals" ADD CONSTRAINT "referrals_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
12+
--> statement-breakpoint
13+
ALTER TABLE "referrals" ADD CONSTRAINT "referrals_referral_code_unique" UNIQUE("referral_code");
14+
--> statement-breakpoint
15+
CREATE INDEX "referrals_user_id_idx" ON "referrals" USING btree ("user_id");
16+
--> statement-breakpoint
17+
CREATE INDEX "referrals_code_idx" ON "referrals" USING btree ("referral_code");

src/db/schema.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -605,3 +605,36 @@ export const marketWatchers = pgTable(
605605
export type MarketWatcher = typeof marketWatchers.$inferSelect;
606606
export type NewMarketWatcher = typeof marketWatchers.$inferInsert;
607607

608+
// ---------------------------------------------------------------------------
609+
// Referrals
610+
// ---------------------------------------------------------------------------
611+
/**
612+
* referrals — tracks referral codes created by users and their usage.
613+
*
614+
* Each row represents a referral code created by a user. When the code is
615+
* used by another user, `referredUser` is populated with their Stellar address.
616+
*/
617+
export const referrals = pgTable(
618+
"referrals",
619+
{
620+
id: uuid("id").primaryKey().defaultRandom(),
621+
userId: uuid("user_id")
622+
.notNull()
623+
.references(() => users.id, { onDelete: "cascade" }),
624+
referralCode: text("referral_code").notNull().unique(),
625+
campaignId: text("campaign_id"),
626+
referredUser: text("referred_user"),
627+
status: text("status").notNull().default("pending"),
628+
createdAt: timestamp("created_at", { withTimezone: true })
629+
.notNull()
630+
.defaultNow(),
631+
},
632+
(t) => ({
633+
referralsUserIdIdx: index("referrals_user_id_idx").on(t.userId),
634+
referralsCodeIdx: index("referrals_code_idx").on(t.referralCode),
635+
}),
636+
);
637+
638+
export type Referral = typeof referrals.$inferSelect;
639+
export type NewReferral = typeof referrals.$inferInsert;
640+

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import { createDocsRouter } from "./routes/docs";
4141
import { searchRouter } from "./routes/search";
4242

4343
import { sessionsRouter } from "./routes/me/sessions";
44+
import { referralsRouter } from "./routes/referrals";
4445
import { notificationsRouter } from "./routes/notifications";
4546
import { socialRouter } from "./routes/social";
4647
import { webhooksHealthRouter } from "./routes/webhooks/health";
@@ -206,6 +207,7 @@ export function createApp(_options: CreateAppOptions = {}): express.Express {
206207
app.use("/api/reports", reportsRouter);
207208
app.use("/api/fingerprint", fingerprintRouter);
208209
app.use("/api/alerts", alertsRouter);
210+
app.use("/api/referrals", referralsRouter);
209211

210212

211213
app.get("/metrics", async (req, res) => {

src/routes/referrals.ts

Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
/**
2+
* @module routes/referrals
3+
*
4+
* Referral code management endpoints.
5+
*
6+
* POST /api/referrals — create a new referral code
7+
* GET /api/referrals — list referrals for the authenticated user
8+
*
9+
* Security
10+
* ────────
11+
* Both endpoints require a valid JWT via the `requireAuth` middleware.
12+
* Unauthenticated callers receive 401.
13+
*
14+
* Audit
15+
* ─────
16+
* POST (mutation) writes a structured row to `audit_logs` via `createAuditLog`.
17+
* The row captures:
18+
* • action — "referral.create"
19+
* • walletAddress — the authenticated user's Stellar address (actor)
20+
* • ip — resolved from x-forwarded-for or socket
21+
* • correlationId — forwarded from AsyncLocalStorage / request id
22+
* • beforeState — null (nothing to capture before creation)
23+
* • afterState — { referralCode: "<code>", campaignId: "..." }
24+
*
25+
* Rate limiting
26+
* ─────────────
27+
* Shared per-user rate limiter (60 req/min) on all routes.
28+
*
29+
* Injectable dependencies
30+
* ───────────────────────
31+
* All external I/O is encapsulated in `ReferralsRouterDeps` so tests can
32+
* substitute fully-controlled stubs without network or DB access.
33+
*/
34+
35+
import { Router, type Request, type Response, type NextFunction } from "express";
36+
import { z } from "zod";
37+
import { rateLimit } from "express-rate-limit";
38+
import { logger } from "../config/logger";
39+
import { requireAuth } from "../middleware/requireAuth";
40+
import type { AuthenticatedRequest } from "../middleware/auth";
41+
import { CORRELATION_ID_HEADER } from "../lib/http";
42+
import { getCorrelationId } from "../middleware/correlation";
43+
import { createAuditLog } from "../services/auditService";
44+
import {
45+
createReferral,
46+
listUserReferrals,
47+
type ReferralServiceDeps,
48+
} from "../services/referralService";
49+
50+
// ── Validation ────────────────────────────────────────────────────────────────
51+
52+
/** Body accepted by POST /api/referrals. */
53+
const createReferralBodySchema = z.object({
54+
campaignId: z.string().optional(),
55+
});
56+
57+
// ── Injectable dependency interface ──────────────────────────────────────────
58+
59+
export interface ReferralsRouterDeps {
60+
/** Create a referral code (defaults to referralService.createReferral). */
61+
createReferral?: ReferralServiceDeps["createReferral"];
62+
/** List user referrals (defaults to referralService.listUserReferrals). */
63+
listUserReferrals?: ReferralServiceDeps["listUserReferrals"];
64+
/** Persist an audit log entry (defaults to createAuditLog). */
65+
auditLogger?: typeof createAuditLog;
66+
}
67+
68+
// ── Router options ────────────────────────────────────────────────────────────
69+
70+
export interface ReferralsRouterOptions {
71+
/** Maximum requests per minute per user. Defaults to 60. */
72+
rateLimitPerMinute?: number;
73+
}
74+
75+
// ── Helpers ───────────────────────────────────────────────────────────────────
76+
77+
/** Resolve the correlation ID from AsyncLocalStorage, then fall back to req.id. */
78+
function resolveCorrelationId(req: { id?: unknown }): string {
79+
return (
80+
getCorrelationId() ??
81+
(typeof req.id === "string" ? req.id : "") ??
82+
""
83+
);
84+
}
85+
86+
/** Extract the client IP from forwarded headers or the socket. */
87+
function extractClientIp(req: {
88+
ip?: string;
89+
socket?: { remoteAddress?: string };
90+
headers: Record<string, string | string[] | undefined>;
91+
}): string {
92+
const forwarded = req.headers["x-forwarded-for"];
93+
if (typeof forwarded === "string") {
94+
const first = forwarded.split(",")[0]?.trim();
95+
if (first) return first;
96+
}
97+
if (Array.isArray(forwarded) && forwarded.length > 0) {
98+
return forwarded[0] ?? "unknown";
99+
}
100+
return req.ip ?? req.socket?.remoteAddress ?? "unknown";
101+
}
102+
103+
// ── Router factory ────────────────────────────────────────────────────────────
104+
105+
/**
106+
* Creates the /api/referrals router with injected dependencies.
107+
*
108+
* @param opts.rateLimitPerMinute - Requests per minute per user (default 60).
109+
* @param deps.createReferral - Override referral creator (tests only).
110+
* @param deps.listUserReferrals - Override referral lister (tests only).
111+
* @param deps.auditLogger - Override audit logger (tests only).
112+
*/
113+
export function createReferralsRouter(
114+
opts: ReferralsRouterOptions = {},
115+
deps: ReferralsRouterDeps = {},
116+
): Router {
117+
const rpmLimit = opts.rateLimitPerMinute ?? 60;
118+
const createReferralFn = deps.createReferral ?? createReferral;
119+
const listUserReferralsFn = deps.listUserReferrals ?? listUserReferrals;
120+
const auditLoggerFn = deps.auditLogger ?? createAuditLog;
121+
122+
const router = Router();
123+
124+
// ── Rate limiter ─────────────────────────────────────────────────────────
125+
router.use(
126+
rateLimit({
127+
windowMs: 60_000,
128+
limit: rpmLimit,
129+
keyGenerator: (req) => {
130+
const userId = (req as AuthenticatedRequest).user?.id;
131+
if (typeof userId === "string" && userId.trim().length > 0) {
132+
return `referrals:${userId}`;
133+
}
134+
return `referrals:ip:${req.ip ?? "unknown"}`;
135+
},
136+
standardHeaders: "draft-6",
137+
legacyHeaders: false,
138+
message: { error: { code: "rate_limit_exceeded" } },
139+
}),
140+
);
141+
142+
// ── Auth guard ──────────────────────────────────────────────────────────
143+
router.use(requireAuth);
144+
145+
// ── POST /api/referrals ─────────────────────────────────────────────────
146+
/**
147+
* Create a new referral code for the authenticated user.
148+
*
149+
* Request body: { "campaignId"?: string }
150+
*
151+
* Response 201: { "data": { "referralCode": "REF-XXXX-XXXX", "message": "…" } }
152+
*/
153+
router.post("/", async (req: Request, res: Response, next: NextFunction) => {
154+
try {
155+
const correlationId = resolveCorrelationId(req);
156+
res.setHeader(CORRELATION_ID_HEADER, correlationId);
157+
158+
const parsed = createReferralBodySchema.safeParse(req.body);
159+
if (!parsed.success) {
160+
return res.status(400).json({
161+
error: {
162+
code: "validation_error",
163+
details: parsed.error.issues,
164+
correlationId,
165+
},
166+
});
167+
}
168+
169+
const { campaignId } = parsed.data;
170+
const userId = (req as AuthenticatedRequest).user!.id;
171+
172+
// Capture before-state (null — no state to capture before creation)
173+
const beforeState = null;
174+
175+
// Persist the referral
176+
const result = await createReferralFn({ userId, campaignId });
177+
178+
const afterState = { referralCode: result.referralCode, campaignId: campaignId ?? null };
179+
180+
// Audit log — fire-and-forget (errors are caught inside auditService)
181+
const ip = extractClientIp(req);
182+
await auditLoggerFn({
183+
action: "referral.create",
184+
walletAddress: (req as AuthenticatedRequest).user?.stellarAddress ?? undefined,
185+
ip,
186+
correlationId,
187+
beforeState,
188+
afterState,
189+
});
190+
191+
logger.info(
192+
{
193+
correlationId,
194+
userId,
195+
referralCode: result.referralCode,
196+
campaignId,
197+
},
198+
"referral_created",
199+
);
200+
201+
return res.status(201).json({
202+
data: result,
203+
});
204+
} catch (err) {
205+
return next(err);
206+
}
207+
});
208+
209+
// ── GET /api/referrals ──────────────────────────────────────────────────
210+
/**
211+
* List all referrals for the authenticated user.
212+
*
213+
* Response 200: { "data": Referral[] }
214+
*/
215+
router.get("/", async (req: Request, res: Response, next: NextFunction) => {
216+
try {
217+
const correlationId = resolveCorrelationId(req);
218+
res.setHeader(CORRELATION_ID_HEADER, correlationId);
219+
220+
const userId = (req as AuthenticatedRequest).user!.id;
221+
222+
const userReferrals = await listUserReferralsFn(userId);
223+
224+
logger.info(
225+
{
226+
correlationId,
227+
userId,
228+
count: userReferrals.length,
229+
},
230+
"referrals_listed",
231+
);
232+
233+
return res.json({
234+
data: userReferrals.map((r) => ({
235+
id: r.id,
236+
referredUser: r.referredUser,
237+
status: r.status,
238+
createdAt: r.createdAt.toISOString(),
239+
})),
240+
});
241+
} catch (err) {
242+
return next(err);
243+
}
244+
});
245+
246+
return router;
247+
}
248+
249+
/** Default singleton wired into src/index.ts. */
250+
export const referralsRouter = createReferralsRouter();

0 commit comments

Comments
 (0)