feat: log staff sign-in and sign-out and force logout at a set time
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
const TEHRAN = 'Asia/Tehran';
|
||||
|
||||
export type AttendanceKind = 'login' | 'logout' | 'forced_logout';
|
||||
|
||||
export function tehranDateKey(date: Date): string {
|
||||
const parts = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: TEHRAN,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).format(date);
|
||||
return parts;
|
||||
}
|
||||
|
||||
/** ساعت HH:mm امروز به وقت تهران، به صورت لحظهٔ مطلق */
|
||||
export function tehranCutoff(now: Date, hhmm: string): Date | null {
|
||||
const match = /^([01]\d|2[0-3]):([0-5]\d)$/.exec(hhmm.trim());
|
||||
if (!match) return null;
|
||||
const day = tehranDateKey(now);
|
||||
return new Date(`${day}T${match[1]}:${match[2]}:00+03:30`);
|
||||
}
|
||||
|
||||
export function isPastLogoutTime(now: Date, hhmm: string | null | undefined): boolean {
|
||||
if (!hhmm) return false;
|
||||
const cutoff = tehranCutoff(now, hhmm);
|
||||
if (!cutoff) return false;
|
||||
return now.getTime() >= cutoff.getTime();
|
||||
}
|
||||
|
||||
export function loggedInAfterCutoff(at: Date, hhmm: string | null | undefined): boolean {
|
||||
if (!hhmm) return false;
|
||||
const cutoff = tehranCutoff(at, hhmm);
|
||||
if (!cutoff) return false;
|
||||
return at.getTime() >= cutoff.getTime();
|
||||
}
|
||||
|
||||
export function tehranClock(date: Date): string {
|
||||
return new Intl.DateTimeFormat('fa-IR', {
|
||||
timeZone: TEHRAN,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hourCycle: 'h23',
|
||||
}).format(date);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { SmsService } from '../common/sms.service';
|
||||
import { AttendanceService } from './attendance.service';
|
||||
|
||||
@Module({
|
||||
providers: [AttendanceService, SmsService],
|
||||
exports: [AttendanceService],
|
||||
})
|
||||
export class AttendanceModule {}
|
||||
@@ -0,0 +1,190 @@
|
||||
import { Injectable, Logger, UnauthorizedException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import type { Actor } from '../common/record-scope';
|
||||
import { SmsService } from '../common/sms.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import {
|
||||
loggedInAfterCutoff,
|
||||
tehranClock,
|
||||
tehranCutoff,
|
||||
type AttendanceKind,
|
||||
} from './attendance-time';
|
||||
|
||||
export const WORKDAY_ENDED_MESSAGE =
|
||||
'ساعت کاری امروز تمام شده است. برای ادامه دوباره وارد شوید.';
|
||||
|
||||
type RecordInput = {
|
||||
organizationId: string;
|
||||
userId: string;
|
||||
role: string;
|
||||
kind: AttendanceKind;
|
||||
at: Date;
|
||||
afterHours: boolean;
|
||||
sessionStartedAt: Date | null;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class AttendanceService {
|
||||
private readonly logger = new Logger(AttendanceService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly sms: SmsService,
|
||||
) {}
|
||||
|
||||
async recordLogin(organizationId: string, userId: string, role: string, at = new Date()) {
|
||||
if (role === 'owner') {
|
||||
await this.write({
|
||||
organizationId,
|
||||
userId,
|
||||
role,
|
||||
kind: 'login',
|
||||
at,
|
||||
afterHours: false,
|
||||
sessionStartedAt: at,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const membership = await this.prisma.membership.findFirst({
|
||||
where: { organizationId, userId, isActive: true },
|
||||
select: { logoutTime: true },
|
||||
});
|
||||
const afterHours = loggedInAfterCutoff(at, membership?.logoutTime);
|
||||
await this.write({
|
||||
organizationId,
|
||||
userId,
|
||||
role,
|
||||
kind: 'login',
|
||||
at,
|
||||
afterHours,
|
||||
sessionStartedAt: at,
|
||||
});
|
||||
}
|
||||
|
||||
async recordLogout(actor: Actor) {
|
||||
if (actor.viewAsReadOnly) return { ok: true };
|
||||
const at = new Date();
|
||||
await this.write({
|
||||
organizationId: actor.organizationId,
|
||||
userId: actor.userId,
|
||||
role: actor.role,
|
||||
kind: 'logout',
|
||||
at,
|
||||
afterHours: false,
|
||||
sessionStartedAt: actor.sessionStartedAt ?? at,
|
||||
});
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/** اگر نشست قبل از ساعت خروج امروز شروع شده و آن ساعت گذشته، یک بار خروج اجباری ثبت و رد میشود. */
|
||||
async assertSession(actor: Actor) {
|
||||
if (actor.viewAsReadOnly || actor.role === 'owner' || !actor.sessionStartedAt) return;
|
||||
const membership = await this.prisma.membership.findFirst({
|
||||
where: { userId: actor.userId, organizationId: actor.organizationId, isActive: true },
|
||||
select: { logoutTime: true, role: true },
|
||||
});
|
||||
const logoutTime = membership?.logoutTime;
|
||||
if (!logoutTime || membership?.role === 'owner') return;
|
||||
const now = new Date();
|
||||
const cutoff = tehranCutoff(now, logoutTime);
|
||||
if (!cutoff || now.getTime() < cutoff.getTime()) return;
|
||||
if (actor.sessionStartedAt.getTime() >= cutoff.getTime()) return;
|
||||
|
||||
await this.write({
|
||||
organizationId: actor.organizationId,
|
||||
userId: actor.userId,
|
||||
role: actor.role,
|
||||
kind: 'forced_logout',
|
||||
at: now,
|
||||
afterHours: false,
|
||||
sessionStartedAt: actor.sessionStartedAt,
|
||||
});
|
||||
throw new UnauthorizedException(WORKDAY_ENDED_MESSAGE);
|
||||
}
|
||||
|
||||
async list(
|
||||
organizationId: string,
|
||||
opts: { q?: string; userId?: string; page?: number; limit?: number },
|
||||
) {
|
||||
const page = opts.page ?? 1;
|
||||
const limit = opts.limit ?? 20;
|
||||
const q = opts.q?.trim();
|
||||
const where: Prisma.AttendanceEventWhereInput = {
|
||||
organizationId,
|
||||
...(opts.userId ? { userId: opts.userId } : {}),
|
||||
...(q
|
||||
? { user: { fullName: { contains: q, mode: 'insensitive' } } }
|
||||
: {}),
|
||||
};
|
||||
const [rows, total] = await this.prisma.$transaction([
|
||||
this.prisma.attendanceEvent.findMany({
|
||||
where,
|
||||
include: { user: { select: { fullName: true } } },
|
||||
orderBy: { at: 'desc' },
|
||||
skip: (page - 1) * limit,
|
||||
take: limit,
|
||||
}),
|
||||
this.prisma.attendanceEvent.count({ where }),
|
||||
]);
|
||||
return {
|
||||
items: rows.map((row) => ({
|
||||
id: row.id,
|
||||
userId: row.userId,
|
||||
fullName: row.user.fullName,
|
||||
kind: row.kind as AttendanceKind,
|
||||
at: row.at.toISOString(),
|
||||
afterHours: row.afterHours,
|
||||
})),
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
};
|
||||
}
|
||||
|
||||
private async write(input: RecordInput) {
|
||||
try {
|
||||
await this.prisma.attendanceEvent.create({
|
||||
data: {
|
||||
organizationId: input.organizationId,
|
||||
userId: input.userId,
|
||||
kind: input.kind,
|
||||
at: input.at,
|
||||
afterHours: input.afterHours,
|
||||
sessionStartedAt: input.sessionStartedAt,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||
return;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
if (input.role === 'owner') return;
|
||||
void this.notifyOwner(input).catch((err) => {
|
||||
this.logger.warn(`پیامک ورود و خروج ناموفق بود: ${err instanceof Error ? err.message : err}`);
|
||||
});
|
||||
}
|
||||
|
||||
private async notifyOwner(input: RecordInput) {
|
||||
const owner = await this.prisma.membership.findFirst({
|
||||
where: { organizationId: input.organizationId, role: 'owner', isActive: true },
|
||||
select: { userId: true, user: { select: { mobile: true } } },
|
||||
});
|
||||
if (!owner?.user.mobile || owner.userId === input.userId) return;
|
||||
const person = await this.prisma.user.findUnique({
|
||||
where: { id: input.userId },
|
||||
select: { fullName: true },
|
||||
});
|
||||
const name = person?.fullName?.trim() || 'کارمند';
|
||||
const clock = tehranClock(input.at);
|
||||
const text =
|
||||
input.kind === 'login'
|
||||
? input.afterHours
|
||||
? `${name} بعد از ساعت خروج دوباره وارد منتکت شد. ساعت ${clock}`
|
||||
: `${name} وارد منتکت شد. ساعت ${clock}`
|
||||
: input.kind === 'forced_logout'
|
||||
? `${name} در ساعت تعیینشده از منتکت خارج شد. ساعت ${clock}`
|
||||
: `${name} از منتکت خارج شد. ساعت ${clock}`;
|
||||
await this.sms.sendSmsDetailed(owner.user.mobile, text);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import {
|
||||
CallHandler,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
} from '@nestjs/common';
|
||||
import type { Actor } from '../common/record-scope';
|
||||
import { AttendanceService } from './attendance.service';
|
||||
|
||||
@Injectable()
|
||||
export class WorkdayInterceptor implements NestInterceptor {
|
||||
constructor(private readonly attendance: AttendanceService) {}
|
||||
|
||||
async intercept(context: ExecutionContext, next: CallHandler) {
|
||||
if (context.getType() !== 'http') return next.handle();
|
||||
const req = context.switchToHttp().getRequest<{
|
||||
user?: Actor;
|
||||
originalUrl?: string;
|
||||
url?: string;
|
||||
}>();
|
||||
const actor = req.user;
|
||||
const path = req.originalUrl || req.url || '';
|
||||
if (!actor?.userId || path.includes('/auth/logout')) return next.handle();
|
||||
await this.attendance.assertSession(actor);
|
||||
return next.handle();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user