diff --git a/backend/src/app.ts b/backend/src/app.ts index e3727e11..45ac87c6 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -18,6 +18,7 @@ import { ipfsRouter } from './modules/ipfs/ipfs.routes.js'; import { xRouter } from './modules/x/x.routes.js'; import { notificationsRouter } from './modules/notifications/notifications.routes.js'; import { searchRouter } from './modules/search/search.routes.js'; +import { webhooksRouter } from './modules/webhooks/webhooks.routes.js'; import { analyticsRouter } from './modules/analytics/analytics.routes.js'; /** Builds and configures the Express application without starting a listener. */ @@ -65,6 +66,7 @@ export function createApp(): Express { app.use(`${env.API_BASE_PATH}/x`, xRouter); app.use(`${env.API_BASE_PATH}/balances`, balancesRouter); app.use(`${env.API_BASE_PATH}/search`, searchRouter); + app.use(`${env.API_BASE_PATH}/webhooks`, webhooksRouter); app.use(`${env.API_BASE_PATH}/analytics`, analyticsRouter); app.use(notFoundHandler); diff --git a/backend/src/modules/analytics/analytics.controller.ts b/backend/src/modules/analytics/analytics.controller.ts index 922c7d38..6cb267c5 100644 --- a/backend/src/modules/analytics/analytics.controller.ts +++ b/backend/src/modules/analytics/analytics.controller.ts @@ -1,6 +1,9 @@ import type { Request, Response, NextFunction } from 'express'; -import { analyticsDailyQuerySchema } from './analytics.schema.js'; +import { z } from 'zod'; +import { BadRequestError } from '../../common/errors/AppError.js'; +import { analyticsDailyQuerySchema, volumeQuerySchema, topTippersQuerySchema } from './analytics.schema.js'; import * as analyticsService from './analytics.service.js'; +import { getTipVolume, getTopTippers } from './analytics.service.js'; /** GET /analytics/daily — paginated daily analytics with optional date range. */ export async function getDailyAnalytics( @@ -31,3 +34,45 @@ export async function getAnalyticsSummary( next(err); } } + +/** GET /analytics/volume — tip volume time-series with granularity (issue #1008). */ +export async function getTipVolumeController( + req: Request, + res: Response, + next: NextFunction, +) { + try { + const query = volumeQuerySchema.parse(req.query); + const result = await getTipVolume( + query.granularity, + query.startDate, + query.endDate, + ); + res.json({ data: result }); + } catch (error) { + if (error instanceof z.ZodError) { + next(new BadRequestError('Invalid query parameters', error.issues)); + } else { + next(error); + } + } +} + +/** GET /analytics/top-tippers — top tippers ranked by total stroops (issue #1009). */ +export async function getTopTippersController( + req: Request, + res: Response, + next: NextFunction, +) { + try { + const query = topTippersQuerySchema.parse(req.query); + const result = await getTopTippers(query.page, query.limit); + res.json({ data: result }); + } catch (error) { + if (error instanceof z.ZodError) { + next(new BadRequestError('Invalid query parameters', error.issues)); + } else { + next(error); + } + } +} diff --git a/backend/src/modules/analytics/analytics.routes.ts b/backend/src/modules/analytics/analytics.routes.ts index 97644b60..25399d64 100644 --- a/backend/src/modules/analytics/analytics.routes.ts +++ b/backend/src/modules/analytics/analytics.routes.ts @@ -7,6 +7,8 @@ export const analyticsRouter = Router(); analyticsRouter.get('/daily', analyticsController.getDailyAnalytics); analyticsRouter.get('/summary', analyticsController.getAnalyticsSummary); +analyticsRouter.get('/volume', analyticsController.getTipVolumeController); +analyticsRouter.get('/top-tippers', analyticsController.getTopTippersController); const base = `${env.API_BASE_PATH}/analytics`; diff --git a/backend/src/modules/analytics/analytics.schema.ts b/backend/src/modules/analytics/analytics.schema.ts index a2717eaa..596d80d2 100644 --- a/backend/src/modules/analytics/analytics.schema.ts +++ b/backend/src/modules/analytics/analytics.schema.ts @@ -9,3 +9,20 @@ export const analyticsDailyQuerySchema = z.object({ }); export type AnalyticsDailyQuery = z.infer; + +/** Query parameters for GET /analytics/volume (issue #1008). */ +export const volumeQuerySchema = z.object({ + granularity: z.enum(['day', 'week', 'month']).default('day'), + startDate: z.string().datetime({ offset: true }).optional(), + endDate: z.string().datetime({ offset: true }).optional(), +}); + +export type VolumeQuery = z.infer; + +/** Query parameters for GET /analytics/top-tippers (issue #1009). */ +export const topTippersQuerySchema = z.object({ + page: z.coerce.number().int().min(1).default(1), + limit: z.coerce.number().int().min(1).max(100).default(20), +}); + +export type TopTippersQuery = z.infer; diff --git a/backend/src/modules/analytics/analytics.service.ts b/backend/src/modules/analytics/analytics.service.ts index 69110df1..148ee183 100644 --- a/backend/src/modules/analytics/analytics.service.ts +++ b/backend/src/modules/analytics/analytics.service.ts @@ -1,5 +1,7 @@ import { prisma } from '../../db/prisma.js'; +import { logger } from '../../common/utils/logger.js'; import type { AnalyticsDailyResponse, AnalyticsSummary } from './analytics.types.js'; +import type { TipVolumeResponse, TopTipperEntry, TopTippersResponse } from './analytics.types.js'; /** * Returns paginated daily analytics rows, optionally filtered by date range. @@ -85,3 +87,117 @@ export async function getAnalyticsSummary( }, }; } + +/** + * Returns tip volume time-series bucketed by granularity (issue #1008). + */ +export async function getTipVolume( + granularity: string, + startDate?: string, + endDate?: string, +): Promise { + logger.info({ granularity, startDate, endDate }, 'Fetching tip volume time-series'); + + const now = new Date(); + const start = startDate ? new Date(startDate) : new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); + const end = endDate ? new Date(endDate) : now; + + const tips = await prisma.tip.findMany({ + where: { + createdAt: { gte: start, lte: end }, + status: 'COMPLETED', + }, + select: { amountStroops: true, createdAt: true }, + orderBy: { createdAt: 'asc' }, + }); + + const buckets = new Map(); + + for (const tip of tips) { + let key: string; + const d = new Date(tip.createdAt); + + switch (granularity) { + case 'week': { + const startOfWeek = new Date(d); + startOfWeek.setDate(d.getDate() - d.getDay()); + key = startOfWeek.toISOString().slice(0, 10); + break; + } + case 'month': + key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`; + break; + default: + key = d.toISOString().slice(0, 10); + break; + } + + const existing = buckets.get(key); + if (existing) { + existing.totalStroops += tip.amountStroops; + existing.count += 1; + } else { + buckets.set(key, { totalStroops: tip.amountStroops, count: 1 }); + } + } + + const entries = Array.from(buckets.entries()).map(([date, data]) => ({ + date, + totalTips: data.totalStroops.toString(), + count: data.count, + })); + + return { + entries, + granularity, + startDate: start.toISOString(), + endDate: end.toISOString(), + }; +} + +/** + * Returns top tippers ranked by total stroops sent (issue #1009). + */ +export async function getTopTippers( + page: number, + limit: number, +): Promise { + logger.info({ page, limit }, 'Fetching top tippers'); + + const skip = (page - 1) * limit; + + const grouped = await prisma.tip.groupBy({ + by: ['fromAddress'], + _sum: { amountStroops: true }, + _count: true, + orderBy: { _sum: { amountStroops: 'desc' } }, + skip, + take: limit, + }); + + const total = (await prisma.tip.groupBy({ by: ['fromAddress'] })).length; + + const entries: TopTipperEntry[] = await Promise.all( + grouped.map(async (row) => { + const user = await prisma.user.findUnique({ + where: { stellarAddress: row.fromAddress }, + select: { + id: true, + stellarAddress: true, + username: true, + displayName: true, + }, + }); + return { + userId: user?.id ?? '', + stellarAddress: row.fromAddress, + username: user?.username ?? null, + displayName: user?.displayName ?? null, + totalTipsStroops: (row._sum.amountStroops ?? 0n).toString(), + tipCount: row._count, + }; + }), + ); + + return { entries, total, page, limit }; +} diff --git a/backend/src/modules/analytics/analytics.types.ts b/backend/src/modules/analytics/analytics.types.ts index b5cb7467..551b99f9 100644 --- a/backend/src/modules/analytics/analytics.types.ts +++ b/backend/src/modules/analytics/analytics.types.ts @@ -29,3 +29,36 @@ export interface AnalyticsSummary { end: string | null; }; } + +/** Tip volume time-series entry (issue #1008). */ +export interface TipVolumeEntry { + date: string; + totalTips: string; + count: number; +} + +/** Tip volume time-series response (issue #1008). */ +export interface TipVolumeResponse { + entries: TipVolumeEntry[]; + granularity: string; + startDate: string; + endDate: string; +} + +/** Top tipper entry (issue #1009). */ +export interface TopTipperEntry { + userId: string; + stellarAddress: string; + username: string | null; + displayName: string | null; + totalTipsStroops: string; + tipCount: number; +} + +/** Top tippers response (issue #1009). */ +export interface TopTippersResponse { + entries: TopTipperEntry[]; + total: number; + page: number; + limit: number; +} diff --git a/backend/src/modules/webhooks/webhooks.controller.ts b/backend/src/modules/webhooks/webhooks.controller.ts new file mode 100644 index 00000000..9e0dca92 --- /dev/null +++ b/backend/src/modules/webhooks/webhooks.controller.ts @@ -0,0 +1,46 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { BadRequestError } from "../../common/errors/AppError.js"; +import { listDeliveries, getDelivery } from "./webhooks.service.js"; +import { deliveryQuerySchema, deliveryIdParamSchema } from "./webhooks.schema.js"; + +export async function listDeliveriesController( + req: Request, + res: Response, + next: NextFunction, +) { + try { + const query = deliveryQuerySchema.parse(req.query); + const result = await listDeliveries( + query.page, + query.limit, + query.subscriptionId, + query.status, + ); + res.json({ data: result }); + } catch (error) { + if (error instanceof z.ZodError) { + next(new BadRequestError("Invalid query parameters", error.issues)); + } else { + next(error); + } + } +} + +export async function getDeliveryController( + req: Request, + res: Response, + next: NextFunction, +) { + try { + const { id } = deliveryIdParamSchema.parse(req.params); + const result = await getDelivery(id); + res.json({ data: result }); + } catch (error) { + if (error instanceof z.ZodError) { + next(new BadRequestError("Invalid delivery ID", error.issues)); + } else { + next(error); + } + } +} diff --git a/backend/src/modules/webhooks/webhooks.routes.ts b/backend/src/modules/webhooks/webhooks.routes.ts new file mode 100644 index 00000000..cf2d88e6 --- /dev/null +++ b/backend/src/modules/webhooks/webhooks.routes.ts @@ -0,0 +1,11 @@ +import { Router } from "express"; +import { requireAuth } from "../auth/auth.middleware.js"; +import { + listDeliveriesController, + getDeliveryController, +} from "./webhooks.controller.js"; + +export const webhooksRouter = Router(); + +webhooksRouter.get("/deliveries", requireAuth, listDeliveriesController); +webhooksRouter.get("/deliveries/:id", requireAuth, getDeliveryController); diff --git a/backend/src/modules/webhooks/webhooks.schema.ts b/backend/src/modules/webhooks/webhooks.schema.ts new file mode 100644 index 00000000..f5da2636 --- /dev/null +++ b/backend/src/modules/webhooks/webhooks.schema.ts @@ -0,0 +1,15 @@ +import { z } from "zod"; + +export const deliveryQuerySchema = z.object({ + subscriptionId: z.string().optional(), + status: z.enum(["PENDING", "SUCCESS", "FAILED"]).optional(), + page: z.coerce.number().int().min(1).default(1), + limit: z.coerce.number().int().min(1).max(100).default(20), +}); + +export const deliveryIdParamSchema = z.object({ + id: z.string().min(1, "Delivery ID is required"), +}); + +export type DeliveryQuery = z.infer; +export type DeliveryIdParam = z.infer; diff --git a/backend/src/modules/webhooks/webhooks.service.ts b/backend/src/modules/webhooks/webhooks.service.ts new file mode 100644 index 00000000..32fa6a17 --- /dev/null +++ b/backend/src/modules/webhooks/webhooks.service.ts @@ -0,0 +1,60 @@ +import { prisma } from "../../db/prisma.js"; +import { logger } from "../../common/utils/logger.js"; +import { NotFoundError } from "../../common/errors/AppError.js"; +import type { WebhookDeliveryResponse, WebhookDeliveryListResponse } from "./webhooks.types.js"; + +export async function listDeliveries( + page: number, + limit: number, + subscriptionId?: string, + status?: string, +): Promise { + logger.info({ page, limit, subscriptionId, status }, "Listing webhook deliveries"); + + const where: Record = {}; + if (subscriptionId) where.subscriptionId = subscriptionId; + if (status) where.status = status; + + const skip = (page - 1) * limit; + + const [deliveries, total] = await Promise.all([ + prisma.webhookDelivery.findMany({ + where, + orderBy: { createdAt: "desc" }, + skip, + take: limit, + }), + prisma.webhookDelivery.count({ where }), + ]); + + const entries: WebhookDeliveryResponse[] = deliveries.map((d) => ({ + id: d.id, + subscriptionId: d.subscriptionId, + status: d.status, + responseCode: d.responseCode, + attempts: d.attempts, + nextAttemptAt: d.nextAttemptAt?.toISOString() ?? null, + createdAt: d.createdAt.toISOString(), + updatedAt: d.updatedAt.toISOString(), + })); + + return { entries, total, page, limit }; +} + +export async function getDelivery(id: string): Promise { + logger.info({ id }, "Fetching webhook delivery"); + + const delivery = await prisma.webhookDelivery.findUnique({ where: { id } }); + if (!delivery) throw new NotFoundError(`Webhook delivery ${id} not found`); + + return { + id: delivery.id, + subscriptionId: delivery.subscriptionId, + status: delivery.status, + responseCode: delivery.responseCode, + attempts: delivery.attempts, + nextAttemptAt: delivery.nextAttemptAt?.toISOString() ?? null, + createdAt: delivery.createdAt.toISOString(), + updatedAt: delivery.updatedAt.toISOString(), + }; +} diff --git a/backend/src/modules/webhooks/webhooks.test.ts b/backend/src/modules/webhooks/webhooks.test.ts new file mode 100644 index 00000000..dcce330e --- /dev/null +++ b/backend/src/modules/webhooks/webhooks.test.ts @@ -0,0 +1,136 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("../../common/utils/logger.js", () => ({ + logger: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() }, +})); + +vi.mock("../../db/prisma.js", () => ({ + prisma: { + webhookDelivery: { findMany: vi.fn(), count: vi.fn(), findUnique: vi.fn() }, + }, +})); + +import { listDeliveries, getDelivery } from "./webhooks.service.js"; +import { prisma } from "../../db/prisma.js"; + +const fakeDeliveries = [ + { + id: "del_01", + subscriptionId: "sub_01", + status: "SUCCESS", + responseCode: 200, + attempts: 1, + nextAttemptAt: null, + createdAt: new Date("2026-07-01T00:00:00Z"), + updatedAt: new Date("2026-07-01T00:00:00Z"), + }, + { + id: "del_02", + subscriptionId: "sub_01", + status: "FAILED", + responseCode: 500, + attempts: 3, + nextAttemptAt: new Date("2026-07-02T00:00:00Z"), + createdAt: new Date("2026-07-01T01:00:00Z"), + updatedAt: new Date("2026-07-01T02:00:00Z"), + }, +]; + +describe("listDeliveries (issue #1001)", () => { + beforeEach(() => vi.clearAllMocks()); + + it("returns paginated deliveries ordered by createdAt desc", async () => { + vi.mocked(prisma.webhookDelivery.findMany).mockResolvedValueOnce( + fakeDeliveries as never, + ); + vi.mocked(prisma.webhookDelivery.count).mockResolvedValueOnce(2 as never); + + const result = await listDeliveries(1, 20); + + expect(result.entries).toHaveLength(2); + expect(result.total).toBe(2); + expect(result.page).toBe(1); + expect(result.limit).toBe(20); + expect(result.entries[0].id).toBe("del_01"); + expect(result.entries[0].status).toBe("SUCCESS"); + }); + + it("filters by subscriptionId when provided", async () => { + vi.mocked(prisma.webhookDelivery.findMany).mockResolvedValueOnce( + [fakeDeliveries[0]] as never, + ); + vi.mocked(prisma.webhookDelivery.count).mockResolvedValueOnce(1 as never); + + await listDeliveries(1, 20, "sub_01"); + + expect(prisma.webhookDelivery.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ subscriptionId: "sub_01" }), + }), + ); + }); + + it("filters by status when provided", async () => { + vi.mocked(prisma.webhookDelivery.findMany).mockResolvedValueOnce( + [fakeDeliveries[0]] as never, + ); + vi.mocked(prisma.webhookDelivery.count).mockResolvedValueOnce(1 as never); + + await listDeliveries(1, 20, undefined, "SUCCESS"); + + expect(prisma.webhookDelivery.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ status: "SUCCESS" }), + }), + ); + }); + + it("returns empty list when no deliveries match", async () => { + vi.mocked(prisma.webhookDelivery.findMany).mockResolvedValueOnce([] as never); + vi.mocked(prisma.webhookDelivery.count).mockResolvedValueOnce(0 as never); + + const result = await listDeliveries(1, 20); + + expect(result.entries).toHaveLength(0); + expect(result.total).toBe(0); + }); + + it("serializes dates to ISO strings", async () => { + vi.mocked(prisma.webhookDelivery.findMany).mockResolvedValueOnce( + fakeDeliveries as never, + ); + vi.mocked(prisma.webhookDelivery.count).mockResolvedValueOnce(2 as never); + + const result = await listDeliveries(1, 20); + + for (const entry of result.entries) { + expect(() => new Date(entry.createdAt).toISOString()).not.toThrow(); + expect(() => new Date(entry.updatedAt).toISOString()).not.toThrow(); + } + }); +}); + +describe("getDelivery (issue #1001)", () => { + beforeEach(() => vi.clearAllMocks()); + + it("returns a delivery by id", async () => { + vi.mocked(prisma.webhookDelivery.findUnique).mockResolvedValueOnce( + fakeDeliveries[0] as never, + ); + + const result = await getDelivery("del_01"); + + expect(result.id).toBe("del_01"); + expect(result.status).toBe("SUCCESS"); + expect(result.responseCode).toBe(200); + expect(result.attempts).toBe(1); + }); + + it("throws NotFoundError when delivery does not exist", async () => { + vi.mocked(prisma.webhookDelivery.findUnique).mockResolvedValueOnce(null); + + await expect(getDelivery("ghost")).rejects.toMatchObject({ + statusCode: 404, + }); + }); +}); diff --git a/backend/src/modules/webhooks/webhooks.types.ts b/backend/src/modules/webhooks/webhooks.types.ts new file mode 100644 index 00000000..949021f1 --- /dev/null +++ b/backend/src/modules/webhooks/webhooks.types.ts @@ -0,0 +1,17 @@ +export interface WebhookDeliveryResponse { + id: string; + subscriptionId: string; + status: string; + responseCode: number | null; + attempts: number; + nextAttemptAt: string | null; + createdAt: string; + updatedAt: string; +} + +export interface WebhookDeliveryListResponse { + entries: WebhookDeliveryResponse[]; + total: number; + page: number; + limit: number; +}