Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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);
Expand Down
47 changes: 46 additions & 1 deletion backend/src/modules/analytics/analytics.controller.ts
Original file line number Diff line number Diff line change
@@ -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(
Expand Down Expand Up @@ -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);
}
}
}
2 changes: 2 additions & 0 deletions backend/src/modules/analytics/analytics.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`;

Expand Down
17 changes: 17 additions & 0 deletions backend/src/modules/analytics/analytics.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,20 @@ export const analyticsDailyQuerySchema = z.object({
});

export type AnalyticsDailyQuery = z.infer<typeof analyticsDailyQuerySchema>;

/** 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<typeof volumeQuerySchema>;

/** 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<typeof topTippersQuerySchema>;
116 changes: 116 additions & 0 deletions backend/src/modules/analytics/analytics.service.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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<TipVolumeResponse> {
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<string, { totalStroops: bigint; count: number }>();

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<TopTippersResponse> {
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 };
}
33 changes: 33 additions & 0 deletions backend/src/modules/analytics/analytics.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
46 changes: 46 additions & 0 deletions backend/src/modules/webhooks/webhooks.controller.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
11 changes: 11 additions & 0 deletions backend/src/modules/webhooks/webhooks.routes.ts
Original file line number Diff line number Diff line change
@@ -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);
15 changes: 15 additions & 0 deletions backend/src/modules/webhooks/webhooks.schema.ts
Original file line number Diff line number Diff line change
@@ -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<typeof deliveryQuerySchema>;
export type DeliveryIdParam = z.infer<typeof deliveryIdParamSchema>;
Loading
Loading