feat(home): implement home summary feature with routes and use case

- Added home routes and corresponding route handler for fetching home summary.
- Created GetHomeSummary use case to aggregate user data including groups, attendees, and upcoming payments.
- Introduced HomeSummaryDto and related schemas for data validation.
- Implemented nextClassOccurrence function to determine the next class based on user timezone.
- Added tests for home summary functionality and next class occurrence logic.
This commit is contained in:
Jose Selesan
2026-09-23 10:35:24 -03:00
parent 17dca1ad89
commit f1e0fc5aae
13 changed files with 1037 additions and 6 deletions

View File

@@ -0,0 +1,23 @@
import type { HomeQuery } from '@gruperly/shared';
import { HomeQuerySchema } from '@gruperly/shared';
import { Hono } from 'hono';
import { problemJson, resultJson, unauthorizedProblem } from '@/http/problem-details';
import { validate } from '@/http/validate';
import { GetHomeSummary } from './use-case';
const route = new Hono();
route.get('/', validate.query(HomeQuerySchema), async (c) => {
const user = c.get('user');
if (!user) {
return problemJson(c, unauthorizedProblem(c.req.path));
}
const query = c.req.valid('query') as HomeQuery;
const useCase = new GetHomeSummary();
const result = await useCase.execute(user.id, query);
return resultJson(c, result);
});
export default route;

View File

@@ -0,0 +1,134 @@
import type { PrismaClient } from '@generated/prisma/client';
import type {
HomeQuery,
HomeSummary,
NextClass,
ProblemDetails,
Result,
} from '@gruperly/shared';
import { ok } from '@gruperly/shared';
import prisma from '@/lib/prisma';
import { buildGroupWhereForUser } from '@/modules/groups/lib/helpers';
import { DEFAULT_TIME_ZONE, nextClassOccurrence } from '../../lib/scheduler';
export const UPCOMING_PAYMENTS_LIMIT = 5;
type HomeSummaryDeps = {
db?: Pick<PrismaClient, 'group' | 'payment'>;
now?: Date;
timeZone?: string;
};
type GroupSummaryRecord = {
id: string;
name: string;
days: NextClass['days'];
time: string | null;
_count: { attendees: number };
};
export class GetHomeSummary {
constructor(private readonly deps: HomeSummaryDeps = {}) {}
async execute(userId: string, query: HomeQuery = {}): Promise<Result<HomeSummary, ProblemDetails>> {
const db = this.deps.db ?? prisma;
const now = this.deps.now ?? new Date();
const timeZone = this.deps.timeZone ?? query.timeZone ?? DEFAULT_TIME_ZONE;
const groups = await db.group.findMany({
where: buildGroupWhereForUser(userId),
select: {
id: true,
name: true,
days: true,
time: true,
_count: { select: { attendees: true } },
},
});
if (groups.length === 0) {
return ok({
stats: { groups: 0, attendees: 0, pendingPayments: 0, pendingAmount: 0 },
nextClass: null,
upcomingPayments: [],
});
}
const groupIds = groups.map((group) => group.id);
const attendees = groups.reduce((sum, group) => sum + group._count.attendees, 0);
const [pendingAggregate, upcomingPayments] = await Promise.all([
db.payment.aggregate({
where: { groupId: { in: groupIds }, status: { in: ['PENDING', 'OVERDUE'] } },
_count: true,
_sum: { amount: true },
}),
db.payment.findMany({
where: { groupId: { in: groupIds }, status: { in: ['PENDING', 'OVERDUE'] } },
orderBy: [{ dueDate: 'asc' as const }],
take: UPCOMING_PAYMENTS_LIMIT,
include: {
group: { select: { name: true } },
attendee: { select: { fullName: true } },
},
}),
]);
return ok({
stats: {
groups: groups.length,
attendees,
pendingPayments: pendingAggregate._count,
pendingAmount: pendingAggregate._sum.amount
? Number(pendingAggregate._sum.amount.toString())
: 0,
},
nextClass: this.findNextClass(groups, now, timeZone),
upcomingPayments: upcomingPayments.map((payment) => ({
id: payment.id,
amount: Number(payment.amount.toString()),
currency: payment.currency,
dueDate: payment.dueDate.toISOString(),
status: payment.status,
groupName: payment.group.name,
attendeeName: payment.attendee.fullName,
})),
});
}
private findNextClass(
groups: GroupSummaryRecord[],
now: Date,
timeZone: string,
): NextClass | null {
let best: NextClass | null = null;
for (const group of groups) {
if (!group.days || group.days.length === 0 || !group.time) {
continue;
}
const occurrence = nextClassOccurrence({
days: group.days,
time: group.time,
now,
timeZone,
});
if (!occurrence) {
continue;
}
if (best && occurrence.occurrenceAt.getTime() >= new Date(best.occurrenceAt).getTime()) {
continue;
}
best = {
groupId: group.id,
name: group.name,
days: group.days,
time: group.time,
occurrenceAt: occurrence.occurrenceAt.toISOString(),
isNow: occurrence.isNow,
};
}
return best;
}
}