feat: log staff sign-in and sign-out and force logout at a set time

This commit is contained in:
2026-10-19 13:34:55 +03:30
parent 3863c5e7d1
commit 8590579605
25 changed files with 588 additions and 15 deletions
@@ -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();
}
}
@@ -8,6 +8,7 @@ import { RolesGuard } from '../auth/roles.guard';
import { OrgModuleGuard } from '../billing/org-module.guard';
import { RequireOrgModule } from '../billing/org-module.decorator';
import { AuditLogsService } from './audit-logs.service';
import { AttendanceService } from '../attendance/attendance.service';
class ListAuditQueryDto {
@IsOptional()
@@ -45,7 +46,23 @@ class ListAuditQueryDto {
@RequireOrgModule('audit_logs')
@Roles('owner', 'admin')
export class AuditLogsController {
constructor(private readonly auditLogs: AuditLogsService) {}
constructor(
private readonly auditLogs: AuditLogsService,
private readonly attendance: AttendanceService,
) {}
@Get('attendance')
attendanceList(
@CurrentUser() user: { organizationId: string },
@Query() query: ListAuditQueryDto,
) {
return this.attendance.list(user.organizationId, {
q: query.q,
userId: query.actorUserId,
page: query.page,
limit: query.limit,
});
}
@Get()
list(
+2 -1
View File
@@ -2,12 +2,13 @@ import { Module } from '@nestjs/common';
import { APP_INTERCEPTOR } from '@nestjs/core';
import { AuthModule } from '../auth/auth.module';
import { BillingModule } from '../billing/billing.module';
import { AttendanceModule } from '../attendance/attendance.module';
import { AuditLogsController } from './audit-logs.controller';
import { AuditLogsInterceptor } from './audit-logs.interceptor';
import { AuditLogsService } from './audit-logs.service';
@Module({
imports: [AuthModule, BillingModule],
imports: [AuthModule, BillingModule, AttendanceModule],
controllers: [AuditLogsController],
providers: [
AuditLogsService,
+6
View File
@@ -80,6 +80,12 @@ export class AuthController {
return this.authService.listMyOrganizations(user.userId);
}
@UseGuards(JwtAuthGuard)
@Post('logout')
logout(@CurrentUser() user: Actor) {
return this.authService.logout(user);
}
@UseGuards(JwtAuthGuard)
@Get('me')
me(@CurrentUser() user: Actor) {
+7
View File
@@ -5,6 +5,8 @@ import { PassportModule } from '@nestjs/passport';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { MembershipsModule } from '../memberships/memberships.module';
import { BillingModule } from '../billing/billing.module';
import { AttendanceModule } from '../attendance/attendance.module';
import { WorkdayInterceptor } from '../attendance/workday.interceptor';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { JwtStrategy } from './jwt.strategy';
@@ -17,6 +19,7 @@ import { SmsService } from '../common/sms.service';
ConfigModule,
MembershipsModule,
BillingModule,
AttendanceModule,
PassportModule.register({ defaultStrategy: 'jwt' }),
JwtModule.registerAsync({
imports: [ConfigModule],
@@ -39,6 +42,10 @@ import { SmsService } from '../common/sms.service';
provide: APP_INTERCEPTOR,
useClass: ViewAsReadOnlyInterceptor,
},
{
provide: APP_INTERCEPTOR,
useClass: WorkdayInterceptor,
},
],
exports: [AuthService, RolesGuard, JwtModule],
})
+7
View File
@@ -26,6 +26,7 @@ import { SmsService } from '../common/sms.service';
import { LocalFileStorage } from '../storage/local-file.storage';
import { MembershipsService } from '../memberships/memberships.service';
import { BillingService } from '../billing/billing.service';
import { AttendanceService } from '../attendance/attendance.service';
import type { Actor } from '../common/record-scope';
import {
LoginDto,
@@ -110,6 +111,7 @@ export class AuthService {
private readonly files: LocalFileStorage,
private readonly memberships: MembershipsService,
private readonly billing: BillingService,
private readonly attendance: AttendanceService,
) {}
/**
@@ -407,6 +409,10 @@ export class AuthService {
return this.finishLogin(user.id);
}
async logout(actor: Actor) {
return this.attendance.recordLogout(actor);
}
async me(userId: string, organizationId: string, actor?: Actor): Promise<AuthUserDto> {
await this.memberships.requireActive(userId, organizationId);
const user = await this.prisma.user.findUnique({ where: { id: userId } });
@@ -727,6 +733,7 @@ export class AuthService {
where: { id: userId },
data: { lastLoginAt: new Date() },
});
await this.attendance.recordLogin(organizationId, userId, membership.role);
}
const accessToken = this.jwt.sign({
sub: userId,
+2 -1
View File
@@ -34,7 +34,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
});
}
async validate(payload: JwtPayload & { typ?: string; mobile?: string }) {
async validate(payload: JwtPayload & { typ?: string; mobile?: string; iat?: number }) {
if (
payload.typ === 'portal' ||
payload.typ === 'org-select' ||
@@ -135,6 +135,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
priorGranted && membership.organization.priorArchiveEnabledAt
? membership.organization.priorArchiveEnabledAt
: null,
sessionStartedAt: payload.iat ? new Date(payload.iat * 1000) : new Date(),
...(viewAsReadOnly && impersonatorUserId
? {
impersonatorUserId,
+2
View File
@@ -27,6 +27,8 @@ export type Actor = {
/** مالک واقعی وقتی در حالت مشاهده به‌جای کاربر دیگر است */
impersonatorUserId?: string;
impersonatorRole?: string;
/** شروع این نشست؛ برای ساعت خروج روزانه */
sessionStartedAt?: Date;
/** در حالت مشاهده، سرور تغییر را رد می‌کند */
viewAsReadOnly?: boolean;
};
+8
View File
@@ -89,6 +89,14 @@ export class UpdateUserCommissionDto {
commissionPercent!: number;
}
export class UpdateLogoutTimeDto {
@IsOptional()
@ValidateIf((_, value) => value != null && value !== '')
@IsString()
@Matches(/^([01]\d|2[0-3]):[0-5]\d$/, { message: 'ساعت خروج معتبر نیست' })
logoutTime?: string | null;
}
export class UpdateUserVisibilityDto {
@IsOptional()
@IsIn(['own', 'team', 'org'])
+16
View File
@@ -17,6 +17,7 @@ import {
InviteUserDto,
UpdateOrgUserDto,
UpdateUserCommissionDto,
UpdateLogoutTimeDto,
UpdateUserRoleDto,
UpdateUserStatusDto,
UpdateUserVisibilityDto,
@@ -66,6 +67,21 @@ export class UsersController {
);
}
@Patch(':id/logout-time')
@Roles('owner', 'admin')
updateLogoutTime(
@CurrentUser() user: { organizationId: string; role: UserRole },
@Param('id') id: string,
@Body() dto: UpdateLogoutTimeDto,
) {
return this.usersService.updateLogoutTime(
user.organizationId,
user.role,
id,
dto.logoutTime ?? null,
);
}
@Patch(':id/commission')
@Roles('owner', 'admin')
updateCommission(
+27
View File
@@ -45,6 +45,7 @@ type MembershipRow = {
dataVisibility?: string | null;
dataVisibilityScopes?: unknown;
priorArchiveEnabled?: boolean;
logoutTime?: string | null;
createdAt: Date;
orgRole: { id: string; name: string; color: string } | null;
user: {
@@ -242,6 +243,31 @@ export class UsersService {
return this.dtoOf(organizationId, targetId);
}
async updateLogoutTime(
organizationId: string,
actorRole: UserRole,
targetId: string,
logoutTime: string | null,
): Promise<OrgUserDto> {
if (actorRole !== 'owner' && actorRole !== 'admin') {
throw new ForbiddenException('فقط مالک یا مدیر می‌تواند ساعت خروج را تعیین کند');
}
const target = await this.memberships.findUserInOrgById(organizationId, targetId);
if (!target) throw new NotFoundException('کاربر پیدا نشد');
if (target.role === 'owner') {
throw new BadRequestException('برای مالک ساعت خروج تعیین نمی‌شود');
}
const value = logoutTime?.trim() || null;
if (value && !/^([01]\d|2[0-3]):[0-5]\d$/.test(value)) {
throw new BadRequestException('ساعت خروج معتبر نیست');
}
await this.prisma.membership.update({
where: { id: target.id },
data: { logoutTime: value },
});
return this.dtoOf(organizationId, targetId);
}
async updatePriorArchive(
organizationId: string,
actorRole: UserRole,
@@ -506,6 +532,7 @@ export class UsersService {
? {}
: parseVisibilityScopes(row.dataVisibilityScopes),
priorArchiveEnabled: Boolean(row.priorArchiveEnabled),
logoutTime: row.role === 'owner' ? null : row.logoutTime ?? null,
createdAt: row.createdAt.toISOString(),
};
}