feat: log staff sign-in and sign-out and force logout at a set time
This commit is contained in:
@@ -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": "*",
|
||||
|
||||
@@ -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;
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -27,6 +27,8 @@ export type Actor = {
|
||||
/** مالک واقعی وقتی در حالت مشاهده بهجای کاربر دیگر است */
|
||||
impersonatorUserId?: string;
|
||||
impersonatorRole?: string;
|
||||
/** شروع این نشست؛ برای ساعت خروج روزانه */
|
||||
sessionStartedAt?: Date;
|
||||
/** در حالت مشاهده، سرور تغییر را رد میکند */
|
||||
viewAsReadOnly?: boolean;
|
||||
};
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<string, string> = {
|
||||
@@ -77,13 +78,28 @@ const page = ref(1);
|
||||
const pageSize = usePageSize('audit-logs');
|
||||
const data = ref<AuditLogListResponse | null>(null);
|
||||
const deletions = ref<DeletionArchiveListResponse | null>(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<DeletionRetention>('never');
|
||||
const purgeId = ref<string | null>(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<typeof attendance.value>['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<DeletionArchiveListResponse>('/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(() => {
|
||||
/>
|
||||
|
||||
<UiEmptyState
|
||||
v-else-if="!showingArchive && !loading && data && !data.items.length"
|
||||
v-else-if="showingAttendance && !loading && attendance && !attendance.items.length"
|
||||
title="ورود و خروجی ثبت نشده"
|
||||
description="با ورود یا خروج کارمندان، اینجا دیده میشود."
|
||||
/>
|
||||
|
||||
<UiEmptyState
|
||||
v-else-if="!showingArchive && !showingAttendance && !loading && data && !data.items.length"
|
||||
title="رویدادی در این بخش نیست"
|
||||
description="با انجام کار در سامانه، ایجاد و ویرایش اینجا دیده میشود."
|
||||
/>
|
||||
@@ -437,6 +483,39 @@ onMounted(() => {
|
||||
/>
|
||||
</UiCard>
|
||||
|
||||
<UiCard v-else-if="showingAttendance && attendance?.items.length" title="ورود و خروج">
|
||||
<div class="table-wrap">
|
||||
<table data-no-rownum>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="when-col">زمان</th>
|
||||
<th>کاربر</th>
|
||||
<th>رویداد</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in attendance.items" :key="item.id">
|
||||
<td class="when" data-label="زمان">
|
||||
<span class="clock">{{ formatClockFa(new Date(item.at)) }}</span>
|
||||
<span>{{ formatJalaliNumeric(new Date(item.at)) }}</span>
|
||||
</td>
|
||||
<td data-label="کاربر">{{ item.fullName }}</td>
|
||||
<td data-label="رویداد">{{ attendanceLabel(item.kind, item.afterHours) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<UiPagination
|
||||
:page="page"
|
||||
:total-pages="totalPages"
|
||||
:total-items="attendance.total"
|
||||
:page-size="pageSize"
|
||||
:page-size-options="PAGE_SIZE_OPTIONS"
|
||||
@update:page="onPage"
|
||||
@update:page-size="onPageSize"
|
||||
/>
|
||||
</UiCard>
|
||||
|
||||
<UiCard v-else-if="data?.items.length" title="رویدادها">
|
||||
<div class="tc-row">
|
||||
<TableColumnsMenu table-key="audit-logs" align="end" />
|
||||
|
||||
@@ -28,7 +28,11 @@ const remember = ref(true);
|
||||
const otpCode = ref('');
|
||||
const challenge = ref<LoginChallengeResponse | null>(null);
|
||||
const pendingChoice = ref<LoginOrganizationChoiceResponse | null>(null);
|
||||
const error = ref('');
|
||||
const error = ref(
|
||||
route.query.reason === 'hours'
|
||||
? 'ساعت کاری امروز تمام شده است. برای ادامه دوباره وارد شوید.'
|
||||
: '',
|
||||
);
|
||||
const loading = ref(false);
|
||||
const otpSecondsLeft = ref(0);
|
||||
let otpTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
@@ -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(() => {
|
||||
<span :class="{ flip: !sortLoginDesc }">▴</span>
|
||||
</button>
|
||||
</th>
|
||||
<th>ساعت خروج</th>
|
||||
<th class="ops" />
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -1019,6 +1037,19 @@ onUnmounted(() => {
|
||||
</span>
|
||||
</td>
|
||||
<td class="muted">{{ formatLastLogin(user.lastLoginAt) }}</td>
|
||||
<td @click.stop>
|
||||
<span v-if="user.role === 'owner'" class="muted">—</span>
|
||||
<input
|
||||
v-else
|
||||
class="logout-time"
|
||||
type="time"
|
||||
dir="ltr"
|
||||
:value="user.logoutTime || ''"
|
||||
:disabled="!canInvite"
|
||||
:aria-label="`ساعت خروج ${user.fullName}`"
|
||||
@change="changeLogoutTime(user, ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</td>
|
||||
<td class="ops" @click.stop>
|
||||
<UiDropdownMenu
|
||||
:open="menuUserId === user.id"
|
||||
@@ -1825,6 +1856,22 @@ tbody tr + tr td {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.logout-time {
|
||||
width: 7.2rem;
|
||||
min-height: 2rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
padding: 0 0.45rem;
|
||||
font: inherit;
|
||||
background: #fff;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.logout-time:disabled {
|
||||
background: #f8fafc;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.roles-grid,
|
||||
.roles-tree {
|
||||
display: grid;
|
||||
|
||||
@@ -376,6 +376,8 @@ export interface OrgUserDto {
|
||||
dataVisibilityScopes?: DataVisibilityScopes;
|
||||
/** تیک مالک: این کاربر سوئیچ بایگانی اطلاعات قبل را ببیند */
|
||||
priorArchiveEnabled?: boolean;
|
||||
/** ساعت خروج روزانه HH:mm به وقت تهران؛ خالی یعنی بدون محدودیت */
|
||||
logoutTime?: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user