From 8590579605bbe0acfe3f8ca54dc6444b768a3d60 Mon Sep 17 00:00:00 2001 From: Mahdi Date: Mon, 19 Oct 2026 13:34:55 +0330 Subject: [PATCH] feat: log staff sign-in and sign-out and force logout at a set time --- apps/api/package.json | 2 +- .../20261019130000_attendance/migration.sql | 25 +++ apps/api/prisma/schema.prisma | 25 +++ apps/api/src/attendance/attendance-time.ts | 44 ++++ apps/api/src/attendance/attendance.module.ts | 9 + apps/api/src/attendance/attendance.service.ts | 190 ++++++++++++++++++ .../api/src/attendance/workday.interceptor.ts | 27 +++ .../src/audit-logs/audit-logs.controller.ts | 19 +- apps/api/src/audit-logs/audit-logs.module.ts | 3 +- apps/api/src/auth/auth.controller.ts | 6 + apps/api/src/auth/auth.module.ts | 7 + apps/api/src/auth/auth.service.ts | 7 + apps/api/src/auth/jwt.strategy.ts | 3 +- apps/api/src/common/record-scope.ts | 2 + apps/api/src/users/dto.ts | 8 + apps/api/src/users/users.controller.ts | 16 ++ apps/api/src/users/users.service.ts | 27 +++ apps/api/test/attendance-time.test.ts | 17 ++ apps/web/src/layouts/AppLayout.vue | 6 +- apps/web/src/lib/api.ts | 11 +- apps/web/src/stores/auth.ts | 7 +- apps/web/src/views/AuditLogsView.vue | 87 +++++++- apps/web/src/views/LoginView.vue | 6 +- apps/web/src/views/TeamSettingsView.vue | 47 +++++ packages/shared/src/index.ts | 2 + 25 files changed, 588 insertions(+), 15 deletions(-) create mode 100644 apps/api/prisma/migrations/20261019130000_attendance/migration.sql create mode 100644 apps/api/src/attendance/attendance-time.ts create mode 100644 apps/api/src/attendance/attendance.module.ts create mode 100644 apps/api/src/attendance/attendance.service.ts create mode 100644 apps/api/src/attendance/workday.interceptor.ts create mode 100644 apps/api/test/attendance-time.test.ts diff --git a/apps/api/package.json b/apps/api/package.json index aea76fe..8699030 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -12,7 +12,7 @@ "prisma:seed": "tsx prisma/seed.ts", "lint": "echo \"api lint placeholder\"", "typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p test/tsconfig.json", - "test": "tsx --test test/tahesab-methods.test.ts test/dates.test.ts test/birthdays.test.ts test/message-automation.test.ts test/record-scope.test.ts test/gamification-engine.test.ts test/automation-branches.test.ts test/pipeline-merge.test.ts test/ask-count.test.ts test/prior-archive.test.ts test/organizations.test.ts test/promote-from-prior.test.ts test/behavior.test.ts" + "test": "tsx --test test/tahesab-methods.test.ts test/dates.test.ts test/birthdays.test.ts test/message-automation.test.ts test/record-scope.test.ts test/gamification-engine.test.ts test/automation-branches.test.ts test/pipeline-merge.test.ts test/ask-count.test.ts test/prior-archive.test.ts test/organizations.test.ts test/promote-from-prior.test.ts test/behavior.test.ts test/attendance-time.test.ts" }, "dependencies": { "@mento/shared": "*", diff --git a/apps/api/prisma/migrations/20261019130000_attendance/migration.sql b/apps/api/prisma/migrations/20261019130000_attendance/migration.sql new file mode 100644 index 0000000..8c225c4 --- /dev/null +++ b/apps/api/prisma/migrations/20261019130000_attendance/migration.sql @@ -0,0 +1,25 @@ +-- AlterTable +ALTER TABLE "Membership" ADD COLUMN "logoutTime" TEXT; + +-- CreateTable +CREATE TABLE "AttendanceEvent" ( + "id" TEXT NOT NULL, + "organizationId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "kind" TEXT NOT NULL, + "at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "afterHours" BOOLEAN NOT NULL DEFAULT false, + "sessionStartedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "AttendanceEvent_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "AttendanceEvent_organizationId_at_idx" ON "AttendanceEvent"("organizationId", "at"); +CREATE INDEX "AttendanceEvent_organizationId_userId_at_idx" ON "AttendanceEvent"("organizationId", "userId", "at"); +CREATE UNIQUE INDEX "AttendanceEvent_organizationId_userId_kind_sessionStartedAt_key" ON "AttendanceEvent"("organizationId", "userId", "kind", "sessionStartedAt"); + +-- AddForeignKey +ALTER TABLE "AttendanceEvent" ADD CONSTRAINT "AttendanceEvent_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "AttendanceEvent" ADD CONSTRAINT "AttendanceEvent_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index f13637e..57214c6 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -161,6 +161,7 @@ model Organization { teams Team[] companies Company[] people Person[] + attendanceEvents AttendanceEvent[] discAssessments DiscAssessment[] salesCopilotSessions SalesCopilotSession[] pipelines Pipeline[] @@ -312,6 +313,7 @@ model User { createdRoutineTasks RoutineTask[] @relation("RoutineTaskCreator") routineTaskEvents RoutineTaskEvent[] routineSettings RoutineUserSettings[] + attendanceEvents AttendanceEvent[] deletionArchives DeletionArchive[] @relation("DeletionActor") discAssessments DiscAssessment[] @relation("DiscAssessor") salesCopilotSessions SalesCopilotSession[] @relation("CopilotAuthor") @@ -369,6 +371,8 @@ model Membership { priorArchiveEnabled Boolean @default(false) /// current | archive priorArchiveLens String @default("current") + /// ساعت خروج روزانه به وقت تهران، مثل 18:00؛ خالی یعنی بدون محدودیت + logoutTime String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -3417,3 +3421,24 @@ model SalesCopilotSession { @@index([organizationId, leadId]) @@index([dealId]) } + +/// ورود و خروج کارمندان به سامانه +model AttendanceEvent { + id String @id @default(cuid()) + organizationId String + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + /// login | logout | forced_logout + kind String + at DateTime @default(now()) + /// ورود بعد از ساعت خروج تعیین‌شده + afterHours Boolean @default(false) + /// شروع نشست؛ برای اینکه خروج اجباری هر نشست فقط یک بار ثبت شود + sessionStartedAt DateTime? + createdAt DateTime @default(now()) + + @@index([organizationId, at]) + @@index([organizationId, userId, at]) + @@unique([organizationId, userId, kind, sessionStartedAt]) +} diff --git a/apps/api/src/attendance/attendance-time.ts b/apps/api/src/attendance/attendance-time.ts new file mode 100644 index 0000000..d90163c --- /dev/null +++ b/apps/api/src/attendance/attendance-time.ts @@ -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); +} diff --git a/apps/api/src/attendance/attendance.module.ts b/apps/api/src/attendance/attendance.module.ts new file mode 100644 index 0000000..5000b26 --- /dev/null +++ b/apps/api/src/attendance/attendance.module.ts @@ -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 {} diff --git a/apps/api/src/attendance/attendance.service.ts b/apps/api/src/attendance/attendance.service.ts new file mode 100644 index 0000000..4d25fdf --- /dev/null +++ b/apps/api/src/attendance/attendance.service.ts @@ -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); + } +} diff --git a/apps/api/src/attendance/workday.interceptor.ts b/apps/api/src/attendance/workday.interceptor.ts new file mode 100644 index 0000000..f300944 --- /dev/null +++ b/apps/api/src/attendance/workday.interceptor.ts @@ -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(); + } +} diff --git a/apps/api/src/audit-logs/audit-logs.controller.ts b/apps/api/src/audit-logs/audit-logs.controller.ts index 571bf97..38e93ce 100644 --- a/apps/api/src/audit-logs/audit-logs.controller.ts +++ b/apps/api/src/audit-logs/audit-logs.controller.ts @@ -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( diff --git a/apps/api/src/audit-logs/audit-logs.module.ts b/apps/api/src/audit-logs/audit-logs.module.ts index 80b9362..7de0d01 100644 --- a/apps/api/src/audit-logs/audit-logs.module.ts +++ b/apps/api/src/audit-logs/audit-logs.module.ts @@ -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, diff --git a/apps/api/src/auth/auth.controller.ts b/apps/api/src/auth/auth.controller.ts index c79c3fa..e9f3d35 100644 --- a/apps/api/src/auth/auth.controller.ts +++ b/apps/api/src/auth/auth.controller.ts @@ -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) { diff --git a/apps/api/src/auth/auth.module.ts b/apps/api/src/auth/auth.module.ts index a13dd73..c161805 100644 --- a/apps/api/src/auth/auth.module.ts +++ b/apps/api/src/auth/auth.module.ts @@ -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], }) diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts index 586b780..054df42 100644 --- a/apps/api/src/auth/auth.service.ts +++ b/apps/api/src/auth/auth.service.ts @@ -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 { 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, diff --git a/apps/api/src/auth/jwt.strategy.ts b/apps/api/src/auth/jwt.strategy.ts index b0c1ee2..6e16234 100644 --- a/apps/api/src/auth/jwt.strategy.ts +++ b/apps/api/src/auth/jwt.strategy.ts @@ -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, diff --git a/apps/api/src/common/record-scope.ts b/apps/api/src/common/record-scope.ts index a568d10..112c41b 100644 --- a/apps/api/src/common/record-scope.ts +++ b/apps/api/src/common/record-scope.ts @@ -27,6 +27,8 @@ export type Actor = { /** مالک واقعی وقتی در حالت مشاهده به‌جای کاربر دیگر است */ impersonatorUserId?: string; impersonatorRole?: string; + /** شروع این نشست؛ برای ساعت خروج روزانه */ + sessionStartedAt?: Date; /** در حالت مشاهده، سرور تغییر را رد می‌کند */ viewAsReadOnly?: boolean; }; diff --git a/apps/api/src/users/dto.ts b/apps/api/src/users/dto.ts index af771bc..b5bb363 100644 --- a/apps/api/src/users/dto.ts +++ b/apps/api/src/users/dto.ts @@ -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']) diff --git a/apps/api/src/users/users.controller.ts b/apps/api/src/users/users.controller.ts index 977dfd2..25dd403 100644 --- a/apps/api/src/users/users.controller.ts +++ b/apps/api/src/users/users.controller.ts @@ -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( diff --git a/apps/api/src/users/users.service.ts b/apps/api/src/users/users.service.ts index f69771a..179b070 100644 --- a/apps/api/src/users/users.service.ts +++ b/apps/api/src/users/users.service.ts @@ -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 { + 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(), }; } diff --git a/apps/api/test/attendance-time.test.ts b/apps/api/test/attendance-time.test.ts new file mode 100644 index 0000000..9dd61da --- /dev/null +++ b/apps/api/test/attendance-time.test.ts @@ -0,0 +1,17 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { loggedInAfterCutoff, tehranCutoff } from '../src/attendance/attendance-time'; + +describe('ساعت خروج تهران', () => { + it('ورود قبل از ۱۸ خروج اجباری می‌خواهد و ورود بعد از آن نه', () => { + const morning = new Date('2026-10-19T05:30:00.000Z'); // ۰۹:۰۰ تهران + const evening = new Date('2026-10-19T15:30:00.000Z'); // ۱۹:۰۰ تهران + const cutoff = tehranCutoff(evening, '18:00'); + assert.ok(cutoff); + assert.equal(morning.getTime() < cutoff.getTime(), true); + assert.equal(evening.getTime() >= cutoff.getTime(), true); + assert.equal(loggedInAfterCutoff(morning, '18:00'), false); + assert.equal(loggedInAfterCutoff(evening, '18:00'), true); + assert.equal(loggedInAfterCutoff(evening, null), false); + }); +}); diff --git a/apps/web/src/layouts/AppLayout.vue b/apps/web/src/layouts/AppLayout.vue index 353eb51..d38c4e5 100644 --- a/apps/web/src/layouts/AppLayout.vue +++ b/apps/web/src/layouts/AppLayout.vue @@ -300,12 +300,12 @@ function onMobileMqChange(event: MediaQueryListEvent) { } } -function onLogout() { +async function onLogout() { closeHub(); closeMenu(); closeNotif(); - auth.logout(); - router.push({ name: 'login' }); + await auth.logout(); + await router.push({ name: 'login' }); } async function refreshNotifs() { diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 14a93fc..128fa50 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -29,13 +29,20 @@ api.interceptors.response.use( if (path.startsWith('/p/') || path.startsWith('/adm')) { return Promise.reject(error); } + const raw = (error.response.data as { message?: string | string[] } | undefined)?.message; + const text = Array.isArray(raw) ? raw.join(' ') : String(raw ?? ''); + const hours = text.includes('ساعت کاری'); localStorage.removeItem('mento_token'); localStorage.removeItem('mento_user'); localStorage.removeItem('ment_token'); localStorage.removeItem('ment_user'); if (!path.startsWith('/login')) { - const redirect = encodeURIComponent(path + window.location.search); - window.location.assign(`/login?redirect=${redirect}`); + if (hours) { + window.location.assign('/login?reason=hours'); + } else { + const redirect = encodeURIComponent(path + window.location.search); + window.location.assign(`/login?redirect=${redirect}`); + } } } return Promise.reject(error); diff --git a/apps/web/src/stores/auth.ts b/apps/web/src/stores/auth.ts index 6bbfc62..1fdca36 100644 --- a/apps/web/src/stores/auth.ts +++ b/apps/web/src/stores/auth.ts @@ -157,7 +157,12 @@ export const useAuthStore = defineStore('auth', () => { persist(data); } - function logout() { + async function logout() { + try { + if (token.value) await api.post('/auth/logout'); + } catch { + /* نشست ممکن است از قبل بسته شده باشد */ + } token.value = null; user.value = null; clearAvatarUrl(); diff --git a/apps/web/src/views/AuditLogsView.vue b/apps/web/src/views/AuditLogsView.vue index dc81679..b981aef 100644 --- a/apps/web/src/views/AuditLogsView.vue +++ b/apps/web/src/views/AuditLogsView.vue @@ -26,13 +26,14 @@ import { import UiIcon from '@/components/UiIcon.vue'; import { formatClockFa, formatJalaliNumeric } from '@/lib/jalali'; -type LogTab = 'all' | 'creates' | 'edits' | 'deletes'; +type LogTab = 'all' | 'creates' | 'edits' | 'deletes' | 'attendance'; const TABS: Array<{ key: LogTab; label: string; action?: string }> = [ { key: 'all', label: 'همه' }, { key: 'creates', label: 'ایجاد', action: 'ایجاد' }, { key: 'edits', label: 'ویرایش', action: 'ویرایش' }, { key: 'deletes', label: 'حذف', action: 'حذف' }, + { key: 'attendance', label: 'ورود و خروج' }, ]; const ENTITY_LABELS: Record = { @@ -77,13 +78,28 @@ const page = ref(1); const pageSize = usePageSize('audit-logs'); const data = ref(null); const deletions = ref(null); +const attendance = ref<{ + items: Array<{ + id: string; + fullName: string; + kind: 'login' | 'logout' | 'forced_logout'; + at: string; + afterHours: boolean; + }>; + total: number; +} | null>(null); const retention = ref('never'); const purgeId = ref(null); const showingArchive = computed(() => tab.value === 'deletes' && isOwner.value); +const showingAttendance = computed(() => tab.value === 'attendance'); const listTotal = computed(() => - showingArchive.value ? deletions.value?.total : data.value?.total, + showingAttendance.value + ? attendance.value?.total + : showingArchive.value + ? deletions.value?.total + : data.value?.total, ); const totalPages = computed(() => { @@ -202,7 +218,23 @@ async function load(resetPage = false) { const current = tab.value; const archive = current === 'deletes' && isOwner.value; try { - if (archive) { + if (current === 'attendance') { + const { data: res } = await api.get<{ + items: NonNullable['items']; + total: number; + }>('/audit-logs/attendance', { + params: { + q: q.value.trim() || undefined, + actorUserId: actorId.value || undefined, + page: page.value, + limit: pageSize.value, + }, + }); + if (tab.value !== current) return; + attendance.value = res; + data.value = null; + deletions.value = null; + } else if (archive) { const { data: res } = await api.get('/deletions', { params: { q: q.value.trim() || undefined, @@ -229,6 +261,7 @@ async function load(resetPage = false) { if (tab.value !== current) return; data.value = res; deletions.value = null; + attendance.value = null; } } catch (e) { if (tab.value !== current) return; @@ -238,12 +271,19 @@ async function load(resetPage = false) { } } +function attendanceLabel(kind: string, afterHours: boolean) { + if (kind === 'login') return afterHours ? 'ورود بعد از ساعت خروج' : 'ورود'; + if (kind === 'forced_logout') return 'خروج در ساعت تعیین‌شده'; + return 'خروج'; +} + function selectTab(next: LogTab) { if (tab.value === next) return; tab.value = next; okMsg.value = ''; data.value = null; deletions.value = null; + attendance.value = null; void load(true); } @@ -382,7 +422,13 @@ onMounted(() => { /> + + @@ -437,6 +483,39 @@ onMounted(() => { /> + +
+ + + + + + + + + + + + + + + +
زمانکاربررویداد
+ {{ formatClockFa(new Date(item.at)) }} + {{ formatJalaliNumeric(new Date(item.at)) }} + {{ item.fullName }}{{ attendanceLabel(item.kind, item.afterHours) }}
+
+ +
+
diff --git a/apps/web/src/views/LoginView.vue b/apps/web/src/views/LoginView.vue index e765959..ee122a1 100644 --- a/apps/web/src/views/LoginView.vue +++ b/apps/web/src/views/LoginView.vue @@ -28,7 +28,11 @@ const remember = ref(true); const otpCode = ref(''); const challenge = ref(null); const pendingChoice = ref(null); -const error = ref(''); +const error = ref( + route.query.reason === 'hours' + ? 'ساعت کاری امروز تمام شده است. برای ادامه دوباره وارد شوید.' + : '', +); const loading = ref(false); const otpSecondsLeft = ref(0); let otpTimer: ReturnType | null = null; diff --git a/apps/web/src/views/TeamSettingsView.vue b/apps/web/src/views/TeamSettingsView.vue index a6efcdf..a5c83e7 100644 --- a/apps/web/src/views/TeamSettingsView.vue +++ b/apps/web/src/views/TeamSettingsView.vue @@ -492,6 +492,23 @@ async function toggleStatus(user: OrgUserDto) { } } +async function changeLogoutTime(user: OrgUserDto, raw: string) { + if (!canInvite.value || user.role === 'owner') return; + const next = raw.trim(); + if (next === (user.logoutTime ?? '')) return; + error.value = ''; + ok.value = ''; + try { + await api.patch(`/users/${user.id}/logout-time`, { logoutTime: next || null }); + ok.value = next + ? `ساعت خروج «${user.fullName}» روی ${toFaDigits(next)} تنظیم شد.` + : `محدودیت خروج «${user.fullName}» برداشته شد.`; + await load(); + } catch (e) { + error.value = apiError(e, 'ذخیره ساعت خروج ناموفق بود'); + } +} + async function changeCommission(user: OrgUserDto, raw: string) { if (!canInvite.value) return; const next = Number(raw); @@ -980,6 +997,7 @@ onUnmounted(() => { ▴ + ساعت خروج @@ -1019,6 +1037,19 @@ onUnmounted(() => { {{ formatLastLogin(user.lastLoginAt) }} + + — + +