feat: add absence notification and analytics features
- Implemented AbsenceNotifyView for notifying absence with session details. - Created AnalyticsView for displaying group analytics and managing students at risk. - Added ClassAttendanceView for marking attendance in classes. - Defined new schemas for analytics and attendance in shared package.
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
import { Hono } from 'hono';
|
||||
import { problemJson, resultJson, unauthorizedProblem } from '@/http/problem-details';
|
||||
import { GetAttendeeHistory } from './use-case';
|
||||
|
||||
const route = new Hono();
|
||||
|
||||
route.get('/:groupId/attendees/:attendeeId/history', async (c) => {
|
||||
const user = c.get('user');
|
||||
if (!user) {
|
||||
return problemJson(c, unauthorizedProblem(c.req.path));
|
||||
}
|
||||
|
||||
const groupId = c.req.param('groupId');
|
||||
const attendeeId = c.req.param('attendeeId');
|
||||
const useCase = new GetAttendeeHistory();
|
||||
const result = await useCase.execute(groupId, attendeeId, user.id);
|
||||
|
||||
return resultJson(c, result);
|
||||
});
|
||||
|
||||
export default route;
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { PrismaClient } from '@generated/prisma/client';
|
||||
import type {
|
||||
AttendanceStatus,
|
||||
AttendeeHistory,
|
||||
ProblemDetails,
|
||||
Result,
|
||||
} from '@gruperly/shared';
|
||||
import { err, ok } from '@gruperly/shared';
|
||||
import { notFoundResourceProblem } from '@/http/problem-builders';
|
||||
import prisma from '@/lib/prisma';
|
||||
import { type AnalyticsAccessDb, findGroupForUser } from '../../lib/access';
|
||||
|
||||
export const HISTORY_SESSIONS_LIMIT = 20;
|
||||
|
||||
type GetAttendeeHistoryDeps = {
|
||||
db?: AnalyticsAccessDb & Pick<PrismaClient, 'classSession' | 'attendee' | 'attendance'>;
|
||||
};
|
||||
|
||||
export class GetAttendeeHistory {
|
||||
constructor(private readonly deps: GetAttendeeHistoryDeps = {}) {}
|
||||
|
||||
async execute(
|
||||
groupId: string,
|
||||
attendeeId: string,
|
||||
userId: string,
|
||||
): Promise<Result<AttendeeHistory, ProblemDetails>> {
|
||||
const db = this.deps.db ?? prisma;
|
||||
|
||||
const access = await findGroupForUser(db, groupId, userId);
|
||||
if (!access.ok) {
|
||||
return access;
|
||||
}
|
||||
|
||||
const attendee = await db.attendee.findFirst({
|
||||
where: { id: attendeeId, groupId },
|
||||
select: { id: true, fullName: true },
|
||||
});
|
||||
if (!attendee) {
|
||||
return err(notFoundResourceProblem('Attendee', attendeeId));
|
||||
}
|
||||
|
||||
const sessions = await db.classSession.findMany({
|
||||
where: { groupId },
|
||||
orderBy: { startsAt: 'desc' },
|
||||
take: HISTORY_SESSIONS_LIMIT,
|
||||
select: { id: true, startsAt: true },
|
||||
});
|
||||
const sessionIds = sessions.map((session) => session.id);
|
||||
|
||||
const attendances = sessionIds.length
|
||||
? await db.attendance.findMany({
|
||||
where: { attendeeId, classSessionId: { in: sessionIds } },
|
||||
select: { classSessionId: true, status: true },
|
||||
})
|
||||
: [];
|
||||
|
||||
const statusBySession = new Map<string, AttendanceStatus>(
|
||||
attendances.map((record) => [record.classSessionId, record.status]),
|
||||
);
|
||||
|
||||
const orderedSessions = sessions
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((session) => ({
|
||||
classSessionId: session.id,
|
||||
startsAt: session.startsAt.toISOString(),
|
||||
status: statusBySession.get(session.id) ?? null,
|
||||
}));
|
||||
|
||||
const presentCount = attendances.filter((record) => record.status === 'PRESENT').length;
|
||||
const attendanceRate =
|
||||
sessions.length > 0 ? Math.round((presentCount / sessions.length) * 100) : 0;
|
||||
|
||||
return ok({
|
||||
attendeeId: attendee.id,
|
||||
fullName: attendee.fullName,
|
||||
groupId,
|
||||
groupName: access.value.name,
|
||||
attendanceRate,
|
||||
sessions: orderedSessions,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Hono } from 'hono';
|
||||
import { problemJson, resultJson, unauthorizedProblem } from '@/http/problem-details';
|
||||
import { GetGroupAnalytics } from './use-case';
|
||||
|
||||
const route = new Hono();
|
||||
|
||||
route.get('/:groupId/analytics', async (c) => {
|
||||
const user = c.get('user');
|
||||
if (!user) {
|
||||
return problemJson(c, unauthorizedProblem(c.req.path));
|
||||
}
|
||||
|
||||
const groupId = c.req.param('groupId');
|
||||
const useCase = new GetGroupAnalytics();
|
||||
const result = await useCase.execute(groupId, user.id);
|
||||
|
||||
return resultJson(c, result);
|
||||
});
|
||||
|
||||
export default route;
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { PrismaClient } from '@generated/prisma/client';
|
||||
import type { GroupAnalytics, ProblemDetails, Result } from '@gruperly/shared';
|
||||
import { ok } from '@gruperly/shared';
|
||||
import prisma from '@/lib/prisma';
|
||||
import { type AnalyticsAccessDb, findGroupForUser } from '../../lib/access';
|
||||
|
||||
export const ANALYTICS_WINDOW_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
type GetGroupAnalyticsDeps = {
|
||||
db?: AnalyticsAccessDb & Pick<PrismaClient, 'classSession' | 'attendance' | 'slotRelease'>;
|
||||
now?: Date;
|
||||
};
|
||||
|
||||
export class GetGroupAnalytics {
|
||||
constructor(private readonly deps: GetGroupAnalyticsDeps = {}) {}
|
||||
|
||||
async execute(
|
||||
groupId: string,
|
||||
userId: string,
|
||||
): Promise<Result<GroupAnalytics, ProblemDetails>> {
|
||||
const db = this.deps.db ?? prisma;
|
||||
const now = this.deps.now ?? new Date();
|
||||
const since = new Date(now.getTime() - ANALYTICS_WINDOW_MS);
|
||||
|
||||
const access = await findGroupForUser(db, groupId, userId);
|
||||
if (!access.ok) {
|
||||
return access;
|
||||
}
|
||||
|
||||
const [sessions, attendances, recoveredSlots] = await Promise.all([
|
||||
db.classSession.findMany({
|
||||
where: { groupId, startsAt: { gte: since } },
|
||||
select: { id: true },
|
||||
}),
|
||||
db.attendance.findMany({
|
||||
where: { classSession: { groupId, startsAt: { gte: since } } },
|
||||
select: { status: true },
|
||||
}),
|
||||
db.slotRelease.count({
|
||||
where: { classSession: { groupId, startsAt: { gte: since } } },
|
||||
}),
|
||||
]);
|
||||
|
||||
const totalClasses = sessions.length;
|
||||
const totalMarked = attendances.length;
|
||||
const totalPresent = attendances.filter((record) => record.status === 'PRESENT').length;
|
||||
|
||||
return ok({
|
||||
attendanceRate: totalMarked > 0 ? Math.round((totalPresent / totalMarked) * 100) : 0,
|
||||
totalPresent,
|
||||
totalClasses,
|
||||
recoveredSlots,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Hono } from 'hono';
|
||||
import { problemJson, resultJson, unauthorizedProblem } from '@/http/problem-details';
|
||||
import { GetStudentsAtRisk } from './use-case';
|
||||
|
||||
const route = new Hono();
|
||||
|
||||
route.get('/:groupId/students-at-risk', async (c) => {
|
||||
const user = c.get('user');
|
||||
if (!user) {
|
||||
return problemJson(c, unauthorizedProblem(c.req.path));
|
||||
}
|
||||
|
||||
const groupId = c.req.param('groupId');
|
||||
const useCase = new GetStudentsAtRisk();
|
||||
const result = await useCase.execute(groupId, user.id);
|
||||
|
||||
return resultJson(c, result);
|
||||
});
|
||||
|
||||
export default route;
|
||||
@@ -0,0 +1,122 @@
|
||||
import type { PrismaClient } from '@generated/prisma/client';
|
||||
import type {
|
||||
AttendanceStatus,
|
||||
ProblemDetails,
|
||||
Result,
|
||||
StudentAtRisk,
|
||||
StudentsAtRisk,
|
||||
} from '@gruperly/shared';
|
||||
import { ok } from '@gruperly/shared';
|
||||
import prisma from '@/lib/prisma';
|
||||
import { type AnalyticsAccessDb, findGroupForUser } from '../../lib/access';
|
||||
import {
|
||||
classifyRisk,
|
||||
lastAttendedAt,
|
||||
monthlyAttendanceRate,
|
||||
trailingAbsentStreak,
|
||||
} from '../../lib/risk';
|
||||
import { ANALYTICS_WINDOW_MS } from '../get-overview/use-case';
|
||||
|
||||
type GetStudentsAtRiskDeps = {
|
||||
db?: AnalyticsAccessDb & Pick<PrismaClient, 'classSession' | 'attendee' | 'attendance'>;
|
||||
now?: Date;
|
||||
};
|
||||
|
||||
type AttendanceStreamRow = {
|
||||
attendeeId: string;
|
||||
status: AttendanceStatus;
|
||||
classSession: { startsAt: Date };
|
||||
};
|
||||
|
||||
export class GetStudentsAtRisk {
|
||||
constructor(private readonly deps: GetStudentsAtRiskDeps = {}) {}
|
||||
|
||||
async execute(
|
||||
groupId: string,
|
||||
userId: string,
|
||||
): Promise<Result<StudentsAtRisk, ProblemDetails>> {
|
||||
const db = this.deps.db ?? prisma;
|
||||
const now = this.deps.now ?? new Date();
|
||||
const since = new Date(now.getTime() - ANALYTICS_WINDOW_MS);
|
||||
|
||||
const access = await findGroupForUser(db, groupId, userId);
|
||||
if (!access.ok) {
|
||||
return access;
|
||||
}
|
||||
|
||||
const [windowSessions, attendees, attendances] = await Promise.all([
|
||||
db.classSession.findMany({
|
||||
where: { groupId, startsAt: { gte: since } },
|
||||
select: { id: true },
|
||||
}),
|
||||
db.attendee.findMany({
|
||||
where: { groupId, status: 'ACTIVE' },
|
||||
select: { id: true, fullName: true, phone: true },
|
||||
}),
|
||||
db.attendance.findMany({
|
||||
where: { attendee: { groupId } },
|
||||
select: {
|
||||
attendeeId: true,
|
||||
status: true,
|
||||
classSession: { select: { startsAt: true } },
|
||||
},
|
||||
orderBy: { classSession: { startsAt: 'desc' } },
|
||||
}),
|
||||
]);
|
||||
|
||||
const totalWindowSessions = windowSessions.length;
|
||||
const streamByAttendee = groupAttendanceByAttendee(attendances);
|
||||
|
||||
const atRisk: StudentAtRisk[] = [];
|
||||
for (const attendee of attendees) {
|
||||
const stream = (streamByAttendee.get(attendee.id) ?? []).map((row) => ({
|
||||
status: row.status,
|
||||
occurredAt: row.classSession.startsAt,
|
||||
}));
|
||||
const consecutiveAbsences = trailingAbsentStreak(stream);
|
||||
const attendedAt = lastAttendedAt(stream);
|
||||
const monthlyRate = monthlyAttendanceRate(
|
||||
stream.filter((entry) => entry.occurredAt.getTime() >= since.getTime()),
|
||||
totalWindowSessions,
|
||||
);
|
||||
const riskLevel = classifyRisk(consecutiveAbsences, monthlyRate);
|
||||
if (!riskLevel) {
|
||||
continue;
|
||||
}
|
||||
atRisk.push({
|
||||
attendeeId: attendee.id,
|
||||
fullName: attendee.fullName,
|
||||
phone: attendee.phone,
|
||||
riskLevel,
|
||||
consecutiveAbsences,
|
||||
lastAttendedAt: attendedAt ? attendedAt.toISOString() : null,
|
||||
monthlyAttendanceRate: monthlyRate,
|
||||
});
|
||||
}
|
||||
|
||||
atRisk.sort((a, b) => {
|
||||
if (a.riskLevel !== b.riskLevel) {
|
||||
return a.riskLevel === 'HIGH' ? -1 : 1;
|
||||
}
|
||||
if (a.consecutiveAbsences !== b.consecutiveAbsences) {
|
||||
return b.consecutiveAbsences - a.consecutiveAbsences;
|
||||
}
|
||||
return a.monthlyAttendanceRate - b.monthlyAttendanceRate;
|
||||
});
|
||||
|
||||
return ok({
|
||||
groupName: access.value.name,
|
||||
data: atRisk,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function groupAttendanceByAttendee(rows: AttendanceStreamRow[]): Map<string, AttendanceStreamRow[]> {
|
||||
const byAttendee = new Map<string, AttendanceStreamRow[]>();
|
||||
for (const row of rows) {
|
||||
const list = byAttendee.get(row.attendeeId) ?? [];
|
||||
list.push(row);
|
||||
byAttendee.set(row.attendeeId, list);
|
||||
}
|
||||
return byAttendee;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { UpdateStudentStatus } from '@gruperly/shared';
|
||||
import { UpdateStudentStatusSchema } from '@gruperly/shared';
|
||||
import { Hono } from 'hono';
|
||||
import { problemJson, resultJson, unauthorizedProblem } from '@/http/problem-details';
|
||||
import { validate } from '@/http/validate';
|
||||
import { UpdateAttendeeStatus } from './use-case';
|
||||
|
||||
const route = new Hono();
|
||||
|
||||
route.post('/:studentId/status', validate.json(UpdateStudentStatusSchema), async (c) => {
|
||||
const user = c.get('user');
|
||||
if (!user) {
|
||||
return problemJson(c, unauthorizedProblem(c.req.path));
|
||||
}
|
||||
|
||||
const studentId = c.req.param('studentId');
|
||||
const payload = c.req.valid('json') as UpdateStudentStatus;
|
||||
const useCase = new UpdateAttendeeStatus();
|
||||
const result = await useCase.execute(studentId, user.id, payload);
|
||||
|
||||
return resultJson(c, result);
|
||||
});
|
||||
|
||||
export default route;
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { PrismaClient } from '@generated/prisma/client';
|
||||
import type {
|
||||
ProblemDetails,
|
||||
Result,
|
||||
UpdateStudentStatus,
|
||||
UpdateStudentStatusResult,
|
||||
} from '@gruperly/shared';
|
||||
import { err, ok } from '@gruperly/shared';
|
||||
import { noGroupAccessProblem, notFoundResourceProblem } from '@/http/problem-builders';
|
||||
import prisma from '@/lib/prisma';
|
||||
|
||||
type UpdateAttendeeStatusDeps = {
|
||||
db?: Pick<PrismaClient, 'attendee'>;
|
||||
};
|
||||
|
||||
export class UpdateAttendeeStatus {
|
||||
constructor(private readonly deps: UpdateAttendeeStatusDeps = {}) {}
|
||||
|
||||
async execute(
|
||||
studentId: string,
|
||||
userId: string,
|
||||
payload: UpdateStudentStatus,
|
||||
): Promise<Result<UpdateStudentStatusResult, ProblemDetails>> {
|
||||
const db = this.deps.db ?? prisma;
|
||||
|
||||
const attendee = await db.attendee.findUnique({
|
||||
where: { id: studentId },
|
||||
include: { group: { include: { members: { where: { userId } } } } },
|
||||
});
|
||||
|
||||
if (!attendee) {
|
||||
return err(notFoundResourceProblem('Attendee', studentId));
|
||||
}
|
||||
|
||||
const isOwner = attendee.group.createdById === userId;
|
||||
const isMember = attendee.group.members.length > 0;
|
||||
if (!isOwner && !isMember) {
|
||||
return err(noGroupAccessProblem());
|
||||
}
|
||||
|
||||
if (attendee.status === payload.status) {
|
||||
return ok({ attendeeId: studentId, status: payload.status });
|
||||
}
|
||||
|
||||
const updated = await db.attendee.update({
|
||||
where: { id: studentId },
|
||||
data: { status: payload.status },
|
||||
select: { id: true, status: true },
|
||||
});
|
||||
|
||||
return ok({ attendeeId: updated.id, status: updated.status });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user