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:
@@ -14,6 +14,7 @@ import { attendeesRoutes } from './modules/attendees';
|
||||
import { authRoutes } from './modules/auth';
|
||||
import { groupsRoutes } from './modules/groups';
|
||||
import { healthCheckRoutes } from './modules/health-check';
|
||||
import { homeRoutes } from './modules/home';
|
||||
import { invitationsRoutes } from './modules/invitations';
|
||||
import { onboardingRoutes } from './modules/onboarding';
|
||||
import { paymentsRoutes } from './modules/payments';
|
||||
@@ -36,6 +37,7 @@ api.route('/auth', authRoutes);
|
||||
api.route('/health', healthCheckRoutes);
|
||||
api.route('/invitations', invitationsRoutes);
|
||||
api.route('/groups', groupsRoutes);
|
||||
api.route('/home', homeRoutes);
|
||||
api.route('/onboarding', onboardingRoutes);
|
||||
api.route('/attendees', attendeesRoutes);
|
||||
api.route('/payments', paymentsRoutes);
|
||||
|
||||
23
apps/backend/src/modules/home/features/get-summary/route.ts
Normal file
23
apps/backend/src/modules/home/features/get-summary/route.ts
Normal 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;
|
||||
134
apps/backend/src/modules/home/features/get-summary/use-case.ts
Normal file
134
apps/backend/src/modules/home/features/get-summary/use-case.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
1
apps/backend/src/modules/home/index.ts
Normal file
1
apps/backend/src/modules/home/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as homeRoutes } from './routes';
|
||||
146
apps/backend/src/modules/home/lib/scheduler.ts
Normal file
146
apps/backend/src/modules/home/lib/scheduler.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import type { WeekDay } from '@gruperly/shared';
|
||||
|
||||
export const DEFAULT_TIME_ZONE = process.env.APP_TIMEZONE ?? 'America/Mexico_City';
|
||||
|
||||
// Ventana (en horas) durante la que una clase ya arrancada se muestra como "en curso".
|
||||
export const CLASS_WINDOW_HOURS = 2;
|
||||
export const CLASS_WINDOW_MS = CLASS_WINDOW_HOURS * 60 * 60 * 1000;
|
||||
|
||||
const WEEKDAY_TO_DOW: Record<WeekDay, number> = {
|
||||
SUNDAY: 0,
|
||||
MONDAY: 1,
|
||||
TUESDAY: 2,
|
||||
WEDNESDAY: 3,
|
||||
THURSDAY: 4,
|
||||
FRIDAY: 5,
|
||||
SATURDAY: 6,
|
||||
};
|
||||
|
||||
export type NextClassOccurrence = {
|
||||
occurrenceAt: Date;
|
||||
isNow: boolean;
|
||||
};
|
||||
|
||||
// Convierte un tiempo de "pared" (wall clock) expresado en `timeZone` al instante absoluto.
|
||||
function zonedWallClockToUtc(wall: string, timeZone: string): Date {
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/.exec(wall);
|
||||
if (!match) {
|
||||
throw new Error(`Invalid wall clock: ${wall}`);
|
||||
}
|
||||
const [, year, month, day, hour, minute] = match;
|
||||
|
||||
const asUtc = new Date(
|
||||
Date.UTC(Number(year), Number(month) - 1, Number(day), Number(hour), Number(minute)),
|
||||
);
|
||||
|
||||
const parts = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone,
|
||||
hourCycle: 'h23',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
})
|
||||
.formatToParts(asUtc)
|
||||
.reduce<Record<string, string>>((acc, part) => {
|
||||
acc[part.type] = part.value;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const tzRepresentation = new Date(
|
||||
Date.UTC(
|
||||
Number(parts.year),
|
||||
Number(parts.month) - 1,
|
||||
Number(parts.day),
|
||||
Number(parts.hour),
|
||||
Number(parts.minute),
|
||||
Number(parts.second),
|
||||
),
|
||||
);
|
||||
|
||||
const offsetMs = asUtc.getTime() - tzRepresentation.getTime();
|
||||
return new Date(asUtc.getTime() + offsetMs);
|
||||
}
|
||||
|
||||
function wallDateInTimeZone(now: Date, timeZone: string): { year: number; month: number; day: number } {
|
||||
const wall = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
})
|
||||
.formatToParts(now)
|
||||
.reduce<Record<string, string>>((acc, part) => {
|
||||
acc[part.type] = part.value;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return {
|
||||
year: Number(wall.year),
|
||||
month: Number(wall.month),
|
||||
day: Number(wall.day),
|
||||
};
|
||||
}
|
||||
|
||||
export function nextClassOccurrence(opt: {
|
||||
days: WeekDay[];
|
||||
time: string;
|
||||
now: Date;
|
||||
timeZone: string;
|
||||
}): NextClassOccurrence | null {
|
||||
const { days, time, now, timeZone } = opt;
|
||||
|
||||
if (days.length === 0 || !time) {
|
||||
return null;
|
||||
}
|
||||
const [hour, minute] = time.split(':').map(Number);
|
||||
if (
|
||||
Number.isNaN(hour) ||
|
||||
Number.isNaN(minute) ||
|
||||
hour < 0 ||
|
||||
hour > 23 ||
|
||||
minute < 0 ||
|
||||
minute > 59
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const dowSet = new Set(days.map((day) => WEEKDAY_TO_DOW[day]));
|
||||
const { year, month, day } = wallDateInTimeZone(now, timeZone);
|
||||
|
||||
let best: NextClassOccurrence | null = null;
|
||||
|
||||
for (let offset = 0; offset <= 7; offset++) {
|
||||
const candidateDay = new Date(Date.UTC(year, month - 1, day + offset, 0, 0, 0));
|
||||
const dow = candidateDay.getUTCDay();
|
||||
if (!dowSet.has(dow)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const wall = `${candidateDay.toISOString().slice(0, 10)}T${pad(hour)}:${pad(minute)}`;
|
||||
const candidate = zonedWallClockToUtc(wall, timeZone);
|
||||
|
||||
if (offset === 0) {
|
||||
if (candidate.getTime() > now.getTime()) {
|
||||
best = { occurrenceAt: candidate, isNow: false };
|
||||
break;
|
||||
}
|
||||
if (now.getTime() >= candidate.getTime() && now.getTime() - candidate.getTime() < CLASS_WINDOW_MS) {
|
||||
return { occurrenceAt: candidate, isNow: true };
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!best || candidate.getTime() < best.occurrenceAt.getTime()) {
|
||||
best = { occurrenceAt: candidate, isNow: false };
|
||||
}
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
function pad(value: number): string {
|
||||
return String(value).padStart(2, '0');
|
||||
}
|
||||
8
apps/backend/src/modules/home/routes.ts
Normal file
8
apps/backend/src/modules/home/routes.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Hono } from 'hono';
|
||||
import getSummaryRoute from './features/get-summary/route';
|
||||
|
||||
const routes = new Hono();
|
||||
|
||||
routes.route('/', getSummaryRoute);
|
||||
|
||||
export default routes;
|
||||
Reference in New Issue
Block a user